This commit is contained in:
zhx 2026-06-11 18:40:23 +08:00
parent 7a6f0db859
commit 103b70f5e5
6 changed files with 143 additions and 73 deletions

View File

@ -42,17 +42,19 @@ export const taskAudienceLabels = {
export const taskActionOptions = [
["none", "无跳转"],
["room_random", "随机房间"],
["room_random_game", "随机房间并打开游戏"],
["wallet", "钱包"],
["gift_panel", "礼物面板"],
["gift_panel_specific", "指定礼物面板"],
["app", "APP 公共跳转"],
];
export const taskActionLabels = {
app: "APP 公共跳转",
explore_game: "Explore 游戏 H5",
gift_panel: "礼物面板",
gift_panel_specific: "指定礼物面板",
room: "指定房间",
room_game: "指定房内拉起游戏",
none: "无跳转",
room_random_window: "随机房内拉起窗口",
room_window: "指定房内拉起窗口",
room_random: "随机房间",
room_random_game: "随机房间并打开游戏",
wallet: "钱包",
@ -60,6 +62,8 @@ export const taskActionLabels = {
export const taskMetricOptions = [
["game_spend_coin", "游戏消耗金币"],
["game_dice_play_count", "玩 Dice Battle 局数"],
["game_rock_play_count", "玩 Rock Paper Scissors 局数"],
["gift_spend_coin", "普通礼物消耗金币"],
["gift_send_count", "赠送礼物次数"],
["lucky_gift_spend_coin", "幸运礼物消耗金币"],

View File

@ -10,21 +10,22 @@ import {
} from "@/features/daily-tasks/api";
import { useDailyTaskAbilities } from "@/features/daily-tasks/permissions.js";
import { dailyTaskFormSchema } from "@/features/daily-tasks/schema";
import { buildAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyForm = (task = {}) => {
const actionType = task.actionType || "none";
const flutterJumpParam = taskFlutterJumpParam(actionType, task.actionParam, task.actionPayloadJson);
const appJumpParam = taskAppJumpParam(actionType, task.actionParam, task.actionPayloadJson);
return {
appJumpParam,
actionParam: task.actionParam || "",
actionType,
actionType: actionType === "none" ? "none" : appJumpTypeFromParam(appJumpParam),
category: task.category || "",
description: task.description || "",
effectiveFrom: msToDatetimeLocal(task.effectiveFromMs),
effectiveTo: msToDatetimeLocal(task.effectiveToMs),
flutterJumpParam,
iconUrl: task.iconUrl || "",
metricType: task.metricType || "",
rewardCoinAmount: task.rewardCoinAmount === 0 || task.rewardCoinAmount ? String(task.rewardCoinAmount) : "",
@ -102,11 +103,12 @@ export function useDailyTasksPage() {
};
const changeFormActionType = (actionType) => {
const nextAppJumpParam = actionType === "app" ? buildAppJumpParam({ target: "wallet" }) : "";
setForm((current) => ({
...current,
actionParam: "",
actionType,
flutterJumpParam: defaultFlutterJumpParam(actionType),
actionType: actionType === "app" ? "wallet" : "none",
appJumpParam: nextAppJumpParam,
}));
};
@ -158,6 +160,7 @@ export function useDailyTasksPage() {
return {
abilities,
activeAction,
appJumpTypeFromParam,
changeFormActionType,
changeQuery,
changeStatus,
@ -185,16 +188,15 @@ export function useDailyTasksPage() {
}
export function buildTaskPayload(form) {
// 表单不再暴露“跳转扩展 JSON”但后端和 App 仍读取 action_payload_json。
// 这里把可编辑的 Flutter 跳转参数压缩成对象 JSON既保留后台配置能力也避免 UI 暴露底层字段名。
const actionPayloadJSON = normalizedJSONObject(form.flutterJumpParam || defaultFlutterJumpParam(form.actionType));
// 每日任务现在和 banner/弹窗共用公共 APP 跳转 JSONaction_type/action_param 继续回填短字段,保证旧 H5 或旧 App 只读短字段时仍能打开。
const appJumpEnabled = form.actionType !== "none";
const actionPayloadJSON = appJumpEnabled ? normalizedJSONObject(form.appJumpParam || defaultAppJumpParam()) : "{}";
const actionType = appJumpEnabled ? appJumpTypeFromParam(actionPayloadJSON) : "none";
return {
// room_random_game / gift_panel_specific 的旧 App 合约仍从 action_param 读取 game_id/gift_id。
// 新表单只让运营维护一份 Flutter 参数,所以提交前从对象里反抽标量,保证新旧字段保持同一含义。
action_param: actionParamFromFlutterJump(form.actionType, actionPayloadJSON, form.actionParam),
action_param: appJumpEnabled ? actionParamFromAppJump(actionPayloadJSON, form.actionParam) : "",
action_payload_json: actionPayloadJSON,
action_type: form.actionType,
action_type: actionType,
// 人群、维度过滤、图标键已从表单移除:任务默认全量用户可见,不做事件维度限制,图标只使用上传 URL。
audience_type: "all",
category: hiddenTaskCategory(form),
@ -215,42 +217,62 @@ export function buildTaskPayload(form) {
};
}
export function defaultFlutterJumpParam(actionType) {
const template =
{
gift_panel: { route: "gift_panel" },
gift_panel_specific: { gift_id: "", route: "gift_panel" },
none: {},
room_random: { route: "room_random" },
room_random_game: { game_id: "", route: "room_random", show_game: true },
wallet: { route: "wallet" },
}[actionType] || {};
return JSON.stringify(template, null, 2);
export function defaultAppJumpParam() {
return buildAppJumpParam({ target: "wallet" });
}
function taskFlutterJumpParam(actionType, actionParam, actionPayloadJSON) {
function taskAppJumpParam(actionType, actionParam, actionPayloadJSON) {
const parsed = parseObjectJSON(actionPayloadJSON);
const payload = Object.keys(parsed).length > 0 ? parsed : parseObjectJSON(defaultFlutterJumpParam(actionType));
if (Object.keys(parsed).length > 0 && String(parsed.type || "").trim()) {
return JSON.stringify(parsed);
}
const scalarParam = String(actionParam || "").trim();
if (actionType === "room_random_game" && scalarParam && !payload.game_id) {
payload.game_id = scalarParam;
switch (String(actionType || "").trim()) {
case "wallet":
return buildAppJumpParam({ target: "wallet" });
case "room_random":
return buildAppJumpParam({ target: "room_random" });
case "room_random_game":
return buildAppJumpParam({ gameId: scalarParam, target: "room_random_game" });
case "gift_panel":
return buildAppJumpParam({ target: "room_random_window", window: "gift_panel" });
case "gift_panel_specific":
return buildAppJumpParam({ giftId: scalarParam, target: "room_random_window", window: "gift_panel" });
case "explore_game":
return buildAppJumpParam({ gameCode: scalarParam, target: "explore_game" });
case "room":
case "room_window":
case "room_random_window":
case "room_game":
return buildAppJumpParam({ target: actionType });
default:
return "";
}
if (actionType === "gift_panel_specific" && scalarParam && !payload.gift_id) {
payload.gift_id = scalarParam;
}
return JSON.stringify(payload, null, 2);
}
function actionParamFromFlutterJump(actionType, actionPayloadJSON, fallback = "") {
export function appJumpTypeFromParam(actionPayloadJSON) {
const payload = parseObjectJSON(actionPayloadJSON);
return String(payload.type || "").trim() || "wallet";
}
if (actionType === "room_random_game") {
function actionParamFromAppJump(actionPayloadJSON, fallback = "") {
const payload = parseObjectJSON(actionPayloadJSON);
const target = String(payload.type || "").trim();
if (target === "explore_game") {
return String(payload.game_code || payload.gameCode || fallback || "").trim();
}
if (target === "room_game" || target === "room_random_game") {
return String(payload.game_id || payload.gameId || fallback || "").trim();
}
if (actionType === "gift_panel_specific") {
if (target === "room" || target === "room_window") {
return String(payload.room_id || payload.roomId || fallback || "").trim();
}
if (target === "room_window" || target === "room_random_window") {
if (String(payload.window || "").trim() === "game_window") {
return String(payload.game_id || payload.gameId || fallback || "").trim();
}
return String(payload.gift_id || payload.giftId || fallback || "").trim();
}
return String(fallback || "").trim();

View File

@ -21,6 +21,7 @@ import {
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import {
@ -384,7 +385,7 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
label="跳转类型"
required
select
value={form.actionType}
value={form.actionType === "none" ? "none" : "app"}
onChange={(event) => page.changeFormActionType(event.target.value)}
>
{taskActionOptions.map(([value, label]) => (
@ -402,15 +403,26 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
onChange={(iconUrl) => page.setForm({ ...form, iconUrl })}
/>
</div>
<TextField
className={styles.flutterJumpField}
disabled={disabled}
multiline
minRows={5}
label="Flutter 跳转参数"
value={form.flutterJumpParam}
onChange={(event) => page.setForm({ ...form, flutterJumpParam: event.target.value })}
/>
{form.actionType === "none" ? null : (
<AppJumpConfigField
allowCustom={false}
allowH5={false}
appParamLabel="APP 公共跳转 JSON"
className={styles.flutterJumpField}
disabled={disabled}
paramValue={form.appJumpParam}
required
typeLabel="跳转协议"
typeValue="app"
onChange={({ param }) =>
page.setForm({
...form,
actionType: page.appJumpTypeFromParam(param),
appJumpParam: param,
})
}
/>
)}
</div>
</AdminFormSection>
<AdminFormSection title="有效期">

View File

@ -1,14 +1,29 @@
import { z } from "zod";
import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
const taskActionTypes = [
"none",
"wallet",
"room",
"room_random",
"room_window",
"room_random_window",
"room_game",
"room_random_game",
"explore_game",
"gift_panel",
"gift_panel_specific",
] as const;
export const dailyTaskFormSchema = z
.object({
appJumpParam: z.string().trim().max(2048, "APP 跳转参数不能超过 2048 个字符").optional(),
actionParam: z.string().trim().max(512, "跳转参数不能超过 512 个字符").optional(),
actionType: z.enum(["none", "room_random", "room_random_game", "wallet", "gift_panel", "gift_panel_specific"]),
actionType: z.enum(taskActionTypes),
category: z.string().trim().max(32, "任务分类不能超过 32 个字符").optional(),
description: z.string().trim().max(512, "描述不能超过 512 个字符"),
effectiveFrom: z.string().optional(),
effectiveTo: z.string().optional(),
flutterJumpParam: z.string().trim().optional(),
iconUrl: z.string().trim().max(512, "图标 URL 不能超过 512 个字符"),
metricType: z.string().trim().min(1, "请输入指标类型").max(64, "指标类型不能超过 64 个字符"),
rewardCoinAmount: z.union([z.string(), z.number()]),
@ -44,11 +59,21 @@ export const dailyTaskFormSchema = z
if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) {
context.addIssue({ code: "custom", message: "结束时间必须晚于开始时间", path: ["effectiveTo"] });
}
if (!isJSONObject(value.flutterJumpParam)) {
if (value.actionType !== "none") {
const validation = validatePublicAppJumpParam(value.appJumpParam || "");
if (!validation.valid) {
context.addIssue({
code: "custom",
message: validation.message,
path: ["appJumpParam"],
});
}
}
if (!isJSONObject(value.appJumpParam)) {
context.addIssue({
code: "custom",
message: "Flutter 跳转参数必须是对象格式",
path: ["flutterJumpParam"],
message: "APP 跳转参数必须是对象格式",
path: ["appJumpParam"],
});
}
});

View File

@ -1,38 +1,38 @@
import { expect, test } from "vitest";
import { parseForm } from "@/shared/forms/validation";
import { buildTaskPayload, defaultFlutterJumpParam } from "./hooks/useDailyTasksPage.js";
import { buildTaskPayload, defaultAppJumpParam } from "./hooks/useDailyTasksPage.js";
import { dailyTaskFormSchema } from "./schema";
test("daily task payload hides removed admin fields and keeps Flutter jump params editable", () => {
test("daily task payload stores public app jump params for explore game", () => {
const form = parseForm(dailyTaskFormSchema, {
actionType: "room_random_game",
actionType: "explore_game",
appJumpParam: '{"type":"explore_game","game_code":"game_dice"}',
description: "",
effectiveFrom: "",
effectiveTo: "",
flutterJumpParam: JSON.stringify({ game_id: "dice", route: "room_random", show_game: true }, null, 2),
iconUrl: "https://media.haiyihy.com/admin/images/task-game.png",
metricType: "game_spend_coin",
metricType: "game_dice_play_count",
rewardCoinAmount: "20",
sortOrder: "3",
status: "active",
targetUnit: "coin",
targetValue: "100",
targetUnit: "count",
targetValue: "1",
taskType: "daily",
title: "玩游戏消耗金币",
title: "玩 Dice Battle 1 局",
});
const payload = buildTaskPayload(form);
expect(payload.category).toBe("game_spend_coin");
expect(payload.category).toBe("game_dice_play_count");
expect(payload.audience_type).toBe("all");
expect(payload.dimension_filter_json).toBe("{}");
expect(payload.icon_key).toBe("");
expect(payload.icon_url).toBe("https://media.haiyihy.com/admin/images/task-game.png");
expect(payload.action_param).toBe("dice");
expect(payload.action_payload_json).toBe('{"game_id":"dice","route":"room_random","show_game":true}');
expect(payload.action_type).toBe("explore_game");
expect(payload.action_param).toBe("game_dice");
expect(payload.action_payload_json).toBe('{"type":"explore_game","game_code":"game_dice"}');
});
test("daily task action type selection provides concrete Flutter jump template", () => {
expect(defaultFlutterJumpParam("gift_panel_specific")).toContain('"gift_id": ""');
expect(defaultFlutterJumpParam("wallet")).toContain('"route": "wallet"');
test("daily task action type selection provides public app jump template", () => {
expect(defaultAppJumpParam()).toBe('{"type":"wallet"}');
});

View File

@ -13,6 +13,8 @@ import { AdminFormFieldGrid } from "@/shared/ui/AdminFormDialog.jsx";
import styles from "@/shared/ui/AppJumpConfigField.module.css";
export function AppJumpConfigField({
allowCustom = true,
allowH5 = true,
appParamLabel = "APP 参数 JSON",
className = "",
disabled = false,
@ -25,6 +27,11 @@ export function AppJumpConfigField({
}) {
const type = normalizeJumpConfigType(typeValue);
const appJump = useMemo(() => parseAppJumpParam(paramValue), [paramValue]);
const appTargetOptions = useMemo(
() => (allowCustom ? appJumpTargetOptions : appJumpTargetOptions.filter(([value]) => value !== "custom")),
[allowCustom],
);
const appTargetValue = !allowCustom && appJump.target === "custom" ? "wallet" : appJump.target;
const setType = (nextType) => {
if (nextType === "app") {
onChange({ param: buildAppJumpParam({ target: "wallet" }), type: "app" });
@ -52,7 +59,7 @@ export function AppJumpConfigField({
value={type}
onChange={(event) => setType(event.target.value)}
>
<MenuItem value="h5">H5</MenuItem>
{allowH5 ? <MenuItem value="h5">H5</MenuItem> : null}
<MenuItem value="app">APP</MenuItem>
</TextField>
{type === "h5" ? (
@ -71,10 +78,10 @@ export function AppJumpConfigField({
label="APP 目标"
required
select
value={appJump.target}
value={appTargetValue}
onChange={(event) => setAppJump({ target: event.target.value })}
>
{appJumpTargetOptions.map(([value, label]) => (
{appTargetOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>