Skip to content

Commit 1fac28c

Browse files
dengzhaofunclaude
andauthored
Add level (关卡) module — config/stage/level hierarchy + admin dashboard (#20)
Three-tier level progression system for the game SaaS platform: LevelConfig → LevelStage (optional) → Level, with recursive JSONB unlock rules, star-based scoring, and unified reward delivery. Server: schema (4 tables), service with evaluateUnlockRule pure function, admin CRUD routes (15 endpoints), client API (5 endpoints: overview, detail, reportClear, claimRewards), 31 passing service tests. Admin: types, TanStack Query hooks, config list/create/detail pages with stages & levels management, i18n (en/zh), sidebar nav entry. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7611d42 commit 1fac28c

24 files changed

Lines changed: 16751 additions & 2 deletions

File tree

apps/admin/messages/en.json

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -835,5 +835,63 @@
835835
"entity_add_stat": "Add Stat",
836836
"entity_add_tag": "Add Tag",
837837
"entity_add_slot": "Add Slot",
838-
"entity_add_rank": "Add Rank"
838+
"entity_add_rank": "Add Rank",
839+
840+
"nav_level": "Levels",
841+
"level_title": "Level Configs",
842+
"level_new_config": "New Config",
843+
"level_create_config": "Create Level Config",
844+
"level_failed_load": "Failed to load:",
845+
"level_not_found": "Config not found",
846+
"level_empty": "No level configs yet.",
847+
"level_config_created": "Config created",
848+
"level_config_saved": "Saved",
849+
"level_config_deleted": "Config deleted",
850+
"level_failed_create": "Failed to create",
851+
"level_failed_save": "Failed to save",
852+
"level_failed_delete": "Failed to delete",
853+
"level_delete_config": "Delete Config",
854+
"level_delete_config_desc": "Are you sure you want to delete this config? All stages, levels and player progress will be wiped.",
855+
"level_config_name": "Name",
856+
"level_config_alias": "Alias (optional)",
857+
"level_config_description": "Description",
858+
"level_config_has_stages": "Has Stages",
859+
"level_config_cover_image": "Cover Image URL",
860+
"level_config_icon": "Icon URL",
861+
"level_back_to_list": "Back to list",
862+
"level_tab_info": "Info",
863+
"level_tab_stages": "Stages",
864+
"level_tab_levels": "Levels",
865+
"level_new_stage": "New Stage",
866+
"level_edit_stage": "Edit Stage",
867+
"level_stage_empty": "No stages yet.",
868+
"level_stage_created": "Stage created",
869+
"level_stage_saved": "Saved",
870+
"level_stage_deleted": "Stage deleted",
871+
"level_delete_stage_desc": "Are you sure you want to delete this stage? Levels under it will lose their stage reference.",
872+
"level_new_level": "New Level",
873+
"level_edit_level": "Edit Level",
874+
"level_level_empty": "No levels yet.",
875+
"level_level_created": "Level created",
876+
"level_level_saved": "Saved",
877+
"level_level_deleted": "Level deleted",
878+
"level_delete_level_desc": "Are you sure you want to delete this level? Player progress for it will be wiped.",
879+
"level_field_name": "Name",
880+
"level_field_alias": "Alias",
881+
"level_field_description": "Description",
882+
"level_field_difficulty": "Difficulty",
883+
"level_field_max_stars": "Max Stars",
884+
"level_field_stage": "Stage",
885+
"level_field_stage_none": "-- No stage --",
886+
"level_field_unlock_rule": "Unlock Rule (JSON)",
887+
"level_field_clear_rewards": "Clear Rewards (JSON)",
888+
"level_field_star_rewards": "Star Rewards (JSON)",
889+
"level_field_metadata": "Metadata (JSON)",
890+
"level_col_name": "Name",
891+
"level_col_alias": "Alias",
892+
"level_col_has_stages": "Stages",
893+
"level_col_difficulty": "Difficulty",
894+
"level_col_max_stars": "Stars",
895+
"level_col_stage": "Stage",
896+
"level_filter_all_stages": "All stages"
839897
}

