完善后台管理功能
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(fetchSocialBiMaster).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchSocialBiOverview).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
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" }));
|
expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
||||||
|
|
||||||
// 经营概览:App 名、Hero 汇总充值(12,300 + 5,000 USD 分 = $173)、
|
// 经营概览: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",
|
section: "new_users",
|
||||||
userRole: "all"
|
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("下载APP人数")).toBeTruthy();
|
||||||
expect(screen.getByText("注册成功率")).toBeTruthy();
|
expect(screen.getByText("注册成功率")).toBeTruthy();
|
||||||
expect(screen.getAllByText("12").length).toBeGreaterThan(0);
|
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();
|
expect(screen.queryByRole("radiogroup", { name: "付费身份" })).toBeNull();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
screen.getByRole("tab", { name: "P1营收" }).click();
|
screen.getByRole("tab", { name: "营收" }).click();
|
||||||
});
|
});
|
||||||
await flushEffects();
|
await flushEffects();
|
||||||
expect(screen.getByRole("radiogroup", { name: "用户身份" })).toBeTruthy();
|
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 });
|
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 = {};
|
const query = {};
|
||||||
if (statTz) {
|
if (statTz) {
|
||||||
query.stat_tz = statTz;
|
query.stat_tz = statTz;
|
||||||
@ -208,6 +208,9 @@ export async function fetchSocialBiRequirements({ appCodes, endMs, payerType, re
|
|||||||
if (payerType) {
|
if (payerType) {
|
||||||
query.payer_type = payerType;
|
query.payer_type = payerType;
|
||||||
}
|
}
|
||||||
|
if (newUserType) {
|
||||||
|
query.new_user_type = newUserType;
|
||||||
|
}
|
||||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiRequirements), { query });
|
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiRequirements), { query });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export function SocialBiApp() {
|
|||||||
const initial = useMemo(() => readStateFromURL(), []);
|
const initial = useMemo(() => readStateFromURL(), []);
|
||||||
const [filters, setFilters] = useState(initial.filters);
|
const [filters, setFilters] = useState(initial.filters);
|
||||||
const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview"));
|
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(() => {
|
useEffect(() => {
|
||||||
writeStateToURL(filters, viewKey);
|
writeStateToURL(filters, viewKey);
|
||||||
|
|||||||
@ -249,6 +249,24 @@ export function aggregateMetricMaps(rows, { acrossTime = false } = {}) {
|
|||||||
return out;
|
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 品牌色:chips、图表系列、行标识统一取色,保证同一 App 全站同色。
|
||||||
// 已知 App 用固定映射(与筛选状态无关);未知 App 按 code 哈希取色,避免"选中子集后颜色漂移"。
|
// 已知 App 用固定映射(与筛选状态无关);未知 App 按 code 哈希取色,避免"选中子集后颜色漂移"。
|
||||||
const APP_COLOR_PALETTE = ["#2563eb", "#0e9488", "#d97706", "#7c3aed", "#db2777", "#059669", "#dc2626", "#4f46e5"];
|
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_TZ = DEFAULT_TIME_ZONE;
|
||||||
export const SOCIAL_BI_FUNNEL_APP_CODES = ["lalu", "huwaa", "fami"];
|
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);
|
const [master, setMaster] = useState(null);
|
||||||
// 数据请求等 master 结果落地后再发:否则会先用兜底 App 列表打一轮,master 到达后立刻重打一轮(整轮浪费)。
|
// 数据请求等 master 结果落地后再发:否则会先用兜底 App 列表打一轮,master 到达后立刻重打一轮(整轮浪费)。
|
||||||
const [isMasterSettled, setIsMasterSettled] = useState(false);
|
const [isMasterSettled, setIsMasterSettled] = useState(false);
|
||||||
@ -78,9 +78,13 @@ export function useSocialBiData(filters) {
|
|||||||
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
|
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
|
||||||
const endMs = rangeEndMs(range, SOCIAL_BI_TZ);
|
const endMs = rangeEndMs(range, SOCIAL_BI_TZ);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
// 漏斗会执行 cohort 聚合,只在用户进入漏斗视图时请求;其他视图不能为不可见数据承担查询成本或错误提示。
|
||||||
|
const funnelRequest = includeFunnel
|
||||||
|
? fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
|
||||||
|
: Promise.resolve(null);
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
fetchSocialBiOverview({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }),
|
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 })
|
fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
|
||||||
]).then(([overviewResult, funnelResult, kpiResult]) => {
|
]).then(([overviewResult, funnelResult, kpiResult]) => {
|
||||||
if (sequence !== requestSeq.current) {
|
if (sequence !== requestSeq.current) {
|
||||||
@ -98,7 +102,9 @@ export function useSocialBiData(filters) {
|
|||||||
setOverview(null);
|
setOverview(null);
|
||||||
nextErrors.push(`统计数据加载失败: ${overviewResult.reason?.message || "未知错误"}`);
|
nextErrors.push(`统计数据加载失败: ${overviewResult.reason?.message || "未知错误"}`);
|
||||||
}
|
}
|
||||||
if (funnelResult.status === "fulfilled") {
|
if (!includeFunnel) {
|
||||||
|
setFunnel(null);
|
||||||
|
} else if (funnelResult.status === "fulfilled") {
|
||||||
setFunnel(funnelResult.value);
|
setFunnel(funnelResult.value);
|
||||||
(funnelResult.value?.apps || []).forEach((app) => {
|
(funnelResult.value?.apps || []).forEach((app) => {
|
||||||
if (app.error) {
|
if (app.error) {
|
||||||
@ -121,10 +127,10 @@ export function useSocialBiData(filters) {
|
|||||||
setErrors(nextErrors);
|
setErrors(nextErrors);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
}, [appCodes, funnelAppCodes, isMasterSettled, range, refreshToken]);
|
}, [appCodes, funnelAppCodes, includeFunnel, isMasterSettled, range, refreshToken]);
|
||||||
|
|
||||||
const loadRequirements = useCallback(
|
const loadRequirements = useCallback(
|
||||||
({ payerType, section, userRole } = {}) => {
|
({ newUserType, payerType, section, userRole } = {}) => {
|
||||||
if (!isMasterSettled) {
|
if (!isMasterSettled) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
@ -145,6 +151,7 @@ export function useSocialBiData(filters) {
|
|||||||
return Promise.all(requestScopes.map((scope) => fetchSocialBiRequirements({
|
return Promise.all(requestScopes.map((scope) => fetchSocialBiRequirements({
|
||||||
...scope,
|
...scope,
|
||||||
endMs,
|
endMs,
|
||||||
|
newUserType,
|
||||||
payerType,
|
payerType,
|
||||||
section,
|
section,
|
||||||
startMs,
|
startMs,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { useSocialBi } from "../SocialBiApp.jsx";
|
|||||||
import {
|
import {
|
||||||
METRIC_GROUPS,
|
METRIC_GROUPS,
|
||||||
aggregateMetricMaps,
|
aggregateMetricMaps,
|
||||||
|
averageMetricMaps,
|
||||||
formatMetric,
|
formatMetric,
|
||||||
metricChartValue,
|
metricChartValue,
|
||||||
metricLabel,
|
metricLabel,
|
||||||
@ -54,7 +55,7 @@ const REQUIREMENT_SECTIONS = [
|
|||||||
{ key: "first_room_join_users", label: "首次进房", type: "count" }
|
{ key: "first_room_join_users", label: "首次进房", type: "count" }
|
||||||
],
|
],
|
||||||
key: "new_users",
|
key: "new_users",
|
||||||
label: "P0新用户"
|
label: "新用户"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
csvName: "p1-revenue",
|
csvName: "p1-revenue",
|
||||||
@ -66,21 +67,23 @@ const REQUIREMENT_SECTIONS = [
|
|||||||
{ key: "arppu_usd_minor", label: "ARPPU", type: "money_minor" }
|
{ key: "arppu_usd_minor", label: "ARPPU", type: "money_minor" }
|
||||||
],
|
],
|
||||||
key: "revenue",
|
key: "revenue",
|
||||||
label: "P1营收",
|
label: "营收",
|
||||||
|
newUserFilter: true,
|
||||||
payerFilter: true,
|
payerFilter: true,
|
||||||
roleFilter: true
|
roleFilter: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
csvName: "p2-retention",
|
csvName: "p2-retention",
|
||||||
fallbackColumns: [
|
fallbackColumns: [
|
||||||
{ key: "cohort_users", label: "留存基数", type: "count" },
|
{ key: "active_d1_active_base_users", label: "活跃用户基数", type: "count" },
|
||||||
{ key: "d1_retention_users", label: "次留用户", type: "count" },
|
{ key: "active_d1_active_users", label: "次留人数", type: "count" },
|
||||||
{ key: "d1_retention_rate", label: "次留率", type: "ratio" },
|
{ key: "active_d1_active_rate", label: "用户次留", type: "ratio" },
|
||||||
{ key: "d7_retention_rate", label: "7日留存", type: "ratio" },
|
{ key: "active_d7_active_rate", label: "用户7日留", type: "ratio" },
|
||||||
{ key: "d30_retention_rate", label: "30日留存", type: "ratio" }
|
{ key: "active_d30_active_rate", label: "用户30日留", type: "ratio" }
|
||||||
],
|
],
|
||||||
key: "retention",
|
key: "retention",
|
||||||
label: "P2留存",
|
label: "留存",
|
||||||
|
newUserFilter: true,
|
||||||
payerFilter: true,
|
payerFilter: true,
|
||||||
roleFilter: true
|
roleFilter: true
|
||||||
},
|
},
|
||||||
@ -94,7 +97,8 @@ const REQUIREMENT_SECTIONS = [
|
|||||||
{ key: "avg_session_ms", label: "人均在线", type: "duration_ms" }
|
{ key: "avg_session_ms", label: "人均在线", type: "duration_ms" }
|
||||||
],
|
],
|
||||||
key: "active",
|
key: "active",
|
||||||
label: "P2活跃",
|
label: "活跃",
|
||||||
|
newUserFilter: true,
|
||||||
payerFilter: true,
|
payerFilter: true,
|
||||||
roleFilter: true
|
roleFilter: true
|
||||||
},
|
},
|
||||||
@ -107,7 +111,8 @@ const REQUIREMENT_SECTIONS = [
|
|||||||
{ key: "gift_paid_users", label: "付费送礼用户", type: "count" }
|
{ key: "gift_paid_users", label: "付费送礼用户", type: "count" }
|
||||||
],
|
],
|
||||||
key: "gift",
|
key: "gift",
|
||||||
label: "P4送礼",
|
label: "送礼",
|
||||||
|
newUserFilter: true,
|
||||||
roleFilter: true
|
roleFilter: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -120,7 +125,8 @@ const REQUIREMENT_SECTIONS = [
|
|||||||
{ key: "game_profit_rate", label: "游戏利润率", type: "ratio" }
|
{ key: "game_profit_rate", label: "游戏利润率", type: "ratio" }
|
||||||
],
|
],
|
||||||
key: "game",
|
key: "game",
|
||||||
label: "P5游戏",
|
label: "游戏",
|
||||||
|
newUserFilter: true,
|
||||||
roleFilter: true
|
roleFilter: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@ -137,6 +143,23 @@ const PAYER_TYPE_OPTIONS = [
|
|||||||
{ label: "未付费", value: "unpaid" }
|
{ 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() {
|
function readClosedGroups() {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(GROUPS_STORAGE_KEY);
|
const raw = window.localStorage.getItem(GROUPS_STORAGE_KEY);
|
||||||
@ -236,6 +259,18 @@ function normalizedRequirementColumns(sectionItem, fallbackColumns) {
|
|||||||
return columns.length ? columns : 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) {
|
function normalizeRequirementPayload(payload, sectionKey) {
|
||||||
const fallbackColumns = requirementSectionConfig(sectionKey).fallbackColumns;
|
const fallbackColumns = requirementSectionConfig(sectionKey).fallbackColumns;
|
||||||
const appSections = asArray(payload?.apps).flatMap((app) => {
|
const appSections = asArray(payload?.apps).flatMap((app) => {
|
||||||
@ -251,21 +286,27 @@ function normalizeRequirementPayload(payload, sectionKey) {
|
|||||||
if (appSections.length === 1) {
|
if (appSections.length === 1) {
|
||||||
const { context, section } = appSections[0];
|
const { context, section } = appSections[0];
|
||||||
return {
|
return {
|
||||||
|
average: flattenRequirementRow(section.average, context),
|
||||||
columns,
|
columns,
|
||||||
rows: asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
|
rows: sortRequirementDailyRows(
|
||||||
.map((row) => flattenRequirementRow(row, context))
|
asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
|
||||||
.filter(Boolean),
|
.map((row) => flattenRequirementRow(row, context))
|
||||||
|
.filter(Boolean)
|
||||||
|
),
|
||||||
total: flattenRequirementRow(section.total || section.totals || section.summary, context)
|
total: flattenRequirementRow(section.total || section.totals || section.summary, context)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const rows = appSections.flatMap(({ context, section }) => {
|
const rows = appSections.flatMap(({ context, section }) => {
|
||||||
const total = flattenRequirementRow(section.total || section.totals || section.summary, { ...context, stat_day: "汇总" });
|
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)
|
const average = flattenRequirementRow(section.average, { ...context, stat_day: "平均值" });
|
||||||
.map((row) => flattenRequirementRow(row, context))
|
const dailyRows = sortRequirementDailyRows(
|
||||||
.filter(Boolean);
|
asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
|
||||||
return total ? [total, ...dailyRows] : dailyRows;
|
.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))
|
const sectionItem = asArray(payload?.sections).find((item) => sectionMatches(item, sectionKey))
|
||||||
@ -273,17 +314,64 @@ function normalizeRequirementPayload(payload, sectionKey) {
|
|||||||
|| payload
|
|| payload
|
||||||
|| {};
|
|| {};
|
||||||
const columns = normalizedRequirementColumns(sectionItem, fallbackColumns);
|
const columns = normalizedRequirementColumns(sectionItem, fallbackColumns);
|
||||||
const rows = asArray(sectionItem.daily_rows || sectionItem.dailyRows || sectionItem.daily_series || sectionItem.rows || sectionItem.items)
|
const rows = sortRequirementDailyRows(
|
||||||
.map((row) => flattenRequirementRow(row))
|
asArray(sectionItem.daily_rows || sectionItem.dailyRows || sectionItem.daily_series || sectionItem.rows || sectionItem.items)
|
||||||
.filter(Boolean);
|
.map((row) => flattenRequirementRow(row))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
const total = flattenRequirementRow(sectionItem.total || sectionItem.totals || sectionItem.summary);
|
const total = flattenRequirementRow(sectionItem.total || sectionItem.totals || sectionItem.summary);
|
||||||
return {
|
return {
|
||||||
|
average: flattenRequirementRow(sectionItem.average),
|
||||||
columns,
|
columns,
|
||||||
rows,
|
rows,
|
||||||
total
|
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) {
|
function inferRequirementType(column) {
|
||||||
const type = String(column.type || "").toLowerCase();
|
const type = String(column.type || "").toLowerCase();
|
||||||
const key = String(column.key || "").toLowerCase();
|
const key = String(column.key || "").toLowerCase();
|
||||||
@ -302,13 +390,17 @@ function inferRequirementType(column) {
|
|||||||
return type || "number";
|
return type || "number";
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateTwoDecimals(value) {
|
// 文档展示规则:百分比固定保留 2 位小数(截断补零);整数展示整数;非整数展示 2 位小数(超出截断)。
|
||||||
|
function truncateTwoDecimals(value, { pad = false } = {}) {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
if (!Number.isFinite(numeric)) {
|
if (!Number.isFinite(numeric)) {
|
||||||
return "--";
|
return "--";
|
||||||
}
|
}
|
||||||
const truncated = Math.trunc(numeric * 100) / 100;
|
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) {
|
function formatRequirementRatio(value) {
|
||||||
@ -316,7 +408,7 @@ function formatRequirementRatio(value) {
|
|||||||
if (!Number.isFinite(numeric)) {
|
if (!Number.isFinite(numeric)) {
|
||||||
return "--";
|
return "--";
|
||||||
}
|
}
|
||||||
return `${truncateTwoDecimals(numeric * 100)}%`;
|
return `${truncateTwoDecimals(numeric * 100, { pad: true })}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRequirementValue(column, value) {
|
function formatRequirementValue(column, value) {
|
||||||
@ -325,7 +417,11 @@ function formatRequirementValue(column, value) {
|
|||||||
}
|
}
|
||||||
const type = inferRequirementType(column);
|
const type = inferRequirementType(column);
|
||||||
if (type === "money_minor") {
|
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") {
|
if (type === "ratio") {
|
||||||
return formatRequirementRatio(value);
|
return formatRequirementRatio(value);
|
||||||
@ -334,11 +430,16 @@ function formatRequirementValue(column, value) {
|
|||||||
return formatDurationMs(value);
|
return formatDurationMs(value);
|
||||||
}
|
}
|
||||||
if (type === "coin" || type === "count" || type === "integer") {
|
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);
|
return formatCount(value);
|
||||||
}
|
}
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
if (Number.isFinite(numeric)) {
|
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);
|
return String(value);
|
||||||
}
|
}
|
||||||
@ -384,6 +485,7 @@ export function DataTableView() {
|
|||||||
const [requirementSection, setRequirementSection] = useState(REQUIREMENT_SECTIONS[0].key);
|
const [requirementSection, setRequirementSection] = useState(REQUIREMENT_SECTIONS[0].key);
|
||||||
const [requirementRole, setRequirementRole] = useState("all");
|
const [requirementRole, setRequirementRole] = useState("all");
|
||||||
const [requirementPayer, setRequirementPayer] = useState("all");
|
const [requirementPayer, setRequirementPayer] = useState("all");
|
||||||
|
const [requirementNewUser, setRequirementNewUser] = useState("all");
|
||||||
|
|
||||||
const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0];
|
const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0];
|
||||||
const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]);
|
const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]);
|
||||||
@ -392,10 +494,16 @@ export function DataTableView() {
|
|||||||
const activeRequirementSection = requirementSectionConfig(requirementSection);
|
const activeRequirementSection = requirementSectionConfig(requirementSection);
|
||||||
const effectiveRequirementRole = activeRequirementSection.roleFilter ? requirementRole : "all";
|
const effectiveRequirementRole = activeRequirementSection.roleFilter ? requirementRole : "all";
|
||||||
const effectiveRequirementPayer = activeRequirementSection.payerFilter ? requirementPayer : "all";
|
const effectiveRequirementPayer = activeRequirementSection.payerFilter ? requirementPayer : "all";
|
||||||
|
const effectiveRequirementNewUser = activeRequirementSection.newUserFilter ? requirementNewUser : "all";
|
||||||
const requirementData = useMemo(() => normalizeRequirementPayload(requirements, requirementSection), [requirements, requirementSection]);
|
const requirementData = useMemo(() => normalizeRequirementPayload(requirements, requirementSection), [requirements, requirementSection]);
|
||||||
const requirementColumns = requirementData.columns;
|
const requirementColumns = requirementData.columns;
|
||||||
const requirementRows = requirementData.rows;
|
const requirementRows = requirementData.rows;
|
||||||
const requirementTotal = requirementData.total;
|
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 visibleGroups = useMemo(() => METRIC_GROUPS.filter((group) => !closedGroups.includes(group.key)), [closedGroups]);
|
||||||
const visibleMetrics = useMemo(() => visibleGroups.flatMap((group) => group.metrics), [visibleGroups]);
|
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 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 renderRows = sortedRows.slice(0, MAX_RENDER_ROWS);
|
||||||
const isTruncated = sortedRows.length > MAX_RENDER_ROWS;
|
const isTruncated = sortedRows.length > MAX_RENDER_ROWS;
|
||||||
const hasRestricted = baseRows.some((row) => row.restricted);
|
const hasRestricted = baseRows.some((row) => row.restricted);
|
||||||
@ -431,11 +542,12 @@ export function DataTableView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadRequirements({
|
loadRequirements({
|
||||||
|
newUserType: effectiveRequirementNewUser,
|
||||||
payerType: effectiveRequirementPayer,
|
payerType: effectiveRequirementPayer,
|
||||||
section: requirementSection,
|
section: requirementSection,
|
||||||
userRole: effectiveRequirementRole
|
userRole: effectiveRequirementRole
|
||||||
});
|
});
|
||||||
}, [effectiveRequirementPayer, effectiveRequirementRole, loadRequirements, mode, refreshToken, requirementSection]);
|
}, [effectiveRequirementNewUser, effectiveRequirementPayer, effectiveRequirementRole, loadRequirements, mode, refreshToken, requirementSection]);
|
||||||
|
|
||||||
const handleDimChange = (key) => {
|
const handleDimChange = (key) => {
|
||||||
setDimKey(key);
|
setDimKey(key);
|
||||||
@ -451,6 +563,9 @@ export function DataTableView() {
|
|||||||
if (!nextSection.payerFilter) {
|
if (!nextSection.payerFilter) {
|
||||||
setRequirementPayer("all");
|
setRequirementPayer("all");
|
||||||
}
|
}
|
||||||
|
if (!nextSection.newUserFilter) {
|
||||||
|
setRequirementNewUser("all");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleGroup = (key) => {
|
const toggleGroup = (key) => {
|
||||||
@ -480,6 +595,7 @@ export function DataTableView() {
|
|||||||
const header = ["日期", "App", ...requirementColumns.map((column) => column.label)];
|
const header = ["日期", "App", ...requirementColumns.map((column) => column.label)];
|
||||||
const rows = [
|
const rows = [
|
||||||
...(requirementTotal ? [{ ...requirementTotal, app_name: "全部", stat_day: "汇总" }] : []),
|
...(requirementTotal ? [{ ...requirementTotal, app_name: "全部", stat_day: "汇总" }] : []),
|
||||||
|
...(requirementAverage ? [{ ...requirementAverage, app_name: "全部", stat_day: "平均值" }] : []),
|
||||||
...requirementRows
|
...requirementRows
|
||||||
].map((row) => [
|
].map((row) => [
|
||||||
row.stat_day || "--",
|
row.stat_day || "--",
|
||||||
@ -489,6 +605,17 @@ export function DataTableView() {
|
|||||||
downloadCsv(`social-bi-requirements-${activeRequirementSection.csvName}-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
|
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;
|
let wideContent;
|
||||||
if (isLoading && !baseRows.length) {
|
if (isLoading && !baseRows.length) {
|
||||||
wideContent = (
|
wideContent = (
|
||||||
@ -559,6 +686,18 @@ export function DataTableView() {
|
|||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : 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) => (
|
{renderRows.map((row, rowIndex) => (
|
||||||
<tr key={rowIdentity(row, dim.key, rowIndex)}>
|
<tr key={rowIdentity(row, dim.key, rowIndex)}>
|
||||||
{dimColumns.map((column, index) => (
|
{dimColumns.map((column, index) => (
|
||||||
@ -607,6 +746,7 @@ export function DataTableView() {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
requirementsContent = (
|
requirementsContent = (
|
||||||
|
<>
|
||||||
<div className="sbi-table-scroll sbi-dt-scroll">
|
<div className="sbi-table-scroll sbi-dt-scroll">
|
||||||
<table className="sbi-table sbi-dt-req-table">
|
<table className="sbi-table sbi-dt-req-table">
|
||||||
<thead>
|
<thead>
|
||||||
@ -632,6 +772,15 @@ export function DataTableView() {
|
|||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : 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) => (
|
{requirementRows.map((row, rowIndex) => (
|
||||||
<tr key={requirementRowIdentity(row, activeRequirementSection.key, rowIndex)}>
|
<tr key={requirementRowIdentity(row, activeRequirementSection.key, rowIndex)}>
|
||||||
<td className="is-left sbi-dt-sticky">{row.stat_day || "--"}</td>
|
<td className="is-left sbi-dt-sticky">{row.stat_day || "--"}</td>
|
||||||
@ -644,6 +793,48 @@ export function DataTableView() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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}
|
onChange={setRequirementPayer}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{activeRequirementSection.newUserFilter ? (
|
||||||
|
<RequirementFilterGroup
|
||||||
|
ariaLabel="是否新用户"
|
||||||
|
label="是否新用户"
|
||||||
|
options={NEW_USER_OPTIONS}
|
||||||
|
value={effectiveRequirementNewUser}
|
||||||
|
onChange={setRequirementNewUser}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="sbi-dt-export">
|
<div className="sbi-dt-export">
|
||||||
|
{mode === "requirements" && requirementGameData.rows.length ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleGameBreakdownExport}
|
||||||
|
size="small"
|
||||||
|
startIcon={<FileDownloadOutlined />}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
导出游戏明细
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
disabled={exportDisabled}
|
disabled={exportDisabled}
|
||||||
onClick={mode === "requirements" ? handleRequirementsExport : handleExport}
|
onClick={mode === "requirements" ? handleRequirementsExport : handleExport}
|
||||||
|
|||||||
@ -467,15 +467,8 @@ function coinText(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function billAmountText(item) {
|
function billAmountText(item) {
|
||||||
if (item.billAmountText) {
|
// 账单金额是平台统一核算的 USDT 口径;currencyCode/providerAmountMinor 属于用户向三方渠道实付的本地币种,不能混到本列。
|
||||||
return item.billAmountText;
|
return formatUsdMinor(item.usdMinorAmount, "USDT");
|
||||||
}
|
|
||||||
const amount = hasNumber(item.billAmountMinor)
|
|
||||||
? item.billAmountMinor
|
|
||||||
: hasPositiveNumber(item.providerAmountMinor)
|
|
||||||
? item.providerAmountMinor
|
|
||||||
: item.usdMinorAmount;
|
|
||||||
return formatUsdMinor(amount, item.currencyCode || "USD");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function userPaidText(item) {
|
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.getAllByText("谷歌充值").length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText("tx-google")).toBeInTheDocument();
|
expect(screen.getByText("tx-google")).toBeInTheDocument();
|
||||||
expect(screen.getByText("cmd-1 / GPA.1")).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();
|
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", () => {
|
test("keeps empty recharge detail table colspan aligned with visible columns", () => {
|
||||||
const { container } = render(<FinanceRechargeDetailList {...baseProps} />);
|
const { container } = render(<FinanceRechargeDetailList {...baseProps} />);
|
||||||
|
|
||||||
|
|||||||
@ -113,6 +113,8 @@ export const PERMISSIONS = {
|
|||||||
appUserUpdate: "app-user:update",
|
appUserUpdate: "app-user:update",
|
||||||
appUserStatus: "app-user:status",
|
appUserStatus: "app-user:status",
|
||||||
appUserPassword: "app-user:password",
|
appUserPassword: "app-user:password",
|
||||||
|
appUserLevel: "app-user:level",
|
||||||
|
appUserExport: "app-user:export",
|
||||||
prettyIdView: "pretty-id:view",
|
prettyIdView: "pretty-id:view",
|
||||||
prettyIdUpdate: "pretty-id:update",
|
prettyIdUpdate: "pretty-id:update",
|
||||||
prettyIdGenerate: "pretty-id:generate",
|
prettyIdGenerate: "pretty-id:generate",
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { setAccessToken } from "@/shared/api/request";
|
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(() => {
|
afterEach(() => {
|
||||||
setAccessToken("");
|
setAccessToken("");
|
||||||
@ -351,3 +360,147 @@ test("getAppUser uses detail endpoint and normalizes assets", async () => {
|
|||||||
resourceCode: "frame_gold",
|
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 { apiRequest } from "@/shared/api/request";
|
||||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
import type {
|
import type {
|
||||||
|
AdminJobDto,
|
||||||
ApiPage,
|
ApiPage,
|
||||||
|
AppUserBanPayload,
|
||||||
AppUserBanRecordDto,
|
AppUserBanRecordDto,
|
||||||
|
AppUserExportJobDto,
|
||||||
|
AppUserLevelAdjustmentPayload,
|
||||||
AppUserLoginLogDto,
|
AppUserLoginLogDto,
|
||||||
AppUserDto,
|
AppUserDto,
|
||||||
AppUserPasswordPayload,
|
AppUserPasswordPayload,
|
||||||
|
AppUserUnbanPayload,
|
||||||
AppUserUpdatePayload,
|
AppUserUpdatePayload,
|
||||||
AdminGrantPrettyDisplayIDResultDto,
|
AdminGrantPrettyDisplayIDResultDto,
|
||||||
EntityId,
|
EntityId,
|
||||||
@ -76,20 +81,54 @@ export async function updateAppUser(userId: EntityId, payload: AppUserUpdatePayl
|
|||||||
return normalizeAppUser(data);
|
return normalizeAppUser(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function banAppUser(userId: EntityId): Promise<AppUserDto> {
|
export async function adjustAppUserLevels(
|
||||||
const endpoint = API_ENDPOINTS.appBanUser;
|
userId: EntityId,
|
||||||
const data = await apiRequest<RawAppUser>(apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }), {
|
payload: AppUserLevelAdjustmentPayload,
|
||||||
method: endpoint.method,
|
): 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);
|
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 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,
|
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(
|
export function setAppUserPassword(
|
||||||
@ -213,8 +252,11 @@ export async function setPrettyDisplayIDStatus(
|
|||||||
|
|
||||||
// 用户相关接口历史上既返回过 snake_case,也返回过 camelCase;在 API 边界归一化后,共享用户组件只消费稳定字段。
|
// 用户相关接口历史上既返回过 snake_case,也返回过 camelCase;在 API 边界归一化后,共享用户组件只消费稳定字段。
|
||||||
interface RawAppUser {
|
interface RawAppUser {
|
||||||
|
age?: number | null;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
balances?: RawAppUserAssetBalance[];
|
balances?: RawAppUserAssetBalance[];
|
||||||
|
ban?: RawAppUserBan;
|
||||||
|
birth?: string;
|
||||||
coin?: number;
|
coin?: number;
|
||||||
country?: string;
|
country?: string;
|
||||||
country_display_name?: string;
|
country_display_name?: string;
|
||||||
@ -233,6 +275,13 @@ interface RawAppUser {
|
|||||||
gender?: string;
|
gender?: string;
|
||||||
last_active_at_ms?: number;
|
last_active_at_ms?: number;
|
||||||
lastActiveAtMs?: 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;
|
pretty_display_user_id?: string;
|
||||||
prettyDisplayUserId?: string;
|
prettyDisplayUserId?: string;
|
||||||
pretty_id?: string;
|
pretty_id?: string;
|
||||||
@ -241,7 +290,10 @@ interface RawAppUser {
|
|||||||
regionId?: number;
|
regionId?: number;
|
||||||
region_name?: string;
|
region_name?: string;
|
||||||
regionName?: string;
|
regionName?: string;
|
||||||
|
register_device?: string;
|
||||||
|
registerDevice?: string;
|
||||||
resources?: RawAppUserResource[];
|
resources?: RawAppUserResource[];
|
||||||
|
roles?: string[];
|
||||||
status?: string;
|
status?: string;
|
||||||
updated_at_ms?: number;
|
updated_at_ms?: number;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
@ -249,6 +301,55 @@ interface RawAppUser {
|
|||||||
userId?: EntityId;
|
userId?: EntityId;
|
||||||
username?: string;
|
username?: string;
|
||||||
vip?: RawAppUserVIP;
|
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;
|
type RawAppUserBrief = RawAppUser;
|
||||||
@ -503,8 +604,11 @@ function normalizePage<TRaw, TItem>(
|
|||||||
|
|
||||||
function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
|
function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
|
||||||
return {
|
return {
|
||||||
|
age: optionalNumberValue(item.age),
|
||||||
avatar: stringValue(item.avatar),
|
avatar: stringValue(item.avatar),
|
||||||
balances: (item.balances || []).map(normalizeAppUserAssetBalance),
|
balances: (item.balances || []).map(normalizeAppUserAssetBalance),
|
||||||
|
ban: normalizeAppUserBan(item.ban),
|
||||||
|
birth: stringValue(item.birth),
|
||||||
coin: numberValue(item.coin),
|
coin: numberValue(item.coin),
|
||||||
country: stringValue(item.country),
|
country: stringValue(item.country),
|
||||||
countryDisplayName: stringValue(item.countryDisplayName ?? item.country_display_name),
|
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),
|
equippedResources: (item.equippedResources ?? item.equipped_resources ?? []).map(normalizeAppUserResource),
|
||||||
gender: stringValue(item.gender),
|
gender: stringValue(item.gender),
|
||||||
lastActiveAtMs: numberValue(item.lastActiveAtMs ?? item.last_active_at_ms),
|
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),
|
prettyDisplayUserId: stringValue(item.prettyDisplayUserId ?? item.pretty_display_user_id),
|
||||||
prettyId: stringValue(item.prettyId ?? item.pretty_id),
|
prettyId: stringValue(item.prettyId ?? item.pretty_id),
|
||||||
regionId: numberValue(item.regionId ?? item.region_id),
|
regionId: numberValue(item.regionId ?? item.region_id),
|
||||||
regionName: stringValue(item.regionName ?? item.region_name),
|
regionName: stringValue(item.regionName ?? item.region_name),
|
||||||
|
registerDevice: stringValue(item.registerDevice ?? item.register_device),
|
||||||
resources: (item.resources || []).map(normalizeAppUserResource),
|
resources: (item.resources || []).map(normalizeAppUserResource),
|
||||||
|
roles: Array.isArray(item.roles) ? item.roles.map(stringValue).filter(Boolean) : [],
|
||||||
status: stringValue(item.status),
|
status: stringValue(item.status),
|
||||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
userId: stringValue(item.userId ?? item.user_id),
|
userId: stringValue(item.userId ?? item.user_id),
|
||||||
username: stringValue(item.username),
|
username: stringValue(item.username),
|
||||||
vip: normalizeAppUserVIP(item.vip),
|
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;
|
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 即可。
|
// 靓号明细包含池号和后台发放两类来源,pool 可能为空,归一化后页面只判断 source/status 即可。
|
||||||
function normalizePrettyDisplayID(item: RawPrettyDisplayID): PrettyDisplayIDDto {
|
function normalizePrettyDisplayID(item: RawPrettyDisplayID): PrettyDisplayIDDto {
|
||||||
const createdByAdminId = item.createdByAdminId ?? item.created_by_admin_id ?? "";
|
const createdByAdminId = item.createdByAdminId ?? item.created_by_admin_id ?? "";
|
||||||
|
|||||||
@ -413,6 +413,28 @@
|
|||||||
line-height: 1.3;
|
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 {
|
.detailHero {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -669,6 +691,20 @@
|
|||||||
gap: var(--space-3);
|
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) {
|
@media (max-width: 760px) {
|
||||||
.prettyTabsBar {
|
.prettyTabsBar {
|
||||||
padding: 0 var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
|
|||||||
@ -6,6 +6,14 @@ import { formatMillis } from "@/shared/utils/time.js";
|
|||||||
import { getAppUser } from "@/features/app-users/api";
|
import { getAppUser } from "@/features/app-users/api";
|
||||||
import { appUserStatusLabels } from "@/features/app-users/constants.js";
|
import { appUserStatusLabels } from "@/features/app-users/constants.js";
|
||||||
import { AppUserDetailContext } from "@/features/app-users/components/AppUserDetailContext.jsx";
|
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";
|
import styles from "@/features/app-users/app-users.module.css";
|
||||||
|
|
||||||
export function AppUserDetailProvider({ children }) {
|
export function AppUserDetailProvider({ children }) {
|
||||||
@ -89,22 +97,43 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) {
|
|||||||
<DetailItem label="靓号" value={<PrettyValue user={user} />} />
|
<DetailItem label="靓号" value={<PrettyValue user={user} />} />
|
||||||
<DetailItem label="靓号记录 ID" value={user.prettyId} />
|
<DetailItem label="靓号记录 ID" value={user.prettyId} />
|
||||||
<DetailItem label="昵称" value={user.username} />
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className={styles.detailSection}>
|
<section className={styles.detailSection}>
|
||||||
<h3 className={styles.detailSectionTitle}>账号状态</h3>
|
<h3 className={styles.detailSectionTitle}>账号状态</h3>
|
||||||
<div className={styles.detailGrid}>
|
<div className={styles.detailGrid}>
|
||||||
<DetailItem label="状态" value={appUserStatusLabels[user.status] || user.status || "-"} />
|
<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.coin)} />
|
||||||
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
|
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
|
||||||
|
<DetailItem label="累计充值" value={formatUsdMinor(user.cumulativeRechargeUsdMinor)} />
|
||||||
<DetailItem label="国家" value={formatCountry(user)} />
|
<DetailItem label="国家" value={formatCountry(user)} />
|
||||||
<DetailItem
|
<DetailItem
|
||||||
label="区域"
|
label="区域"
|
||||||
value={user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-")}
|
value={user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-")}
|
||||||
/>
|
/>
|
||||||
|
<DetailItem label="最新成功登录" value={formatMillis(user.lastLoginAtMs)} />
|
||||||
<DetailItem label="最近活跃" value={formatMillis(user.lastActiveAtMs)} />
|
<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.createdAtMs)} />
|
||||||
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
|
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
|
||||||
</div>
|
</div>
|
||||||
@ -248,25 +277,26 @@ function formatCountry(user) {
|
|||||||
return user.countryDisplayName || user.countryName || user.country || "-";
|
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) {
|
function formatNumber(value) {
|
||||||
const number = Number(value || 0);
|
const number = Number(value || 0);
|
||||||
return Number.isFinite(number) ? number.toLocaleString() : "-";
|
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) {
|
function formatAssetType(value) {
|
||||||
const normalized = String(value || "").toUpperCase();
|
const normalized = String(value || "").toUpperCase();
|
||||||
const labels = {
|
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 { useEffect, useMemo, useState } from "react";
|
||||||
|
import { downloadResponse } from "@/shared/api/download";
|
||||||
import { parseForm } from "@/shared/forms/validation";
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.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 { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { emptyUserIdentityFilter, userIdentityFilterParams } from "@/shared/ui/userIdentityFilter.js";
|
import { emptyUserIdentityFilter, userIdentityFilterParams } from "@/shared/ui/userIdentityFilter.js";
|
||||||
import {
|
import {
|
||||||
|
adjustAppUserLevels,
|
||||||
banAppUser,
|
banAppUser,
|
||||||
|
createAppUserExport,
|
||||||
|
downloadAppUserExport,
|
||||||
|
getAppUserExportJob,
|
||||||
listAppUsers,
|
listAppUsers,
|
||||||
setAppUserPassword,
|
setAppUserPassword,
|
||||||
unbanAppUser,
|
unbanAppUser,
|
||||||
updateAppUser,
|
updateAppUser,
|
||||||
} from "@/features/app-users/api";
|
} from "@/features/app-users/api";
|
||||||
import { useAppUserAbilities } from "@/features/app-users/permissions.js";
|
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";
|
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
|
||||||
|
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
@ -21,6 +32,7 @@ const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
|||||||
const emptyEditForm = () => ({ avatar: "", gender: "", username: "" });
|
const emptyEditForm = () => ({ avatar: "", gender: "", username: "" });
|
||||||
const emptyCountryForm = () => ({ country: "" });
|
const emptyCountryForm = () => ({ country: "" });
|
||||||
const emptyPasswordForm = () => ({ password: "" });
|
const emptyPasswordForm = () => ({ password: "" });
|
||||||
|
const emptyBanForm = () => ({ expiresAtLocal: futureDateTimeLocal(7), mode: "permanent", reason: "" });
|
||||||
|
|
||||||
export function useAppUsersPage() {
|
export function useAppUsersPage() {
|
||||||
const abilities = useAppUserAbilities();
|
const abilities = useAppUserAbilities();
|
||||||
@ -41,6 +53,8 @@ export function useAppUsersPage() {
|
|||||||
const [editForm, setEditForm] = useState(emptyEditForm);
|
const [editForm, setEditForm] = useState(emptyEditForm);
|
||||||
const [countryForm, setCountryForm] = useState(emptyCountryForm);
|
const [countryForm, setCountryForm] = useState(emptyCountryForm);
|
||||||
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
|
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
|
||||||
|
const [levelForm, setLevelForm] = useState(() => levelFormFromUser(null));
|
||||||
|
const [banForm, setBanForm] = useState(emptyBanForm);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState([]);
|
const [selectedUserIds, setSelectedUserIds] = useState([]);
|
||||||
const [patchedUsers, setPatchedUsers] = useState({});
|
const [patchedUsers, setPatchedUsers] = useState({});
|
||||||
@ -141,6 +155,8 @@ export function useAppUsersPage() {
|
|||||||
setRegionId("");
|
setRegionId("");
|
||||||
setStatus("");
|
setStatus("");
|
||||||
setTimeRange({ endMs: "", startMs: "" });
|
setTimeRange({ endMs: "", startMs: "" });
|
||||||
|
setSortBy("created_at");
|
||||||
|
setSortDirection("desc");
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -151,6 +167,7 @@ export function useAppUsersPage() {
|
|||||||
gender: user.gender || "",
|
gender: user.gender || "",
|
||||||
username: user.username || "",
|
username: user.username || "",
|
||||||
});
|
});
|
||||||
|
setLevelForm(levelFormFromUser(user));
|
||||||
setActiveAction("edit");
|
setActiveAction("edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -183,9 +200,30 @@ export function useAppUsersPage() {
|
|||||||
}
|
}
|
||||||
await runAction("edit", "用户已更新", async () => {
|
await runAction("edit", "用户已更新", async () => {
|
||||||
const payload = parseForm(appUserUpdateSchema, editForm);
|
const payload = parseForm(appUserUpdateSchema, editForm);
|
||||||
await updateAppUser(activeUser.userId, payload);
|
const result = await updateAppUser(activeUser.userId, payload);
|
||||||
closeAction();
|
patchUsers([result]);
|
||||||
await reload();
|
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 toggleBan = async (user) => {
|
||||||
const banned = isBanned(user);
|
const banned = isBanned(user);
|
||||||
await runAction(`status-${user.userId}`, banned ? "用户已解封" : "用户已封禁", async () => {
|
if (!banned) {
|
||||||
const result = banned ? await unbanAppUser(user.userId) : await banAppUser(user.userId);
|
setActiveUser(user);
|
||||||
patchUsers([mergeUserStatus(user, result, banned ? "active" : "banned")]);
|
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));
|
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 batchBan = async () => {
|
||||||
const users = (data.items || []).filter((user) => selectedUserIds.includes(user.userId) && !isBanned(user));
|
const users = (data.items || []).filter((user) => selectedUserIds.includes(user.userId) && !isBanned(user));
|
||||||
if (!users.length) {
|
if (!users.length) {
|
||||||
@ -231,7 +301,7 @@ export function useAppUsersPage() {
|
|||||||
}
|
}
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
confirmText: "封禁",
|
confirmText: "封禁",
|
||||||
message: `将封禁 ${users.length} 个用户。`,
|
message: `将永久封禁 ${users.length} 个用户。`,
|
||||||
title: "批量封禁用户",
|
title: "批量封禁用户",
|
||||||
tone: "danger",
|
tone: "danger",
|
||||||
});
|
});
|
||||||
@ -241,7 +311,11 @@ export function useAppUsersPage() {
|
|||||||
|
|
||||||
setLoadingAction("batch-ban");
|
setLoadingAction("batch-ban");
|
||||||
try {
|
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
|
const updatedUsers = results
|
||||||
.map((result, index) =>
|
.map((result, index) =>
|
||||||
result.status === "fulfilled" ? mergeUserStatus(users[index], result.value, "banned") : null,
|
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) => {
|
const runAction = async (action, successMessage, submitter) => {
|
||||||
setLoadingAction(action);
|
setLoadingAction(action);
|
||||||
try {
|
try {
|
||||||
@ -299,6 +390,7 @@ export function useAppUsersPage() {
|
|||||||
abilities,
|
abilities,
|
||||||
activeAction,
|
activeAction,
|
||||||
activeUser,
|
activeUser,
|
||||||
|
banForm,
|
||||||
batchBan,
|
batchBan,
|
||||||
changeUserFilter,
|
changeUserFilter,
|
||||||
changeStatus,
|
changeStatus,
|
||||||
@ -313,6 +405,7 @@ export function useAppUsersPage() {
|
|||||||
loadingAction,
|
loadingAction,
|
||||||
loadingCountries,
|
loadingCountries,
|
||||||
loadingRegions,
|
loadingRegions,
|
||||||
|
levelForm,
|
||||||
locationFilterOptions,
|
locationFilterOptions,
|
||||||
locationFilterValue,
|
locationFilterValue,
|
||||||
openCountry,
|
openCountry,
|
||||||
@ -327,7 +420,9 @@ export function useAppUsersPage() {
|
|||||||
resetFilters,
|
resetFilters,
|
||||||
selectedUserIds,
|
selectedUserIds,
|
||||||
setCountryForm,
|
setCountryForm,
|
||||||
|
setBanForm,
|
||||||
setEditForm,
|
setEditForm,
|
||||||
|
setLevelForm,
|
||||||
setPage,
|
setPage,
|
||||||
setPasswordForm,
|
setPasswordForm,
|
||||||
setSelectedUserIds,
|
setSelectedUserIds,
|
||||||
@ -339,9 +434,12 @@ export function useAppUsersPage() {
|
|||||||
changeSort,
|
changeSort,
|
||||||
changeTimeRange,
|
changeTimeRange,
|
||||||
submitCountry,
|
submitCountry,
|
||||||
|
submitBan,
|
||||||
submitEdit,
|
submitEdit,
|
||||||
|
submitLevels,
|
||||||
submitPassword,
|
submitPassword,
|
||||||
toggleBan,
|
toggleBan,
|
||||||
|
exportUsers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -356,3 +454,56 @@ function mergeUserStatus(currentUser, result, fallbackStatus) {
|
|||||||
status: result?.status || 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 BlockOutlined from "@mui/icons-material/BlockOutlined";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
|
||||||
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||||
import KeyboardArrowUpOutlined from "@mui/icons-material/KeyboardArrowUpOutlined";
|
import KeyboardArrowUpOutlined from "@mui/icons-material/KeyboardArrowUpOutlined";
|
||||||
import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
|
import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
|
||||||
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
|
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
|
||||||
|
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
||||||
import UnfoldMoreOutlined from "@mui/icons-material/UnfoldMoreOutlined";
|
import UnfoldMoreOutlined from "@mui/icons-material/UnfoldMoreOutlined";
|
||||||
import Autocomplete from "@mui/material/Autocomplete";
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
import Checkbox from "@mui/material/Checkbox";
|
import Checkbox from "@mui/material/Checkbox";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
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 { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.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 { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
|
||||||
import { CoinLedgerTable } from "@/features/operations/components/CoinLedgerTable.jsx";
|
import { CoinLedgerTable } from "@/features/operations/components/CoinLedgerTable.jsx";
|
||||||
import styles from "@/features/app-users/app-users.module.css";
|
import styles from "@/features/app-users/app-users.module.css";
|
||||||
@ -89,6 +99,42 @@ export function AppUserListPage() {
|
|||||||
}),
|
}),
|
||||||
render: (user) => <AdminUserIdentity openInAppUserDetail user={user} />,
|
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",
|
key: "vip",
|
||||||
label: "VIP",
|
label: "VIP",
|
||||||
@ -134,7 +180,30 @@ export function AppUserListPage() {
|
|||||||
value: page.status,
|
value: page.status,
|
||||||
onChange: page.changeStatus,
|
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",
|
key: "time",
|
||||||
@ -181,21 +250,35 @@ export function AppUserListPage() {
|
|||||||
onChange={page.changeTimeRange}
|
onChange={page.changeTimeRange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{page.abilities.canStatus ? (
|
<div className={styles.toolbarActions}>
|
||||||
<Button
|
<Button startIcon={<RestartAltOutlined fontSize="small" />} onClick={page.resetFilters}>
|
||||||
disabled={!page.selectedUserIds.length || page.loadingAction === "batch-ban"}
|
重置筛选
|
||||||
startIcon={<BlockOutlined fontSize="small" />}
|
|
||||||
variant="danger"
|
|
||||||
onClick={page.batchBan}
|
|
||||||
>
|
|
||||||
批量封禁
|
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1220px"
|
minWidth="2380px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -212,40 +295,96 @@ export function AppUserListPage() {
|
|||||||
</DataState>
|
</DataState>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ActionModal
|
<AdminFormDialog
|
||||||
disabled={!page.abilities.canUpdate}
|
|
||||||
loading={page.loadingAction === "edit"}
|
loading={page.loadingAction === "edit"}
|
||||||
open={page.activeAction === "edit"}
|
open={page.activeAction === "edit"}
|
||||||
sectionTitle="用户资料"
|
size="standard"
|
||||||
|
submitDisabled={!page.abilities.canUpdate || page.loadingAction === "edit"}
|
||||||
|
submitLabel="保存资料"
|
||||||
title="编辑用户"
|
title="编辑用户"
|
||||||
onClose={page.closeAction}
|
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
|
<TextField
|
||||||
disabled={!page.abilities.canUpdate}
|
disabled={!page.abilities.canStatus}
|
||||||
label="用户名称"
|
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
|
select
|
||||||
value={page.editForm.gender}
|
value={page.banForm.mode}
|
||||||
onChange={(event) => page.setEditForm({ ...page.editForm, gender: event.target.value })}
|
onChange={(event) => page.setBanForm({ ...page.banForm, mode: event.target.value })}
|
||||||
>
|
>
|
||||||
{genderOptions.map(([value, label]) => (
|
<MenuItem value="permanent">永久</MenuItem>
|
||||||
<MenuItem key={value || "empty"} value={value}>
|
<MenuItem value="timed">指定截止时间</MenuItem>
|
||||||
{label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
</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>
|
||||||
|
|
||||||
<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 }) {
|
function VipValue({ vip }) {
|
||||||
const level = Number(vip?.level || 0);
|
const level = Number(vip?.level || 0);
|
||||||
if (level <= 0 || !vip?.active) {
|
if (level <= 0 || !vip?.active) {
|
||||||
@ -350,10 +553,13 @@ function UserActions({ page, user }) {
|
|||||||
const banned = user.status === "banned" || user.status === "disabled";
|
const banned = user.status === "banned" || user.status === "disabled";
|
||||||
return (
|
return (
|
||||||
<div className={styles.rowActions}>
|
<div className={styles.rowActions}>
|
||||||
{page.abilities.canUpdate ? (
|
{page.abilities.canUpdate || page.abilities.canLevel ? (
|
||||||
<Tooltip arrow title="编辑">
|
<Tooltip arrow title={page.abilities.canUpdate ? "编辑" : "等级调整"}>
|
||||||
<span>
|
<span>
|
||||||
<IconButton label="编辑" onClick={() => page.openEdit(user)}>
|
<IconButton
|
||||||
|
label={page.abilities.canUpdate ? "编辑" : "等级调整"}
|
||||||
|
onClick={() => page.openEdit(user)}
|
||||||
|
>
|
||||||
<EditOutlined fontSize="small" />
|
<EditOutlined fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { AppUserListPage } from "./AppUserListPage.jsx";
|
import { AppUserListPage } from "./AppUserListPage.jsx";
|
||||||
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
|
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
@ -26,9 +27,17 @@ afterEach(() => {
|
|||||||
test("clicking an app user's coin opens the coin ledger drawer", () => {
|
test("clicking an app user's coin opens the coin ledger drawer", () => {
|
||||||
vi.mocked(useAppUsersPage).mockReturnValue(pageFixture());
|
vi.mocked(useAppUsersPage).mockReturnValue(pageFixture());
|
||||||
|
|
||||||
render(<AppUserListPage />);
|
render(
|
||||||
|
<ToastProvider>
|
||||||
|
<AppUserListPage />
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.getByText("VIP 2")).toBeInTheDocument();
|
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 的金币流水" }));
|
fireEvent.click(screen.getByRole("button", { name: "查看 d 的金币流水" }));
|
||||||
|
|
||||||
expect(mocks.openCoinLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" }));
|
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(screen.getByTestId("coin-ledger-table")).toHaveTextContent("10001");
|
||||||
expect(mocks.ledgerTable).toHaveBeenCalledWith(
|
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 = {}) {
|
function pageFixture(patch = {}) {
|
||||||
return {
|
return {
|
||||||
abilities: {
|
abilities: {
|
||||||
canCoinLedger: true,
|
canCoinLedger: true,
|
||||||
|
canExport: true,
|
||||||
|
canLevel: true,
|
||||||
canPassword: true,
|
canPassword: true,
|
||||||
canStatus: true,
|
canStatus: true,
|
||||||
canUpdate: true,
|
canUpdate: true,
|
||||||
@ -63,6 +115,7 @@ function pageFixture(patch = {}) {
|
|||||||
},
|
},
|
||||||
activeAction: "",
|
activeAction: "",
|
||||||
activeUser: null,
|
activeUser: null,
|
||||||
|
banForm: { expiresAtLocal: "", mode: "permanent", reason: "" },
|
||||||
batchBan: vi.fn(),
|
batchBan: vi.fn(),
|
||||||
changeLocationFilter: vi.fn(),
|
changeLocationFilter: vi.fn(),
|
||||||
changeQuery: vi.fn(),
|
changeQuery: vi.fn(),
|
||||||
@ -74,11 +127,17 @@ function pageFixture(patch = {}) {
|
|||||||
countryOptions: [],
|
countryOptions: [],
|
||||||
data: { items: [userFixture()], page: 1, pageSize: 50, total: 1 },
|
data: { items: [userFixture()], page: 1, pageSize: 50, total: 1 },
|
||||||
editForm: { avatar: "", gender: "", username: "" },
|
editForm: { avatar: "", gender: "", username: "" },
|
||||||
|
exportUsers: vi.fn(),
|
||||||
error: null,
|
error: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
loadingAction: "",
|
loadingAction: "",
|
||||||
loadingCountries: false,
|
loadingCountries: false,
|
||||||
loadingRegions: false,
|
loadingRegions: false,
|
||||||
|
levelForm: {
|
||||||
|
charm: { durationDays: "7", enabled: false, level: "4" },
|
||||||
|
reason: "",
|
||||||
|
wealth: { durationDays: "7", enabled: false, level: "5" },
|
||||||
|
},
|
||||||
locationFilterOptions: [],
|
locationFilterOptions: [],
|
||||||
locationFilterValue: "",
|
locationFilterValue: "",
|
||||||
openCoinLedger: mocks.openCoinLedger,
|
openCoinLedger: mocks.openCoinLedger,
|
||||||
@ -90,17 +149,22 @@ function pageFixture(patch = {}) {
|
|||||||
query: "",
|
query: "",
|
||||||
regionOptions: [],
|
regionOptions: [],
|
||||||
reload: vi.fn(),
|
reload: vi.fn(),
|
||||||
|
resetFilters: vi.fn(),
|
||||||
selectedUserIds: [],
|
selectedUserIds: [],
|
||||||
|
setBanForm: vi.fn(),
|
||||||
setCountryForm: vi.fn(),
|
setCountryForm: vi.fn(),
|
||||||
setEditForm: vi.fn(),
|
setEditForm: vi.fn(),
|
||||||
|
setLevelForm: vi.fn(),
|
||||||
setPage: vi.fn(),
|
setPage: vi.fn(),
|
||||||
setPasswordForm: vi.fn(),
|
setPasswordForm: vi.fn(),
|
||||||
setSelectedUserIds: vi.fn(),
|
setSelectedUserIds: vi.fn(),
|
||||||
sortBy: "created_at",
|
sortBy: "created_at",
|
||||||
sortDirection: "desc",
|
sortDirection: "desc",
|
||||||
status: "",
|
status: "",
|
||||||
|
submitBan: vi.fn(),
|
||||||
submitCountry: vi.fn(),
|
submitCountry: vi.fn(),
|
||||||
submitEdit: vi.fn(),
|
submitEdit: vi.fn(),
|
||||||
|
submitLevels: vi.fn(),
|
||||||
submitPassword: vi.fn(),
|
submitPassword: vi.fn(),
|
||||||
timeRange: { endMs: "", startMs: "" },
|
timeRange: { endMs: "", startMs: "" },
|
||||||
toggleBan: vi.fn(),
|
toggleBan: vi.fn(),
|
||||||
@ -110,15 +174,30 @@ function pageFixture(patch = {}) {
|
|||||||
|
|
||||||
function userFixture() {
|
function userFixture() {
|
||||||
return {
|
return {
|
||||||
|
age: 24,
|
||||||
avatar: "",
|
avatar: "",
|
||||||
|
ban: { active: false, expiresAtMs: 0, permanent: false },
|
||||||
coin: 223437,
|
coin: 223437,
|
||||||
country: "AE",
|
country: "AE",
|
||||||
countryDisplayName: "阿拉伯联合酋长国",
|
countryDisplayName: "阿拉伯联合酋长国",
|
||||||
createdAtMs: 1779322523000,
|
createdAtMs: 1779322523000,
|
||||||
|
defaultDisplayUserId: "163337",
|
||||||
displayUserId: "163337",
|
displayUserId: "163337",
|
||||||
|
gender: "female",
|
||||||
lastActiveAtMs: 1781325798000,
|
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,
|
regionId: 2,
|
||||||
regionName: "中东区",
|
regionName: "中东区",
|
||||||
|
roles: ["公会长", "BD"],
|
||||||
status: "active",
|
status: "active",
|
||||||
userId: "10001",
|
userId: "10001",
|
||||||
username: "d",
|
username: "d",
|
||||||
|
|||||||
@ -15,5 +15,7 @@ export function useAppUserAbilities() {
|
|||||||
canUpdate: can(PERMISSIONS.appUserUpdate),
|
canUpdate: can(PERMISSIONS.appUserUpdate),
|
||||||
canUpload: can(PERMISSIONS.uploadCreate),
|
canUpload: can(PERMISSIONS.uploadCreate),
|
||||||
canView: can(PERMISSIONS.appUserView),
|
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 个字符"),
|
password: z.string().trim().min(6, "密码至少 6 位").max(128, "密码不能超过 128 个字符"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 与服务端后台发放规则保持一致:只允许 Unicode 字母、数字和组合标记,空格、标点、emoji 都在前端先拦截。
|
const temporaryLevelTrackSchema = z.object({
|
||||||
const prettyContentPattern = /^[\p{L}\p{N}\p{M}]+$/u;
|
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 = [
|
const prettyRuleValues = [
|
||||||
"aa",
|
"aa",
|
||||||
"aaa",
|
"aaa",
|
||||||
@ -77,14 +133,16 @@ export const prettyDisplayIDGenerateSchema = z.object({
|
|||||||
ruleType: z.enum(prettyRuleValues, "请选择生成规则"),
|
ruleType: z.enum(prettyRuleValues, "请选择生成规则"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 后台发放允许管理员输入非数字靓号;durationDays 为空或 0 都会转成 duration_ms=0,表示长期持有。
|
// 靓号展示内容必须原样支持任意 Unicode 字符,不能以字母数字正则过滤字体字形、表情或符号;仅去除首尾空白、限制长度并拦截控制字符,服务端继续负责唯一性和持久化安全校验。
|
||||||
export const prettyDisplayIDGrantSchema = z.object({
|
export const prettyDisplayIDGrantSchema = z.object({
|
||||||
displayUserId: z
|
displayUserId: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(1, "请输入靓号内容")
|
.min(1, "请输入靓号内容")
|
||||||
.max(64, "靓号不能超过 64 个字符")
|
// String.length 会把补充平面 Unicode 字符计为两个 UTF-16 单元,必须按 code point 计数,才能和服务端的 rune 上限一致。
|
||||||
.regex(prettyContentPattern, "靓号只支持字母、数字和组合标记"),
|
.refine((value) => Array.from(value).length <= 64, "靓号不能超过 64 个字符")
|
||||||
|
// 控制字符不是展示字形,保留会破坏列表、日志和协议载荷;符号、emoji 和格式化字形仍可通过。
|
||||||
|
.refine((value) => !/\p{Cc}/u.test(value), "靓号不能包含控制字符"),
|
||||||
durationDays: z
|
durationDays: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@ -111,6 +169,8 @@ export const prettyDisplayIDStatusSchema = z.object({
|
|||||||
export type AppUserUpdateForm = z.infer<typeof appUserUpdateSchema>;
|
export type AppUserUpdateForm = z.infer<typeof appUserUpdateSchema>;
|
||||||
export type AppUserCountryForm = z.infer<typeof appUserCountrySchema>;
|
export type AppUserCountryForm = z.infer<typeof appUserCountrySchema>;
|
||||||
export type AppUserPasswordForm = z.infer<typeof appUserPasswordSchema>;
|
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 PrettyDisplayIDPoolForm = z.infer<typeof prettyDisplayIDPoolSchema>;
|
||||||
export type PrettyDisplayIDGenerateForm = z.infer<typeof prettyDisplayIDGenerateSchema>;
|
export type PrettyDisplayIDGenerateForm = z.infer<typeof prettyDisplayIDGenerateSchema>;
|
||||||
export type PrettyDisplayIDGrantForm = z.infer<typeof prettyDisplayIDGrantSchema>;
|
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();
|
const { formatMillis } = useTimeZone();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageHead title="系统概览" meta={`刷新时间 ${formatMillis(overview?.updatedAtMs ?? overview?.updatedAt)}`}>
|
<PageHead title="工作台" meta={`刷新时间 ${formatMillis(overview?.updatedAtMs ?? overview?.updatedAt)}`}>
|
||||||
<TimeRangeSelect value={range} onChange={onRangeChange} />
|
<TimeRangeSelect value={range} onChange={onRangeChange} />
|
||||||
<Button onClick={onRefresh}>
|
<Button onClick={onRefresh}>
|
||||||
<Refresh fontSize="small" />
|
<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);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-section {
|
.workspace-entry-section {
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-head {
|
.workspace-entry-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-4);
|
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-head h2 {
|
.workspace-entry-head h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: var(--admin-font-size-lg);
|
font-size: var(--admin-font-size-lg);
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-head p {
|
.workspace-entry-grid {
|
||||||
margin: var(--space-1) 0 0;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: var(--admin-font-size);
|
|
||||||
}
|
|
||||||
|
|
||||||
.frequent-menu-grid {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(180px, 1fr));
|
grid-template-columns: repeat(2, minmax(240px, 1fr));
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-card {
|
.workspace-entry-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 112px;
|
min-height: 128px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
@ -48,6 +39,7 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
text-decoration: none;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition:
|
transition:
|
||||||
border-color var(--motion-base) var(--ease-standard),
|
border-color var(--motion-base) var(--ease-standard),
|
||||||
@ -55,13 +47,19 @@
|
|||||||
transform var(--motion-base) var(--ease-emphasized);
|
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);
|
border-color: var(--primary-border-strong);
|
||||||
box-shadow: var(--shadow-panel);
|
box-shadow: var(--shadow-panel);
|
||||||
transform: translateY(-2px);
|
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;
|
display: inline-flex;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@ -72,13 +70,13 @@
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-card__group {
|
.workspace-entry-card__group {
|
||||||
margin-top: var(--space-1);
|
margin-top: var(--space-1);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: var(--admin-font-size-sm);
|
font-size: var(--admin-font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-card strong {
|
.workspace-entry-card strong {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@ -88,12 +86,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.frequent-menu-card__meta {
|
.workspace-entry-empty {
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: var(--admin-font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.frequent-menu-empty {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 88px;
|
min-height: 88px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
import { useOutletContext } from "react-router-dom";
|
import { DashboardWorkspaces } from "@/features/dashboard/components/DashboardWorkspaces.jsx";
|
||||||
import { DashboardFrequentMenus } from "@/features/dashboard/components/DashboardFrequentMenus.jsx";
|
|
||||||
|
|
||||||
export function OverviewPage() {
|
export function OverviewPage() {
|
||||||
const { menus = [] } = useOutletContext() || {};
|
return <DashboardWorkspaces />;
|
||||||
|
|
||||||
return <DashboardFrequentMenus menus={menus} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
|||||||
|
|
||||||
export const dashboardRoutes = [
|
export const dashboardRoutes = [
|
||||||
{
|
{
|
||||||
label: "系统概览",
|
label: "工作台",
|
||||||
loader: () => import("./pages/OverviewPage.jsx").then((module) => module.OverviewPage),
|
loader: () => import("./pages/OverviewPage.jsx").then((module) => module.OverviewPage),
|
||||||
menuCode: MENU_CODES.overview,
|
menuCode: MENU_CODES.overview,
|
||||||
pageKey: "overview",
|
pageKey: "overview",
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||||
|
import CloseOutlined from "@mui/icons-material/CloseOutlined";
|
||||||
import SearchOutlined from "@mui/icons-material/SearchOutlined";
|
import SearchOutlined from "@mui/icons-material/SearchOutlined";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import InputAdornment from "@mui/material/InputAdornment";
|
import InputAdornment from "@mui/material/InputAdornment";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
@ -18,6 +20,7 @@ export function ResourceSelectField({
|
|||||||
loading = false,
|
loading = false,
|
||||||
onChange = () => {},
|
onChange = () => {},
|
||||||
placeholder = "请选择资源",
|
placeholder = "请选择资源",
|
||||||
|
required = true,
|
||||||
resources = [],
|
resources = [],
|
||||||
value,
|
value,
|
||||||
}) {
|
}) {
|
||||||
@ -74,6 +77,12 @@ export function ResourceSelectField({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearResource = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onChange("", null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
@ -82,8 +91,20 @@ export function ResourceSelectField({
|
|||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
label={label}
|
label={label}
|
||||||
placeholder={loading ? "资源加载中" : placeholder}
|
placeholder={loading ? "资源加载中" : placeholder}
|
||||||
required
|
required={required}
|
||||||
slotProps={{ htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" } }}
|
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}
|
value={displayValue}
|
||||||
onClick={openDrawer}
|
onClick={openDrawer}
|
||||||
onKeyDown={(event) => {
|
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.queryByText("Rose Gift")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("VIP Vehicle")).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: "资源组",
|
resource_group: "资源组",
|
||||||
vip: "VIP",
|
vip: "VIP",
|
||||||
vip_level: "VIP等级",
|
vip_level: "VIP等级",
|
||||||
|
vip_trial_card: "VIP体验卡",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const emojiPackPricingLabels = {
|
export const emojiPackPricingLabels = {
|
||||||
@ -107,6 +108,7 @@ export const resourceTypeFilters = [
|
|||||||
["mic_seat_icon", "麦位图标"],
|
["mic_seat_icon", "麦位图标"],
|
||||||
["mic_seat_animation", "麦位动效"],
|
["mic_seat_animation", "麦位动效"],
|
||||||
["emoji_pack", "表情包"],
|
["emoji_pack", "表情包"],
|
||||||
|
["vip_trial_card", "VIP体验卡"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const resourceTypeLabels = Object.fromEntries(resourceTypeFilters.filter(([value]) => value));
|
export const resourceTypeLabels = Object.fromEntries(resourceTypeFilters.filter(([value]) => value));
|
||||||
|
|||||||
@ -5,9 +5,11 @@ import { useResourceGrantListPage } from "./useResourcePages.js";
|
|||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
can: vi.fn(),
|
can: vi.fn(),
|
||||||
getVipConfig: vi.fn(),
|
getVipConfig: vi.fn(),
|
||||||
|
getVipProgram: vi.fn(),
|
||||||
grantResource: vi.fn(),
|
grantResource: vi.fn(),
|
||||||
grantResourceGroup: vi.fn(),
|
grantResourceGroup: vi.fn(),
|
||||||
grantVip: vi.fn(),
|
grantVip: vi.fn(),
|
||||||
|
grantVipTrialCard: vi.fn(),
|
||||||
listGiftTypes: vi.fn(),
|
listGiftTypes: vi.fn(),
|
||||||
listGifts: vi.fn(),
|
listGifts: vi.fn(),
|
||||||
listResourceGroups: vi.fn(),
|
listResourceGroups: vi.fn(),
|
||||||
@ -47,15 +49,19 @@ vi.mock("@/features/resources/api", async (importOriginal) => ({
|
|||||||
|
|
||||||
vi.mock("@/features/vip-config/api", () => ({
|
vi.mock("@/features/vip-config/api", () => ({
|
||||||
getVipConfig: mocks.getVipConfig,
|
getVipConfig: mocks.getVipConfig,
|
||||||
|
getVipProgram: mocks.getVipProgram,
|
||||||
grantVip: mocks.grantVip,
|
grantVip: mocks.grantVip,
|
||||||
|
grantVipTrialCard: mocks.grantVipTrialCard,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.can.mockReturnValue(true);
|
mocks.can.mockReturnValue(true);
|
||||||
mocks.getVipConfig.mockResolvedValue({ levels: [{ level: 5, name: "VIP5", status: "active" }] });
|
mocks.getVipConfig.mockResolvedValue({ levels: [{ level: 5, name: "VIP5", status: "active" }] });
|
||||||
|
mocks.getVipProgram.mockResolvedValue({ programType: "legacy_timed", trialCardEnabled: false });
|
||||||
mocks.grantResource.mockResolvedValue({});
|
mocks.grantResource.mockResolvedValue({});
|
||||||
mocks.grantResourceGroup.mockResolvedValue({});
|
mocks.grantResourceGroup.mockResolvedValue({});
|
||||||
mocks.grantVip.mockResolvedValue({ vip: { level: 5, name: "VIP5" } });
|
mocks.grantVip.mockResolvedValue({ vip: { level: 5, name: "VIP5" } });
|
||||||
|
mocks.grantVipTrialCard.mockResolvedValue({});
|
||||||
mocks.listGiftTypes.mockResolvedValue([]);
|
mocks.listGiftTypes.mockResolvedValue([]);
|
||||||
mocks.listGifts.mockResolvedValue({ items: [], page: 1, pageSize: 100, total: 0 });
|
mocks.listGifts.mockResolvedValue({ items: [], page: 1, pageSize: 100, total: 0 });
|
||||||
mocks.listResourceGroups.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();
|
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");
|
mocks.can.mockImplementation((permission) => permission === "vip-config:grant");
|
||||||
const { result } = renderHook(() => useResourceGrantListPage());
|
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));
|
await waitFor(() => expect(mocks.getVipConfig).toHaveBeenCalledTimes(1));
|
||||||
expect(result.current.form.subjectType).toBe("vip");
|
expect(result.current.form.subjectType).toBe("vip");
|
||||||
|
expect(mocks.getVipProgram).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.listResources).not.toHaveBeenCalled();
|
expect(mocks.listResources).not.toHaveBeenCalled();
|
||||||
expect(mocks.listGifts).not.toHaveBeenCalled();
|
expect(mocks.listGifts).not.toHaveBeenCalled();
|
||||||
expect(mocks.listGiftTypes).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());
|
await waitFor(() => expect(mocks.listResources).toHaveBeenCalled());
|
||||||
expect(result.current.form.subjectType).toBe("resource");
|
expect(result.current.form.subjectType).toBe("resource");
|
||||||
expect(mocks.getVipConfig).not.toHaveBeenCalled();
|
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 = {}) {
|
function grantFormFixture(patch = {}) {
|
||||||
|
|||||||
@ -43,7 +43,7 @@ import {
|
|||||||
updateResourceGroup,
|
updateResourceGroup,
|
||||||
upsertResourceShopItems,
|
upsertResourceShopItems,
|
||||||
} from "@/features/resources/api";
|
} from "@/features/resources/api";
|
||||||
import { getVipConfig, grantVip } from "@/features/vip-config/api";
|
import { getVipConfig, getVipProgram, grantVip, grantVipTrialCard } from "@/features/vip-config/api";
|
||||||
import {
|
import {
|
||||||
cpRelationTypeLabels,
|
cpRelationTypeLabels,
|
||||||
cpRelationTypeOptions,
|
cpRelationTypeOptions,
|
||||||
@ -1851,6 +1851,7 @@ export function useResourceGrantListPage() {
|
|||||||
const [giftTypeOptions, setGiftTypeOptions] = useState(defaultGiftTypeOptions);
|
const [giftTypeOptions, setGiftTypeOptions] = useState(defaultGiftTypeOptions);
|
||||||
const [groupOptions, setGroupOptions] = useState([]);
|
const [groupOptions, setGroupOptions] = useState([]);
|
||||||
const [resourceOptions, setResourceOptions] = useState([]);
|
const [resourceOptions, setResourceOptions] = useState([]);
|
||||||
|
const [vipProgram, setVipProgram] = useState({ programType: "legacy_timed", trialCardEnabled: false });
|
||||||
const [vipLevelOptions, setVipLevelOptions] = useState([]);
|
const [vipLevelOptions, setVipLevelOptions] = useState([]);
|
||||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
@ -1890,8 +1891,14 @@ export function useResourceGrantListPage() {
|
|||||||
setGroupOptions([]);
|
setGroupOptions([]);
|
||||||
}
|
}
|
||||||
if (abilities.canGrantVip) {
|
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 {
|
} else {
|
||||||
|
setVipProgram({ programType: "legacy_timed", trialCardEnabled: false });
|
||||||
setVipLevelOptions([]);
|
setVipLevelOptions([]);
|
||||||
}
|
}
|
||||||
Promise.allSettled(optionRequests)
|
Promise.allSettled(optionRequests)
|
||||||
@ -1908,7 +1915,11 @@ export function useResourceGrantListPage() {
|
|||||||
const { gifts, giftTypes, groups, resources } = result.value.data;
|
const { gifts, giftTypes, groups, resources } = result.value.data;
|
||||||
setResourceOptions(
|
setResourceOptions(
|
||||||
(resources.items || []).filter(
|
(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(
|
setGiftOptions(
|
||||||
@ -1918,13 +1929,22 @@ export function useResourceGrantListPage() {
|
|||||||
setGroupOptions(groups.items || []);
|
setGroupOptions(groups.items || []);
|
||||||
return;
|
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);
|
setVipLevelOptions(activeVipLevels);
|
||||||
setForm((current) => {
|
setForm((current) => {
|
||||||
if (current.subjectType !== "vip" || current.vipLevel || !activeVipLevels.length) {
|
if (!isVipGrantSubject(current.subjectType)) {
|
||||||
return current;
|
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") {
|
} else if (payload.subjectType === "resource_group") {
|
||||||
await grantResourceGroup(payload.group);
|
await grantResourceGroup(payload.group);
|
||||||
} else {
|
} else if (payload.subjectType === "vip") {
|
||||||
await grantVip(payload.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();
|
closeAction();
|
||||||
setForm(emptyGrantForm());
|
setForm(emptyGrantForm());
|
||||||
await result.reload();
|
await result.reload();
|
||||||
@ -2044,6 +2073,7 @@ export function useResourceGrantListPage() {
|
|||||||
status,
|
status,
|
||||||
submitGrant,
|
submitGrant,
|
||||||
targetFilter,
|
targetFilter,
|
||||||
|
vipProgram,
|
||||||
vipLevelOptions,
|
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 {
|
return {
|
||||||
group: {
|
group: {
|
||||||
commandId: makeCommandId("resource-group-grant"),
|
commandId: makeCommandId("resource-group-grant"),
|
||||||
@ -2420,6 +2462,9 @@ function grantResourceIds(form) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isRevocableGrant(grant) {
|
function isRevocableGrant(grant) {
|
||||||
|
if (isVipTrialCardRecord(grant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (isVipGrantRecord(grant)) {
|
if (isVipGrantRecord(grant)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -2427,18 +2472,38 @@ function isRevocableGrant(grant) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isVipGrantRecord(grant) {
|
function isVipGrantRecord(grant) {
|
||||||
|
if (isVipTrialCardRecord(grant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const source = String(grant?.grantSource || "").trim();
|
const source = String(grant?.grantSource || "").trim();
|
||||||
const commandId = String(grant?.commandId || "").trim();
|
const commandId = String(grant?.commandId || "").trim();
|
||||||
return source === "admin_grant" || source === "activity_grant" || source === "vip_purchase" || commandId.startsWith("vip_reward:");
|
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) {
|
function canSubmitGrantSubject(subjectType, abilities) {
|
||||||
if (subjectType === "vip") {
|
if (isVipGrantSubject(subjectType)) {
|
||||||
return Boolean(abilities.canGrantVip);
|
return Boolean(abilities.canGrantVip);
|
||||||
}
|
}
|
||||||
return Boolean(abilities.canCreateGrant);
|
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) {
|
function defaultGrantSubjectType(abilities) {
|
||||||
if (abilities.canCreateGrant) {
|
if (abilities.canCreateGrant) {
|
||||||
return "resource";
|
return "resource";
|
||||||
|
|||||||
@ -166,6 +166,7 @@ export function ResourceGrantListPage() {
|
|||||||
optionsLoading={page.optionsLoading}
|
optionsLoading={page.optionsLoading}
|
||||||
resourceOptions={page.resourceOptions}
|
resourceOptions={page.resourceOptions}
|
||||||
setForm={page.setForm}
|
setForm={page.setForm}
|
||||||
|
vipProgram={page.vipProgram}
|
||||||
vipLevelOptions={page.vipLevelOptions}
|
vipLevelOptions={page.vipLevelOptions}
|
||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitGrant}
|
onSubmit={page.submitGrant}
|
||||||
@ -212,6 +213,7 @@ function ResourceGrantDialog({
|
|||||||
optionsLoading,
|
optionsLoading,
|
||||||
resourceOptions,
|
resourceOptions,
|
||||||
setForm,
|
setForm,
|
||||||
|
vipProgram,
|
||||||
vipLevelOptions,
|
vipLevelOptions,
|
||||||
}) {
|
}) {
|
||||||
const resourceGrantAllowed = Boolean(abilities?.canCreateGrant);
|
const resourceGrantAllowed = Boolean(abilities?.canCreateGrant);
|
||||||
@ -222,7 +224,13 @@ function ResourceGrantDialog({
|
|||||||
const vipLevelValue = (vipLevelOptions || []).some((level) => String(level.level) === currentVipLevel)
|
const vipLevelValue = (vipLevelOptions || []).some((level) => String(level.level) === currentVipLevel)
|
||||||
? 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 submitDisabled = disabled || loading || optionsLoading || !subjectAllowed || vipUnavailable;
|
||||||
const handleSubjectChange = (event) => {
|
const handleSubjectChange = (event) => {
|
||||||
const subjectType = event.target.value;
|
const subjectType = event.target.value;
|
||||||
@ -232,7 +240,17 @@ function ResourceGrantDialog({
|
|||||||
resourceId: "",
|
resourceId: "",
|
||||||
resourceIds: [],
|
resourceIds: [],
|
||||||
subjectType,
|
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">资源</MenuItem> : null}
|
||||||
{resourceGrantAllowed ? <MenuItem value="resource_group">资源组</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>
|
</TextField>
|
||||||
{form.subjectType === "resource" ? (
|
{form.subjectType === "resource" ? (
|
||||||
<>
|
<>
|
||||||
@ -318,28 +340,46 @@ function ResourceGrantDialog({
|
|||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
) : null}
|
) : null}
|
||||||
{form.subjectType === "vip" ? (
|
{form.subjectType === "vip" || form.subjectType === "vip_trial_card" ? (
|
||||||
<TextField
|
<>
|
||||||
disabled={disabled || optionsLoading || !vipGrantAllowed || !hasVipLevels}
|
<TextField
|
||||||
helperText={!hasVipLevels && !optionsLoading ? "暂无启用VIP等级" : undefined}
|
disabled={disabled || optionsLoading || !vipGrantAllowed || !hasVipLevels}
|
||||||
label="VIP等级"
|
helperText={
|
||||||
required
|
!vipProgramEnabled
|
||||||
select
|
? "VIP体系已停用"
|
||||||
value={vipLevelValue}
|
: !hasVipLevels && !optionsLoading
|
||||||
onChange={(event) => setForm({ ...form, vipLevel: event.target.value })}
|
? "暂无启用VIP等级"
|
||||||
>
|
: undefined
|
||||||
{hasVipLevels ? (
|
}
|
||||||
(vipLevelOptions || []).map((level) => (
|
label="VIP等级"
|
||||||
<MenuItem key={level.level} value={String(level.level)}>
|
required
|
||||||
VIP{level.level} · {level.name || `VIP${level.level}`}
|
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>
|
||||||
))
|
)}
|
||||||
) : (
|
</TextField>
|
||||||
<MenuItem disabled value="">
|
{form.subjectType === "vip_trial_card" ? (
|
||||||
暂无启用VIP等级
|
<TextField
|
||||||
</MenuItem>
|
disabled={disabled || optionsLoading}
|
||||||
)}
|
label="有效天数"
|
||||||
</TextField>
|
required
|
||||||
|
type="number"
|
||||||
|
value={form.durationDays}
|
||||||
|
onChange={(event) => setForm({ ...form, durationDays: event.target.value })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
@ -417,9 +457,11 @@ function GrantItemMedia({ item }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GrantSubject({ grant }) {
|
function GrantSubject({ grant }) {
|
||||||
const subjectLabel = isVipGrantRecord(grant)
|
const subjectLabel = isVipTrialCardRecord(grant)
|
||||||
? "VIP奖励资源组"
|
? "VIP体验卡"
|
||||||
: resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "发放";
|
: isVipGrantRecord(grant)
|
||||||
|
? "VIP奖励资源组"
|
||||||
|
: resourceGrantSubjectLabels[grant.grantSubjectType] || grant.grantSubjectType || "发放";
|
||||||
const sourceLabel = grantSourceLabel(grant.grantSource, grant);
|
const sourceLabel = grantSourceLabel(grant.grantSource, grant);
|
||||||
return (
|
return (
|
||||||
<div className={styles.stack}>
|
<div className={styles.stack}>
|
||||||
@ -486,6 +528,7 @@ const grantSourceLabels = {
|
|||||||
room_rocket: "房间火箭",
|
room_rocket: "房间火箭",
|
||||||
seven_day_checkin: "七日签到",
|
seven_day_checkin: "七日签到",
|
||||||
vip_purchase: "VIP购买",
|
vip_purchase: "VIP购买",
|
||||||
|
vip_trial_card_grant: "后台VIP体验卡赠送",
|
||||||
weekly_star: "周星奖励",
|
weekly_star: "周星奖励",
|
||||||
wheel_reward: "转盘奖励",
|
wheel_reward: "转盘奖励",
|
||||||
};
|
};
|
||||||
@ -510,6 +553,9 @@ function reasonLabel(reason) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function subjectMeta(grant) {
|
function subjectMeta(grant) {
|
||||||
|
if (isVipTrialCardRecord(grant)) {
|
||||||
|
return grant.grantSubjectId ? `体验卡资源 ID ${grant.grantSubjectId}` : "-";
|
||||||
|
}
|
||||||
if (isVipGrantRecord(grant)) {
|
if (isVipGrantRecord(grant)) {
|
||||||
return grant.grantSubjectId ? `奖励资源组 ID ${grant.grantSubjectId}` : "-";
|
return grant.grantSubjectId ? `奖励资源组 ID ${grant.grantSubjectId}` : "-";
|
||||||
}
|
}
|
||||||
@ -521,6 +567,9 @@ function subjectMeta(grant) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isRevocableGrant(grant) {
|
function isRevocableGrant(grant) {
|
||||||
|
if (isVipTrialCardRecord(grant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (isVipGrantRecord(grant)) {
|
if (isVipGrantRecord(grant)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -528,6 +577,9 @@ function isRevocableGrant(grant) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isVipGrantRecord(grant) {
|
function isVipGrantRecord(grant) {
|
||||||
|
if (isVipTrialCardRecord(grant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const source = String(grant?.grantSource || "").trim();
|
const source = String(grant?.grantSource || "").trim();
|
||||||
return (
|
return (
|
||||||
source === "admin_grant" ||
|
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) {
|
function isVipRewardCommand(grant) {
|
||||||
return String(grant?.commandId || "")
|
return String(grant?.commandId || "")
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@ -176,6 +176,27 @@ test("resource grant dialog shows vip subject and level selector", () => {
|
|||||||
expect(screen.getByText("VIP5 · 黄金VIP")).toBeInTheDocument();
|
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", () => {
|
test("resource grant dialog hides vip subject without vip grant permission", () => {
|
||||||
vi.mocked(useResourceGrantListPage).mockReturnValue(
|
vi.mocked(useResourceGrantListPage).mockReturnValue(
|
||||||
pageFixture({
|
pageFixture({
|
||||||
@ -259,6 +280,7 @@ function pageFixture(patch = {}) {
|
|||||||
status: "",
|
status: "",
|
||||||
submitGrant: vi.fn(),
|
submitGrant: vi.fn(),
|
||||||
vipLevelOptions: [],
|
vipLevelOptions: [],
|
||||||
|
vipProgram: { programType: "legacy_timed", trialCardEnabled: false },
|
||||||
...restPatch,
|
...restPatch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ const resourceTypes = [
|
|||||||
"mic_seat_icon",
|
"mic_seat_icon",
|
||||||
"mic_seat_animation",
|
"mic_seat_animation",
|
||||||
"emoji_pack",
|
"emoji_pack",
|
||||||
|
"vip_trial_card",
|
||||||
];
|
];
|
||||||
const walletAssetTypes = ["COIN"];
|
const walletAssetTypes = ["COIN"];
|
||||||
const resourcePriceTypes = ["coin", "free"];
|
const resourcePriceTypes = ["coin", "free"];
|
||||||
@ -446,7 +447,7 @@ export const resourceGrantFormSchema = z
|
|||||||
reason: z.string().trim().min(1, "请输入原因").max(240, "原因不能超过 240 个字符"),
|
reason: z.string().trim().min(1, "请输入原因").max(240, "原因不能超过 240 个字符"),
|
||||||
resourceId: z.union([z.string(), z.number()]).optional(),
|
resourceId: z.union([z.string(), z.number()]).optional(),
|
||||||
resourceIds: z.array(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()]),
|
targetUserId: z.union([z.string(), z.number()]),
|
||||||
vipLevel: z.union([z.string(), z.number()]).optional(),
|
vipLevel: z.union([z.string(), z.number()]).optional(),
|
||||||
})
|
})
|
||||||
@ -505,13 +506,22 @@ export const resourceGrantFormSchema = z
|
|||||||
path: ["groupId"],
|
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({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "请选择VIP等级",
|
message: "请选择VIP等级",
|
||||||
path: ["vipLevel"],
|
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) {
|
function grantResourceIds(value) {
|
||||||
|
|||||||
@ -327,6 +327,19 @@ describe("resource form schema", () => {
|
|||||||
expect(() => parseForm(resourceGrantFormSchema, { ...basePayload, reason: "" })).toThrow(FormValidationError);
|
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", () => {
|
test("validates resource shop durations", () => {
|
||||||
const payload = parseForm(resourceShopItemsFormSchema, {
|
const payload = parseForm(resourceShopItemsFormSchema, {
|
||||||
items: [
|
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 { 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 {
|
export interface VipLevelDto {
|
||||||
level: number;
|
level: number;
|
||||||
@ -8,6 +60,7 @@ export interface VipLevelDto {
|
|||||||
durationMs: number;
|
durationMs: number;
|
||||||
rewardResourceGroupId: number;
|
rewardResourceGroupId: number;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
benefits: VipBenefitDto[];
|
||||||
rechargeGateRequired: boolean;
|
rechargeGateRequired: boolean;
|
||||||
requiredRechargeCoinAmount: number;
|
requiredRechargeCoinAmount: number;
|
||||||
userRechargeCoinAmount?: number;
|
userRechargeCoinAmount?: number;
|
||||||
@ -31,6 +84,7 @@ export interface VipConfigPayload {
|
|||||||
rewardResourceGroupId: number;
|
rewardResourceGroupId: number;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
requiredRechargeCoinAmount: number;
|
requiredRechargeCoinAmount: number;
|
||||||
|
benefits: VipBenefitDto[];
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,29 +121,107 @@ export interface VipGrantPayload {
|
|||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = "/v1/admin/activity/vip-levels";
|
export interface VipTrialCardGrantPayload {
|
||||||
const grantPath = "/v1/admin/activity/vip-grants";
|
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> {
|
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> {
|
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> {
|
export function grantVip(payload: VipGrantPayload): Promise<VipGrantDto> {
|
||||||
return apiRequest<RawVipGrant, VipGrantPayload>(grantPath, { body: payload, method: "POST" }).then(
|
const endpoint = API_ENDPOINTS.grantVip;
|
||||||
normalizeVipGrant,
|
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 RawConfig = VipConfigDto & Record<string, unknown>;
|
||||||
type RawLevel = VipLevelDto & Record<string, unknown>;
|
type RawLevel = VipLevelDto & Record<string, unknown>;
|
||||||
|
type RawBenefit = VipBenefitDto & Record<string, unknown>;
|
||||||
type RawVipGrant = VipGrantDto & Record<string, unknown>;
|
type RawVipGrant = VipGrantDto & Record<string, unknown>;
|
||||||
type RawUserVip = UserVipDto & Record<string, unknown>;
|
type RawUserVip = UserVipDto & Record<string, unknown>;
|
||||||
type RawReward = VipRewardItemDto & 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 {
|
function normalizeConfig(item: RawConfig): VipConfigDto {
|
||||||
const rawLevels = Array.isArray(item.levels) ? (item.levels as RawLevel[]) : [];
|
const rawLevels = Array.isArray(item.levels) ? (item.levels as RawLevel[]) : [];
|
||||||
return {
|
return {
|
||||||
@ -100,6 +232,7 @@ function normalizeConfig(item: RawConfig): VipConfigDto {
|
|||||||
|
|
||||||
function normalizeLevel(item: RawLevel): VipLevelDto {
|
function normalizeLevel(item: RawLevel): VipLevelDto {
|
||||||
const level = numberValue(item.level);
|
const level = numberValue(item.level);
|
||||||
|
const rawBenefits = Array.isArray(item.benefits) ? (item.benefits as RawBenefit[]) : [];
|
||||||
return {
|
return {
|
||||||
level,
|
level,
|
||||||
name: stringValue(item.name) || `VIP${level}`,
|
name: stringValue(item.name) || `VIP${level}`,
|
||||||
@ -108,6 +241,7 @@ function normalizeLevel(item: RawLevel): VipLevelDto {
|
|||||||
durationMs: numberValue(item.durationMs ?? item.duration_ms),
|
durationMs: numberValue(item.durationMs ?? item.duration_ms),
|
||||||
rewardResourceGroupId: numberValue(item.rewardResourceGroupId ?? item.reward_resource_group_id),
|
rewardResourceGroupId: numberValue(item.rewardResourceGroupId ?? item.reward_resource_group_id),
|
||||||
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
|
||||||
|
benefits: rawBenefits.map(normalizeBenefit),
|
||||||
rechargeGateRequired: false,
|
rechargeGateRequired: false,
|
||||||
requiredRechargeCoinAmount: 0,
|
requiredRechargeCoinAmount: 0,
|
||||||
userRechargeCoinAmount: 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 {
|
function normalizeVipGrant(item: RawVipGrant): VipGrantDto {
|
||||||
const rewardItems = Array.isArray(item.rewardItems) ? (item.rewardItems as RawReward[]) : [];
|
const rewardItems = Array.isArray(item.rewardItems) ? (item.rewardItems as RawReward[]) : [];
|
||||||
return {
|
return {
|
||||||
@ -132,7 +298,7 @@ function normalizeUserVip(item: RawUserVip): UserVipDto {
|
|||||||
userId: stringValue(item.userId ?? item.user_id),
|
userId: stringValue(item.userId ?? item.user_id),
|
||||||
level: numberValue(item.level),
|
level: numberValue(item.level),
|
||||||
name: stringValue(item.name),
|
name: stringValue(item.name),
|
||||||
active: Boolean(item.active),
|
active: booleanValue(item.active),
|
||||||
startedAtMs: numberValue(item.startedAtMs ?? item.started_at_ms),
|
startedAtMs: numberValue(item.startedAtMs ?? item.started_at_ms),
|
||||||
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
|
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
|
||||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
@ -158,3 +324,10 @@ function numberValue(value: unknown) {
|
|||||||
const number = Number(value || 0);
|
const number = Number(value || 0);
|
||||||
return Number.isFinite(number) ? number : 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { getVipConfig, updateVipConfig } from "@/features/vip-config/api";
|
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 { 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";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
const defaultDurationMs = 30 * 24 * 60 * 60 * 1000;
|
const dayMillis = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export function useVipConfigPage() {
|
export function useVipConfigPage() {
|
||||||
const abilities = useVipConfigAbilities();
|
const abilities = useVipConfigAbilities();
|
||||||
|
const { appCode } = useAppScope();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [config, setConfig] = useState({ levels: defaultLevels() });
|
const initialProgram = defaultProgram(appCode);
|
||||||
const [form, setForm] = useState({ levels: defaultLevels().map(levelToForm) });
|
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 [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 [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const reloadSequence = useRef(0);
|
||||||
|
const resourceLoadSequence = useRef(0);
|
||||||
|
const resourcesApp = useRef("");
|
||||||
|
|
||||||
const activeCount = useMemo(
|
const activeCount = useMemo(
|
||||||
() => (config.levels || []).filter((level) => level.status === "active").length,
|
() => (config.levels || []).filter((level) => level.status === "active").length,
|
||||||
@ -22,38 +39,113 @@ export function useVipConfigPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
|
const sequence = ++reloadSequence.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [remoteConfig, groups] = await Promise.all([
|
const [remoteProgram, remoteConfig, groups] = await Promise.all([
|
||||||
|
getVipProgram(),
|
||||||
getVipConfig(),
|
getVipConfig(),
|
||||||
listResourceGroups({ page: 1, page_size: 100, status: "active" }),
|
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 });
|
setConfig({ ...remoteConfig, levels });
|
||||||
setForm({ levels: levels.map(levelToForm) });
|
setForm({ levels: levels.map((level) => levelToForm(level, nextProgram)) });
|
||||||
setResourceGroups(groups.items || []);
|
setResourceGroups(groups.items || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (sequence !== reloadSequence.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err);
|
||||||
showToast(err.message || "加载 VIP 配置失败", "error");
|
showToast(err.message || "加载 VIP 配置失败", "error");
|
||||||
} finally {
|
} 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(() => {
|
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();
|
void reload();
|
||||||
}, [reload]);
|
}, [appCode, reload]);
|
||||||
|
|
||||||
const openDrawer = () => {
|
const openProgramEditor = () => {
|
||||||
setForm({ levels: completeLevels(config.levels || []).map(levelToForm) });
|
setProgramForm(programToForm(program));
|
||||||
setDrawerOpen(true);
|
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) {
|
if (saving) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setForm({ levels: completeLevels(config.levels || []).map(levelToForm) });
|
setProgramForm(programToForm(program));
|
||||||
setDrawerOpen(false);
|
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) => {
|
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();
|
event.preventDefault();
|
||||||
if (!abilities.canUpdate) {
|
if (!abilities.canUpdate) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let payload;
|
let payload;
|
||||||
try {
|
try {
|
||||||
payload = payloadFromForm(form);
|
payload = programPayloadFromForm(programForm);
|
||||||
} catch (err) {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const saved = await updateVipConfig(payload);
|
const saved = await updateVipConfig(payload);
|
||||||
const levels = completeLevels(saved.levels || []);
|
const levels = completeVipLevels(saved.levels || [], program);
|
||||||
setConfig({ ...saved, levels });
|
setConfig({ ...saved, levels });
|
||||||
setForm({ levels: levels.map(levelToForm) });
|
setForm({ levels: levels.map((level) => levelToForm(level, program)) });
|
||||||
setDrawerOpen(false);
|
setEditorMode("");
|
||||||
showToast("VIP 配置已保存", "success");
|
showToast("VIP 等级与权益已保存", "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "保存 VIP 配置失败", "error");
|
showToast(err.message || "保存 VIP 等级失败", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@ -93,95 +255,321 @@ export function useVipConfigPage() {
|
|||||||
return {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
activeCount,
|
activeCount,
|
||||||
closeDrawer,
|
appCode,
|
||||||
|
closeEditor,
|
||||||
config,
|
config,
|
||||||
drawerOpen,
|
editorMode,
|
||||||
|
error,
|
||||||
form,
|
form,
|
||||||
loading,
|
loading,
|
||||||
openDrawer,
|
openLevelsEditor,
|
||||||
|
openProgramEditor,
|
||||||
|
program,
|
||||||
|
programForm,
|
||||||
reload,
|
reload,
|
||||||
resourceGroups,
|
resourceGroups,
|
||||||
|
resources,
|
||||||
|
resourcesLoading,
|
||||||
saving,
|
saving,
|
||||||
submit,
|
submitLevels,
|
||||||
|
submitProgram,
|
||||||
|
updateAllBenefits,
|
||||||
|
updateBenefit,
|
||||||
updateLevel,
|
updateLevel,
|
||||||
|
updateProgramField,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultLevels() {
|
async function listAllVipResources() {
|
||||||
return Array.from({ length: 10 }, (_, index) => {
|
const items = [];
|
||||||
const level = index + 1;
|
const pageSize = 100;
|
||||||
return {
|
for (let page = 1; page <= 200; page += 1) {
|
||||||
level,
|
const result = await listResources({ page, page_size: pageSize, status: "active" });
|
||||||
name: `VIP${level}`,
|
items.push(...(result.items || []));
|
||||||
status: "disabled",
|
if (items.length >= Number(result.total || 0) || (result.items || []).length < pageSize) {
|
||||||
priceCoin: 0,
|
return items;
|
||||||
durationMs: defaultDurationMs,
|
}
|
||||||
rewardResourceGroupId: 0,
|
}
|
||||||
requiredRechargeCoinAmount: 0,
|
throw new Error("权益资源数量超过后台可加载上限");
|
||||||
rechargeGateRequired: false,
|
}
|
||||||
sortOrder: level * 10,
|
|
||||||
};
|
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) {
|
function explicitP1Benefits(benefits) {
|
||||||
const byLevel = new Map((levels || []).map((level) => [Number(level.level), level]));
|
// 每级权益是最终快照:只保留该等级真实返回的行,缺项既不按 unlockLevel 补齐,也不从体系目录继承。
|
||||||
return defaultLevels().map((fallback) => ({ ...fallback, ...(byLevel.get(fallback.level) || {}) }));
|
return (benefits || []).map((benefit) =>
|
||||||
|
benefit.benefitCode === "daily_coin_rebate" ? { ...benefit, trialEnabled: false } : benefit,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function levelToForm(level) {
|
export function levelToForm(level, program) {
|
||||||
return {
|
return {
|
||||||
level: Number(level.level || 0),
|
level: Number(level.level || 0),
|
||||||
name: level.name || `VIP${level.level}`,
|
name: level.name || `VIP${level.level}`,
|
||||||
status: level.status || "disabled",
|
status: level.status || "disabled",
|
||||||
priceCoin: level.priceCoin ? String(level.priceCoin) : "",
|
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) : "",
|
rewardResourceGroupId: level.rewardResourceGroupId ? String(level.rewardResourceGroupId) : "",
|
||||||
requiredRechargeCoinAmount: level.requiredRechargeCoinAmount ? String(level.requiredRechargeCoinAmount) : "",
|
|
||||||
sortOrder: String(level.sortOrder || Number(level.level || 0) * 10),
|
sortOrder: String(level.sortOrder || Number(level.level || 0) * 10),
|
||||||
|
benefits: benefitFormsForLevel(level, program),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function payloadFromForm(form) {
|
function benefitFormsForLevel(level, program) {
|
||||||
const levels = completeFormLevels(form.levels || []).map((level) => {
|
const explicitBenefits = level.benefits || [];
|
||||||
const priceCoin = Number(level.priceCoin || 0);
|
if (program?.programType !== "tiered_privilege_v1") {
|
||||||
const durationDays = Number(level.durationDays || 0);
|
return explicitBenefits.map((benefit, index) => benefitToForm(benefit, level.level, index, false));
|
||||||
const rewardResourceGroupId = Number(level.rewardResourceGroupId || 0);
|
}
|
||||||
const sortOrder = Number(level.sortOrder || level.level * 10);
|
const catalog = completeP1BenefitCatalog(program?.benefits || []);
|
||||||
const status = level.status === "active" ? "active" : "disabled";
|
const explicitByCode = new Map(explicitBenefits.map((benefit) => [benefit.benefitCode, benefit]));
|
||||||
if (!String(level.name || "").trim()) {
|
const catalogCodes = new Set(catalog.map((benefit) => benefit.benefitCode));
|
||||||
throw new Error(`VIP${level.level} 名称不能为空`);
|
const rows = catalog.map((template, index) => {
|
||||||
}
|
const explicit = explicitByCode.get(template.benefitCode);
|
||||||
if (!Number.isInteger(priceCoin) || priceCoin < 0) {
|
return benefitToForm(explicit || template, level.level, index, !explicit);
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
// 服务端可能先上线新权益而当前前端目录尚未更新;显式行必须保留,不能因目录版本差异在保存时被删除。
|
||||||
|
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 };
|
return { levels };
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeFormLevels(levels) {
|
function levelPayload(level, program) {
|
||||||
const byLevel = new Map((levels || []).map((level) => [Number(level.level), level]));
|
const priceCoin = Number(level.priceCoin || 0);
|
||||||
return defaultLevels().map((fallback) => ({
|
const durationDays = Number(level.durationDays || 0);
|
||||||
...levelToForm(fallback),
|
const rewardResourceGroupId = Number(level.rewardResourceGroupId || 0);
|
||||||
...(byLevel.get(fallback.level) || {}),
|
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;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/activity/room-rocket/config": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3304,6 +3368,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/app/users/{id}/login-logs": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3384,6 +3464,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/auth/change-password": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3960,6 +4056,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/jobs/{id}/cancel": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -4172,6 +4284,386 @@ export interface components {
|
|||||||
H5LinkDeleteResult: {
|
H5LinkDeleteResult: {
|
||||||
deleted: boolean;
|
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: {
|
ApiPageAgency: {
|
||||||
items: components["schemas"]["Agency"][];
|
items: components["schemas"]["Agency"][];
|
||||||
page: number;
|
page: number;
|
||||||
@ -4689,15 +5181,7 @@ export interface components {
|
|||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
availableDelta: number;
|
availableDelta: number;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
bizType:
|
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";
|
||||||
| "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;
|
commandId?: string;
|
||||||
counterpartyUserId?: string;
|
counterpartyUserId?: string;
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
@ -5029,13 +5513,7 @@ export interface components {
|
|||||||
appCode: string;
|
appCode: string;
|
||||||
appName?: string;
|
appName?: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
operation:
|
operation: "user_coin_credit" | "user_coin_debit" | "user_wallet_debit" | "user_wallet_credit" | "coin_seller_coin_credit" | "coin_seller_coin_debit";
|
||||||
| "user_coin_credit"
|
|
||||||
| "user_coin_debit"
|
|
||||||
| "user_wallet_debit"
|
|
||||||
| "user_wallet_credit"
|
|
||||||
| "coin_seller_coin_credit"
|
|
||||||
| "coin_seller_coin_debit";
|
|
||||||
walletIdentity?: string;
|
walletIdentity?: string;
|
||||||
targetUserId: string;
|
targetUserId: string;
|
||||||
coinAmount: number;
|
coinAmount: number;
|
||||||
@ -5117,13 +5595,7 @@ export interface components {
|
|||||||
FinanceApplicationInput: {
|
FinanceApplicationInput: {
|
||||||
appCode: string;
|
appCode: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
operation:
|
operation: "user_coin_credit" | "user_coin_debit" | "user_wallet_debit" | "user_wallet_credit" | "coin_seller_coin_credit" | "coin_seller_coin_debit";
|
||||||
| "user_coin_credit"
|
|
||||||
| "user_coin_debit"
|
|
||||||
| "user_wallet_debit"
|
|
||||||
| "user_wallet_credit"
|
|
||||||
| "coin_seller_coin_credit"
|
|
||||||
| "coin_seller_coin_debit";
|
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
walletIdentity?: "host" | "agency" | "bd";
|
walletIdentity?: "host" | "agency" | "bd";
|
||||||
targetUserId: string;
|
targetUserId: string;
|
||||||
@ -5207,6 +5679,60 @@ export interface components {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description OK */
|
/** @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: {
|
AgencyPageResponse: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
@ -5351,6 +5877,24 @@ export interface components {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description OK */
|
/** @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: {
|
EmptyResponse: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
@ -6458,6 +7002,94 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
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: {
|
getRoomRocketConfig: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -8111,11 +8743,7 @@ export interface operations {
|
|||||||
seller_filter_user_id?: string;
|
seller_filter_user_id?: string;
|
||||||
seller_display_user_id?: string;
|
seller_display_user_id?: string;
|
||||||
seller_username?: string;
|
seller_username?: string;
|
||||||
ledger_type?:
|
ledger_type?: "admin_stock_credit" | "seller_transfer" | "sub_seller_transfer" | "salary_transfer_received";
|
||||||
| "admin_stock_credit"
|
|
||||||
| "seller_transfer"
|
|
||||||
| "sub_seller_transfer"
|
|
||||||
| "salary_transfer_received";
|
|
||||||
start_at_ms?: number;
|
start_at_ms?: number;
|
||||||
end_at_ms?: number;
|
end_at_ms?: number;
|
||||||
};
|
};
|
||||||
@ -8138,11 +8766,7 @@ export interface operations {
|
|||||||
seller_filter_user_id?: string;
|
seller_filter_user_id?: string;
|
||||||
seller_display_user_id?: string;
|
seller_display_user_id?: string;
|
||||||
seller_username?: string;
|
seller_username?: string;
|
||||||
ledger_type?:
|
ledger_type?: "admin_stock_credit" | "seller_transfer" | "sub_seller_transfer" | "salary_transfer_received";
|
||||||
| "admin_stock_credit"
|
|
||||||
| "seller_transfer"
|
|
||||||
| "sub_seller_transfer"
|
|
||||||
| "salary_transfer_received";
|
|
||||||
start_at_ms?: number;
|
start_at_ms?: number;
|
||||||
end_at_ms?: number;
|
end_at_ms?: number;
|
||||||
};
|
};
|
||||||
@ -8196,13 +8820,7 @@ export interface operations {
|
|||||||
sent_at_ms?: number;
|
sent_at_ms?: number;
|
||||||
summary: string;
|
summary: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
target_scope:
|
target_scope: "all_registered_users" | "all_active_users" | "single_user" | "user_ids" | "region" | "country";
|
||||||
| "all_registered_users"
|
|
||||||
| "all_active_users"
|
|
||||||
| "single_user"
|
|
||||||
| "user_ids"
|
|
||||||
| "region"
|
|
||||||
| "country";
|
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
target_user_id?: number;
|
target_user_id?: number;
|
||||||
title: string;
|
title: string;
|
||||||
@ -9787,14 +10405,28 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
appListUsers: {
|
appListUsers: {
|
||||||
parameters: {
|
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;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
responses: {
|
responses: {
|
||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["AppUserPageResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
appGetUser: {
|
appGetUser: {
|
||||||
@ -9808,7 +10440,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
responses: {
|
responses: {
|
||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["AppUserResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
appUpdateUser: {
|
appUpdateUser: {
|
||||||
@ -9820,9 +10452,13 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AppUserUpdateInput"];
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["AppUserResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
appBanUser: {
|
appBanUser: {
|
||||||
@ -9834,9 +10470,31 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AppUserBanInput"];
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
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: {
|
appListUserLoginLogs: {
|
||||||
@ -9850,7 +10508,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
responses: {
|
responses: {
|
||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["AppUserLoginLogPageResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
appSetPassword: {
|
appSetPassword: {
|
||||||
@ -9862,7 +10520,11 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AppUserPasswordInput"];
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
@ -9876,9 +10538,13 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AppUserUnbanInput"];
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["AppUserStatusMutationResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
appListBannedUsers: {
|
appListBannedUsers: {
|
||||||
@ -9890,7 +10556,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
responses: {
|
responses: {
|
||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["AppUserBanRecordPageResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
appListLoginLogs: {
|
appListLoginLogs: {
|
||||||
@ -9902,7 +10568,31 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
responses: {
|
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: {
|
changePassword: {
|
||||||
@ -10572,6 +11262,28 @@ export interface operations {
|
|||||||
200: components["responses"]["AdminJobResponse"];
|
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: {
|
cancelJob: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -1000,8 +1000,11 @@ export interface CoinSellerSalaryRatesPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AppUserDto {
|
export interface AppUserDto {
|
||||||
|
age?: number;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
balances?: AppUserAssetBalanceDto[];
|
balances?: AppUserAssetBalanceDto[];
|
||||||
|
ban?: AppUserBanDto;
|
||||||
|
birth?: string;
|
||||||
coin?: number;
|
coin?: number;
|
||||||
country?: string;
|
country?: string;
|
||||||
countryDisplayName?: string;
|
countryDisplayName?: string;
|
||||||
@ -1013,16 +1016,56 @@ export interface AppUserDto {
|
|||||||
equippedResources?: AppUserResourceDto[];
|
equippedResources?: AppUserResourceDto[];
|
||||||
gender?: string;
|
gender?: string;
|
||||||
lastActiveAtMs?: number;
|
lastActiveAtMs?: number;
|
||||||
|
lastLoginAtMs?: number;
|
||||||
|
lastOperatedAtMs?: number;
|
||||||
|
lastOperator?: AppUserOperatorDto;
|
||||||
|
levels?: AppUserLevelsDto;
|
||||||
prettyDisplayUserId?: string;
|
prettyDisplayUserId?: string;
|
||||||
prettyId?: string;
|
prettyId?: string;
|
||||||
regionId?: number;
|
regionId?: number;
|
||||||
regionName?: string;
|
regionName?: string;
|
||||||
|
registerDevice?: string;
|
||||||
resources?: AppUserResourceDto[];
|
resources?: AppUserResourceDto[];
|
||||||
|
roles?: string[];
|
||||||
status?: string;
|
status?: string;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
vip?: AppUserVIPDto;
|
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 {
|
export interface AppUserAssetBalanceDto {
|
||||||
@ -1131,6 +1174,30 @@ export interface AppUserUpdatePayload {
|
|||||||
username?: string;
|
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 {
|
export interface AppUserPasswordPayload {
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
@ -1509,8 +1576,8 @@ export interface H5LinkConfigDto {
|
|||||||
appCode: string;
|
appCode: string;
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
updatedAtMs?: number;
|
updatedAtMs: number;
|
||||||
url?: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface H5LinkConfigPayload {
|
export interface H5LinkConfigPayload {
|
||||||
|
|||||||
@ -23,7 +23,7 @@
|
|||||||
.kpi-grid,
|
.kpi-grid,
|
||||||
.panel-grid,
|
.panel-grid,
|
||||||
.overview-charts,
|
.overview-charts,
|
||||||
.frequent-menu-grid,
|
.workspace-entry-grid,
|
||||||
.user-kpi-row,
|
.user-kpi-row,
|
||||||
.page-skeleton__kpis,
|
.page-skeleton__kpis,
|
||||||
.page-skeleton__charts {
|
.page-skeleton__charts {
|
||||||
@ -102,7 +102,7 @@
|
|||||||
.kpi-grid,
|
.kpi-grid,
|
||||||
.panel-grid,
|
.panel-grid,
|
||||||
.overview-charts,
|
.overview-charts,
|
||||||
.frequent-menu-grid,
|
.workspace-entry-grid,
|
||||||
.user-kpi-row,
|
.user-kpi-row,
|
||||||
.alert-strip,
|
.alert-strip,
|
||||||
.page-skeleton__kpis,
|
.page-skeleton__kpis,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user