Skip to content

Commit e28da81

Browse files
dengzhaofunclaude
andauthored
Add targeted task assignments (定向分配) to task module (#27)
Introduces a 'visibility' mode on task definitions — broadcast (legacy; all end users see and can progress the task) vs. assigned (visible only to users with an active task_user_assignments row). processEvent and getTasksForUser both short-circuit for unassigned users so autoClaim rewards don't leak to non-targeted users. Admin UI exposes assignment management on the task detail page as a new 'Assignments' tab: batch-assign by end-user IDs, set TTL / sourceRef, toggle active-only filter, revoke (soft delete). en/zh i18n filled in; adds missing common_save key that existing pages already reference. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent feb03b5 commit e28da81

17 files changed

Lines changed: 16689 additions & 26 deletions

File tree

apps/admin/messages/en.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,51 @@
895895
"level_col_max_stars": "Stars",
896896
"level_col_stage": "Stage",
897897
"level_filter_all_stages": "All stages",
898+
"common_save": "Save",
899+
"task_tab_config": "Configuration",
900+
"task_tab_assignments": "Assignments",
901+
"task_visibility_label": "Visibility",
902+
"task_visibility_hint": "Who can see and progress this task. Broadcast = all users; Assigned = only users with an active assignment row.",
903+
"task_visibility_broadcast": "Broadcast (everyone)",
904+
"task_visibility_assigned": "Assigned (targeted)",
905+
"task_visibility_assigned_badge": "Assigned only",
906+
"task_default_ttl_label": "Default Assignment TTL (seconds)",
907+
"task_default_ttl_placeholder": "e.g. 86400 for 24h",
908+
"task_default_ttl_hint": "If an assignTask call doesn't provide expiresAt or ttlSeconds, this value is used. Leave blank for no default expiry.",
909+
"task_assign_broadcast_notice": "This task is configured as Broadcast, so assignments are not required for users to see it. Assignment rows are still recorded (useful for audit / personalization tags).",
910+
"task_assign_new": "New Assignment",
911+
"task_assign_new_hint": "Grant access to one or more end users. Existing active assignments are skipped unless 'Refresh existing' is on.",
912+
"task_assign_user_ids": "End User IDs",
913+
"task_assign_user_ids_placeholder": "One per line, or comma-separated",
914+
"task_assign_user_ids_count": "{count} id(s) will be assigned",
915+
"task_assign_ttl_label": "TTL (seconds, optional)",
916+
"task_assign_ttl_placeholder": "Override the default TTL for this batch",
917+
"task_assign_ttl_hint": "Overrides the definition-level default. Leave blank to fall back.",
918+
"task_assign_source_ref_label": "Source Ref (optional)",
919+
"task_assign_source_ref_placeholder": "e.g. crm-ticket-123",
920+
"task_assign_source_ref_hint": "Free-form tag for audit / tracing. Attached as source_ref on every row in this batch.",
921+
"task_assign_allow_reassign": "Refresh existing (update assignedAt / expiresAt on active rows)",
922+
"task_assign_submit": "Assign {count} user(s)",
923+
"task_assign_invalid_ttl": "TTL must be a positive integer",
924+
"task_assign_success": "Assigned {assigned}, skipped {skipped}",
925+
"task_assign_failed": "Failed to assign",
926+
"task_assign_list": "Current Assignments",
927+
"task_assign_list_count": "{count} row(s)",
928+
"task_assign_active_only": "Active only",
929+
"task_assign_empty": "No assignments yet.",
930+
"task_assign_col_user": "End User",
931+
"task_assign_col_source": "Source",
932+
"task_assign_col_assigned_at": "Assigned At",
933+
"task_assign_col_expires": "Expires At",
934+
"task_assign_col_status": "Status",
935+
"task_assign_status_active": "Active",
936+
"task_assign_status_expired": "Expired",
937+
"task_assign_status_revoked": "Revoked",
938+
"task_assign_no_expiry": "",
939+
"task_assign_revoke": "Revoke",
940+
"task_assign_revoke_confirm": "Revoke assignment for {endUserId}?",
941+
"task_assign_revoked": "Assignment revoked",
942+
"task_assign_revoke_failed": "Failed to revoke",
898943
"nav_activity": "Activities",
899944
"nav_leaderboard": "Leaderboards"
900945
}