apps/admin/messages/zh.json

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -835,5 +835,63 @@
835835
"entity_add_stat": "添加属性",
836836
"entity_add_tag": "添加标签",
837837
"entity_add_slot": "添加槽位",
838-
"entity_add_rank": "添加阶位"
838+
"entity_add_rank": "添加阶位",
839+
840+
"nav_level": "关卡",
841+
"level_title": "关卡配置",
842+
"level_new_config": "新建配置",
843+
"level_create_config": "新建关卡配置",
844+
"level_failed_load": "加载失败:",
845+
"level_not_found": "未找到配置",
846+
"level_empty": "暂无关卡配置。",
847+
"level_config_created": "配置已创建",
848+
"level_config_saved": "已保存",
849+
"level_config_deleted": "配置已删除",
850+
"level_failed_create": "创建失败",
851+
"level_failed_save": "保存失败",
852+
"level_failed_delete": "删除失败",
853+
"level_delete_config": "删除配置",
854+
"level_delete_config_desc": "确定要删除该配置吗?所有阶段、关卡和玩家进度都会被清除。",
855+
"level_config_name": "名称",
856+
"level_config_alias": "别名(可选)",
857+
"level_config_description": "描述",
858+
"level_config_has_stages": "启用阶段",
859+
"level_config_cover_image": "封面图 URL",
860+
"level_config_icon": "图标 URL",
861+
"level_back_to_list": "返回列表",
862+
"level_tab_info": "基本信息",
863+
"level_tab_stages": "阶段",
864+
"level_tab_levels": "关卡",
865+
"level_new_stage": "新建阶段",
866+
"level_edit_stage": "编辑阶段",
867+
"level_stage_empty": "暂无阶段。",
868+
"level_stage_created": "阶段已创建",
869+
"level_stage_saved": "已保存",
870+
"level_stage_deleted": "阶段已删除",
871+
"level_delete_stage_desc": "确定要删除该阶段吗?其下的关卡将失去阶段引用。",
872+
"level_new_level": "新建关卡",
873+
"level_edit_level": "编辑关卡",
874+
"level_level_empty": "暂无关卡。",
875+
"level_level_created": "关卡已创建",
876+
"level_level_saved": "已保存",
877+
"level_level_deleted": "关卡已删除",
878+
"level_delete_level_desc": "确定要删除该关卡吗?玩家进度将被清除。",
879+
"level_field_name": "名称",
880+
"level_field_alias": "别名",
881+
"level_field_description": "描述",
882+
"level_field_difficulty": "难度",
883+
"level_field_max_stars": "最大星数",
884+
"level_field_stage": "阶段",
885+
"level_field_stage_none": "-- 无阶段 --",
886+
"level_field_unlock_rule": "解锁规则 (JSON)",
887+
"level_field_clear_rewards": "通关奖励 (JSON)",
888+
"level_field_star_rewards": "星级奖励 (JSON)",
889+
"level_field_metadata": "元数据 (JSON)",
890+
"level_col_name": "名称",
891+
"level_col_alias": "别名",
892+
"level_col_has_stages": "阶段",
893+
"level_col_difficulty": "难度",
894+
"level_col_max_stars": "星数",
895+
"level_col_stage": "阶段",
896+
"level_filter_all_stages": "全部阶段"
839897
}