apps/admin/messages/zh.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,51 @@
895895
"level_col_max_stars": "星数",
896896
"level_col_stage": "阶段",
897897
"level_filter_all_stages": "全部阶段",
898+
"common_save": "保存",
899+
"task_tab_config": "配置",
900+
"task_tab_assignments": "定向分配",
901+
"task_visibility_label": "可见性",
902+
"task_visibility_hint": "控制谁能看到并推进这个任务。广播 = 全体用户;定向 = 只有被分配的用户。",
903+
"task_visibility_broadcast": "广播(全体可见)",
904+
"task_visibility_assigned": "定向(按人分配)",
905+
"task_visibility_assigned_badge": "仅限已分配",
906+
"task_default_ttl_label": "默认分配过期时间(秒)",
907+
"task_default_ttl_placeholder": "例如 86400 表示 24 小时",
908+
"task_default_ttl_hint": "调用方未指定 expiresAt / ttlSeconds 时,使用这个默认值。留空表示永不过期。",
909+
"task_assign_broadcast_notice": "当前任务可见性为「广播」,无需分配即可被所有用户看到。这里的分配记录仍会写入(用于审计 / 个性化标签)。",
910+
"task_assign_new": "新增分配",
911+
"task_assign_new_hint": "授权一个或多个终端用户。已有活跃分配会被跳过,除非开启「刷新已有」。",
912+
"task_assign_user_ids": "终端用户 ID",
913+
"task_assign_user_ids_placeholder": "每行一个,或用逗号分隔",
914+
"task_assign_user_ids_count": "将分配 {count} 个 ID",
915+
"task_assign_ttl_label": "过期时间(秒,可选)",
916+
"task_assign_ttl_placeholder": "仅覆盖本次分配的默认 TTL",
917+
"task_assign_ttl_hint": "覆盖任务定义级别的默认过期时间。留空则使用默认。",
918+
"task_assign_source_ref_label": "来源引用(可选)",
919+
"task_assign_source_ref_placeholder": "例如 crm-ticket-123",
920+
"task_assign_source_ref_hint": "自由格式标签,用于审计和溯源。本批次每行都会带上 source_ref。",
921+
"task_assign_allow_reassign": "刷新已有(对活跃行更新 assignedAt / expiresAt)",
922+
"task_assign_submit": "分配给 {count} 位用户",
923+
"task_assign_invalid_ttl": "过期时间必须是正整数",
924+
"task_assign_success": "成功 {assigned} 条,跳过 {skipped} 条",
925+
"task_assign_failed": "分配失败",
926+
"task_assign_list": "当前分配",
927+
"task_assign_list_count": "{count} 条",
928+
"task_assign_active_only": "仅显示生效",
929+
"task_assign_empty": "还没有分配记录。",
930+
"task_assign_col_user": "终端用户",
931+
"task_assign_col_source": "来源",
932+
"task_assign_col_assigned_at": "分配时间",
933+
"task_assign_col_expires": "过期时间",
934+
"task_assign_col_status": "状态",
935+
"task_assign_status_active": "生效中",
936+
"task_assign_status_expired": "已过期",
937+
"task_assign_status_revoked": "已撤销",
938+
"task_assign_no_expiry": "",
939+
"task_assign_revoke": "撤销",
940+
"task_assign_revoke_confirm": "撤销 {endUserId} 的分配吗?",
941+
"task_assign_revoked": "已撤销分配",
942+
"task_assign_revoke_failed": "撤销失败",
898943
"nav_activity": "活动",
899944
"nav_leaderboard": "排行榜"
900945
}
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
import { useState } from "react"
2+
import { toast } from "sonner"
3+
import { Trash2 } from "lucide-react"
4+
5+
import * as m from "#/paraglide/messages.js"
6+
import { Button } from "#/components/ui/button"
7+
import { Input } from "#/components/ui/input"
8+
import { Label } from "#/components/ui/label"
9+
import { Textarea } from "#/components/ui/textarea"
10+
import { Switch } from "#/components/ui/switch"
11+
import { Badge } from "#/components/ui/badge"
12+
import {
13+
Table,
14+
TableBody,
15+
TableCell,
16+
TableHead,
17+
TableHeader,
18+
TableRow,
19+
} from "#/components/ui/table"
20+
import {
21+
useAssignTask,
22+
useRevokeAssignment,
23+
useTaskAssignments,
24+
} from "#/hooks/use-task"
25+
import { ApiError } from "#/lib/api-client"
26+
import type { TaskDefinition } from "#/lib/types/task"
27+
28+
interface AssignmentPanelProps {
29+
definition: TaskDefinition
30+
}
31+
32+
export function AssignmentPanel({ definition }: AssignmentPanelProps) {
33+
const taskKey = definition.id
34+
const [activeOnly, setActiveOnly] = useState(true)
35+
const [userIdsText, setUserIdsText] = useState("")
36+
const [ttlInput, setTtlInput] = useState("")
37+
const [sourceRef, setSourceRef] = useState("")
38+
const [allowReassign, setAllowReassign] = useState(false)
39+
40+
const {
41+
data: assignments,
42+
isPending,
43+
error,
44+
} = useTaskAssignments(taskKey, { activeOnly, limit: 200 })
45+
const assignMutation = useAssignTask(taskKey)
46+
const revokeMutation = useRevokeAssignment(taskKey)
47+
48+
const parsedIds = userIdsText
49+
.split(/[\n,]/)
50+
.map((s) => s.trim())
51+
.filter(Boolean)
52+
53+
const isAssigned = definition.visibility === "assigned"
54+
55+
async function handleAssign() {
56+
if (parsedIds.length === 0) return
57+
const ttl = ttlInput.trim()
58+
const ttlSeconds = ttl ? Number(ttl) : undefined
59+
if (ttl && (!Number.isFinite(ttlSeconds) || (ttlSeconds ?? 0) <= 0)) {
60+
toast.error(m.task_assign_invalid_ttl())
61+
return
62+
}
63+
try {
64+
const res = await assignMutation.mutateAsync({
65+
endUserIds: parsedIds,
66+
ttlSeconds,
67+
sourceRef: sourceRef.trim() || null,
68+
allowReassign,
69+
})
70+
toast.success(
71+
m.task_assign_success({
72+
assigned: res.assigned,
73+
skipped: res.skipped,
74+
}),
75+
)
76+
setUserIdsText("")
77+
setSourceRef("")
78+
} catch (err) {
79+
if (err instanceof ApiError) toast.error(err.body.error)
80+
else toast.error(m.task_assign_failed())
81+
}
82+
}
83+
84+
async function handleRevoke(endUserId: string) {
85+
if (!confirm(m.task_assign_revoke_confirm({ endUserId }))) return
86+
try {
87+
await revokeMutation.mutateAsync(endUserId)
88+
toast.success(m.task_assign_revoked())
89+
} catch (err) {
90+
if (err instanceof ApiError) toast.error(err.body.error)
91+
else toast.error(m.task_assign_revoke_failed())
92+
}
93+
}
94+
95+
return (
96+
<div className="space-y-6">
97+
{!isAssigned && (
98+
<div className="rounded-lg border border-yellow-500/40 bg-yellow-500/10 p-4 text-sm">
99+
{m.task_assign_broadcast_notice()}
100+
</div>
101+
)}
102+
103+
<section className="space-y-4 rounded-xl border bg-card p-6 shadow-sm">
104+
<div>
105+
<h2 className="text-base font-semibold">{m.task_assign_new()}</h2>
106+
<p className="text-xs text-muted-foreground">
107+
{m.task_assign_new_hint()}
108+
</p>
109+
</div>
110+
111+
<div className="space-y-2">
112+
<Label htmlFor="assign-user-ids">{m.task_assign_user_ids()}</Label>
113+
<Textarea
114+
id="assign-user-ids"
115+
value={userIdsText}
116+
onChange={(e) => setUserIdsText(e.target.value)}
117+
rows={4}
118+
placeholder={m.task_assign_user_ids_placeholder()}
119+
/>
120+
<p className="text-xs text-muted-foreground">
121+
{m.task_assign_user_ids_count({ count: parsedIds.length })}
122+
</p>
123+
</div>
124+
125+
<div className="grid gap-4 md:grid-cols-2">
126+
<div className="space-y-2">
127+
<Label htmlFor="assign-ttl">{m.task_assign_ttl_label()}</Label>
128+
<Input
129+
id="assign-ttl"
130+
type="number"
131+
min={1}
132+
value={ttlInput}
133+
onChange={(e) => setTtlInput(e.target.value)}
134+
placeholder={
135+
definition.defaultAssignmentTtlSeconds
136+
? String(definition.defaultAssignmentTtlSeconds)
137+
: m.task_assign_ttl_placeholder()
138+
}
139+
/>
140+
<p className="text-xs text-muted-foreground">
141+
{m.task_assign_ttl_hint()}
142+
</p>
143+
</div>
144+
145+
<div className="space-y-2">
146+
<Label htmlFor="assign-source-ref">
147+
{m.task_assign_source_ref_label()}
148+
</Label>
149+
<Input
150+
id="assign-source-ref"
151+
value={sourceRef}
152+
onChange={(e) => setSourceRef(e.target.value)}
153+
placeholder={m.task_assign_source_ref_placeholder()}
154+
/>
155+
<p className="text-xs text-muted-foreground">
156+
{m.task_assign_source_ref_hint()}
157+
</p>
158+
</div>
159+
</div>
160+
161+
<div className="flex items-center gap-2">
162+
<Switch
163+
id="allow-reassign"
164+
checked={allowReassign}
165+
onCheckedChange={setAllowReassign}
166+
/>
167+
<Label htmlFor="allow-reassign" className="text-sm">
168+
{m.task_assign_allow_reassign()}
169+
</Label>
170+
</div>
171+
172+
<Button
173+
onClick={handleAssign}
174+
disabled={parsedIds.length === 0 || assignMutation.isPending}
175+
>
176+
{assignMutation.isPending
177+
? m.common_saving()
178+
: m.task_assign_submit({ count: parsedIds.length })}
179+
</Button>
180+
</section>
181+
182+
<section className="space-y-3 rounded-xl border bg-card shadow-sm">
183+
<div className="flex items-center justify-between border-b px-6 py-4">
184+
<div>
185+
<h2 className="text-base font-semibold">{m.task_assign_list()}</h2>
186+
<p className="text-xs text-muted-foreground">
187+
{m.task_assign_list_count({ count: assignments?.length ?? 0 })}
188+
</p>
189+
</div>
190+
<div className="flex items-center gap-2">
191+
<Switch
192+
id="active-only"
193+
checked={activeOnly}
194+
onCheckedChange={setActiveOnly}
195+
/>
196+
<Label htmlFor="active-only" className="text-sm">
197+
{m.task_assign_active_only()}
198+
</Label>
199+
</div>
200+
</div>
201+
202+
{isPending ? (
203+
<div className="flex h-32 items-center justify-center text-muted-foreground">
204+
{m.common_loading()}
205+
</div>
206+
) : error ? (
207+
<div className="flex h-32 items-center justify-center text-destructive">
208+
{error.message}
209+
</div>
210+
) : (assignments?.length ?? 0) === 0 ? (
211+
<div className="flex h-32 items-center justify-center text-muted-foreground">
212+
{m.task_assign_empty()}
213+
</div>
214+
) : (
215+
<Table>
216+
<TableHeader>
217+
<TableRow>
218+
<TableHead>{m.task_assign_col_user()}</TableHead>
219+
<TableHead>{m.task_assign_col_source()}</TableHead>
220+
<TableHead>{m.task_assign_col_assigned_at()}</TableHead>
221+
<TableHead>{m.task_assign_col_expires()}</TableHead>
222+
<TableHead>{m.task_assign_col_status()}</TableHead>
223+
<TableHead className="w-10"></TableHead>
224+
</TableRow>
225+
</TableHeader>
226+
<TableBody>
227+
{assignments!.map((a) => {
228+
const expired =
229+
a.expiresAt != null &&
230+
new Date(a.expiresAt).getTime() <= Date.now()
231+
const revoked = a.revokedAt != null
232+
const active = !expired && !revoked
233+
return (
234+
<TableRow key={a.endUserId}>
235+
<TableCell className="font-mono text-xs">
236+
{a.endUserId}
237+
</TableCell>
238+
<TableCell>
239+
<div className="space-y-1">
240+
<Badge variant="outline">{a.source}</Badge>
241+
{a.sourceRef && (
242+
<div className="font-mono text-xs text-muted-foreground">
243+
{a.sourceRef}
244+
</div>
245+
)}
246+
</div>
247+
</TableCell>
248+
<TableCell className="text-xs text-muted-foreground">
249+
{new Date(a.assignedAt).toLocaleString()}
250+
</TableCell>
251+
<TableCell className="text-xs text-muted-foreground">
252+
{a.expiresAt
253+
? new Date(a.expiresAt).toLocaleString()
254+
: m.task_assign_no_expiry()}
255+
</TableCell>
256+
<TableCell>
257+
{revoked ? (
258+
<Badge variant="destructive">
259+
{m.task_assign_status_revoked()}
260+
</Badge>
261+
) : expired ? (
262+
<Badge variant="secondary">
263+
{m.task_assign_status_expired()}
264+
</Badge>
265+
) : (
266+
<Badge>{m.task_assign_status_active()}</Badge>
267+
)}
268+
</TableCell>
269+
<TableCell>
270+
{active && (
271+
<Button
272+
variant="ghost"
273+
size="icon"
274+
disabled={revokeMutation.isPending}
275+
onClick={() => handleRevoke(a.endUserId)}
276+
aria-label={m.task_assign_revoke()}
277+
>
278+
<Trash2 className="size-4" />
279+
</Button>
280+
)}
281+
</TableCell>
282+
</TableRow>
283+
)
284+
})}
285+
</TableBody>
286+
</Table>
287+
)}
288+
</section>
289+
</div>
290+
)
291+
}

0 commit comments

Comments
 (0)