apps/admin/src/components/AppSidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
KeyRound,
1010
LayoutDashboard,
1111
Mail,
12+
Map,
1213
MessagesSquare,
1314
Package,
1415
Shield,
@@ -48,6 +49,7 @@ function getNavItems() {
4849
{ title: m.nav_banner(), to: "/banner" as const, icon: GalleryHorizontal },
4950
{ title: m.nav_dialogue(), to: "/dialogue" as const, icon: MessagesSquare },
5051
{ title: m.nav_collection(), to: "/collection" as const, icon: BookOpen },
52+
{ title: m.nav_level(), to: "/level" as const, icon: Map },
5153
{ title: m.nav_friend(), to: "/friend" as const, icon: Users },
5254
{ title: m.nav_guild(), to: "/guild" as const, icon: Shield },
5355
{ title: m.nav_team(), to: "/team" as const, icon: Swords },

apps/admin/src/hooks/use-level.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
2+
3+
import { api } from "#/lib/api-client"
4+
import type {
5+
ConfigListResponse,
6+
CreateConfigInput,
7+
CreateLevelInput,
8+
CreateStageInput,
9+
Level,
10+
LevelConfig,
11+
LevelListResponse,
12+
LevelStage,
13+
StageListResponse,
14+
UpdateConfigInput,
15+
UpdateLevelInput,
16+
UpdateStageInput,
17+
} from "#/lib/types/level"
18+
19+
const CONFIGS_KEY = ["level-configs"] as const
20+
const configKey = (key: string) => ["level-config", key] as const
21+
const stagesKey = (configId: string) =>
22+
["level-config", configId, "stages"] as const
23+
const levelsKey = (configId: string) =>
24+
["level-config", configId, "levels"] as const
25+
const levelKey = (id: string) => ["level", id] as const
26+
27+
// ─── Configs ─────────────────────────────────────────────────────
28+
29+
export function useLevelConfigs() {
30+
return useQuery({
31+
queryKey: CONFIGS_KEY,
32+
queryFn: () => api.get<ConfigListResponse>("/api/level/configs"),
33+
select: (data) => data.items,
34+
})
35+
}
36+
37+
export function useLevelConfig(key: string) {
38+
return useQuery({
39+
queryKey: configKey(key),
40+
queryFn: () => api.get<LevelConfig>(`/api/level/configs/${key}`),
41+
enabled: !!key,
42+
})
43+
}
44+
45+
export function useCreateLevelConfig() {
46+
const qc = useQueryClient()
47+
return useMutation({
48+
mutationFn: (input: CreateConfigInput) =>
49+
api.post<LevelConfig>("/api/level/configs", input),
50+
onSuccess: () => qc.invalidateQueries({ queryKey: CONFIGS_KEY }),
51+
})
52+
}
53+
54+
export function useUpdateLevelConfig() {
55+
const qc = useQueryClient()
56+
return useMutation({
57+
mutationFn: ({ id, input }: { id: string; input: UpdateConfigInput }) =>
58+
api.put<LevelConfig>(`/api/level/configs/${id}`, input),
59+
onSuccess: (_row, vars) => {
60+
qc.invalidateQueries({ queryKey: CONFIGS_KEY })
61+
qc.invalidateQueries({ queryKey: configKey(vars.id) })
62+
},
63+
})
64+
}
65+
66+
export function useDeleteLevelConfig() {
67+
const qc = useQueryClient()
68+
return useMutation({
69+
mutationFn: (id: string) => api.delete(`/api/level/configs/${id}`),
70+
onSuccess: () => qc.invalidateQueries({ queryKey: CONFIGS_KEY }),
71+
})
72+
}
73+
74+
// ─── Stages ──────────────────────────────────────────────────────
75+
76+
export function useLevelStages(configId: string) {
77+
return useQuery({
78+
queryKey: stagesKey(configId),
79+
queryFn: () =>
80+
api.get<StageListResponse>(
81+
`/api/level/configs/${configId}/stages`,
82+
),
83+
select: (data) => data.items,
84+
enabled: !!configId,
85+
})
86+
}
87+
88+
export function useCreateLevelStage() {
89+
const qc = useQueryClient()
90+
return useMutation({
91+
mutationFn: ({
92+
configId,
93+
input,
94+
}: {
95+
configId: string
96+
input: CreateStageInput
97+
}) =>
98+
api.post<LevelStage>(
99+
`/api/level/configs/${configId}/stages`,
100+
input,
101+
),
102+
onSuccess: (_row, vars) => {
103+
qc.invalidateQueries({ queryKey: stagesKey(vars.configId) })
104+
},
105+
})
106+
}
107+
108+
export function useUpdateLevelStage() {
109+
const qc = useQueryClient()
110+
return useMutation({
111+
mutationFn: ({
112+
id,
113+
input,
114+
}: {
115+
id: string
116+
configId: string
117+
input: UpdateStageInput
118+
}) => api.put<LevelStage>(`/api/level/stages/${id}`, input),
119+
onSuccess: (_row, vars) => {
120+
qc.invalidateQueries({ queryKey: stagesKey(vars.configId) })
121+
},
122+
})
123+
}
124+
125+
export function useDeleteLevelStage() {
126+
const qc = useQueryClient()
127+
return useMutation({
128+
mutationFn: ({ id }: { id: string; configId: string }) =>
129+
api.delete(`/api/level/stages/${id}`),
130+
onSuccess: (_row, vars) => {
131+
qc.invalidateQueries({ queryKey: stagesKey(vars.configId) })
132+
qc.invalidateQueries({ queryKey: levelsKey(vars.configId) })
133+
},
134+
})
135+
}
136+
137+
// ─── Levels ──────────────────────────────────────────────────────
138+
139+
export function useLevels(configId: string, stageId?: string) {
140+
return useQuery({
141+
queryKey: [...levelsKey(configId), stageId ?? "all"],
142+
queryFn: () =>
143+
api.get<LevelListResponse>(
144+
`/api/level/configs/${configId}/levels${stageId ? `?stageId=${stageId}` : ""}`,
145+
),
146+
select: (data) => data.items,
147+
enabled: !!configId,
148+
})
149+
}
150+
151+
export function useLevel(id: string) {
152+
return useQuery({
153+
queryKey: levelKey(id),
154+
queryFn: () => api.get<Level>(`/api/level/levels/${id}`),
155+
enabled: !!id,
156+
})
157+
}
158+
159+
export function useCreateLevel() {
160+
const qc = useQueryClient()
161+
return useMutation({
162+
mutationFn: ({
163+
configId,
164+
input,
165+
}: {
166+
configId: string
167+
input: CreateLevelInput
168+
}) =>
169+
api.post<Level>(
170+
`/api/level/configs/${configId}/levels`,
171+
input,
172+
),
173+
onSuccess: (_row, vars) => {
174+
qc.invalidateQueries({ queryKey: levelsKey(vars.configId) })
175+
},
176+
})
177+
}
178+
179+
export function useUpdateLevel() {
180+
const qc = useQueryClient()
181+
return useMutation({
182+
mutationFn: ({
183+
id,
184+
input,
185+
}: {
186+
id: string
187+
configId: string
188+
input: UpdateLevelInput
189+
}) => api.put<Level>(`/api/level/levels/${id}`, input),
190+
onSuccess: (_row, vars) => {
191+
qc.invalidateQueries({ queryKey: levelsKey(vars.configId) })
192+
qc.invalidateQueries({ queryKey: levelKey(vars.id) })
193+
},
194+
})
195+
}
196+
197+
export function useDeleteLevel() {
198+
const qc = useQueryClient()
199+
return useMutation({
200+
mutationFn: ({ id }: { id: string; configId: string }) =>
201+
api.delete(`/api/level/levels/${id}`),
202+
onSuccess: (_row, vars) => {
203+
qc.invalidateQueries({ queryKey: levelsKey(vars.configId) })
204+
},
205+
})
206+
}

0 commit comments

Comments
 (0)