Use fixed feedback reward amounts
This commit is contained in:
@@ -32,11 +32,11 @@ export function runTests() {
|
|||||||
assert(
|
assert(
|
||||||
source.includes("积分奖励标准") &&
|
source.includes("积分奖励标准") &&
|
||||||
source.includes("REWARD_GUIDELINES") &&
|
source.includes("REWARD_GUIDELINES") &&
|
||||||
source.includes("50-100") &&
|
source.includes("100") &&
|
||||||
source.includes("200-300") &&
|
source.includes("300") &&
|
||||||
source.includes("500") &&
|
source.includes("500") &&
|
||||||
source.includes("800-1000") &&
|
source.includes("1000") &&
|
||||||
source.includes("1500+"),
|
source.includes("1500"),
|
||||||
"ops feedback page must document manual reward point guidelines",
|
"ops feedback page must document fixed reward point guidelines",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,21 +27,33 @@ export function runTests() {
|
|||||||
assert(
|
assert(
|
||||||
opsApiSource.includes("grantFeedbackReward") &&
|
opsApiSource.includes("grantFeedbackReward") &&
|
||||||
opsApiSource.includes("/api/ops/feedback/${feedbackId}/reward") &&
|
opsApiSource.includes("/api/ops/feedback/${feedbackId}/reward") &&
|
||||||
opsApiSource.includes("reason"),
|
opsApiSource.includes("JSON.stringify({ points })"),
|
||||||
"ops API client must expose grantFeedbackReward with reason",
|
"ops API client must expose grantFeedbackReward with fixed points only",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
feedbackPageSource.includes("rewardDrafts") &&
|
feedbackPageSource.includes("REWARD_POINT_OPTIONS") &&
|
||||||
feedbackPageSource.includes("handleRewardGrant") &&
|
feedbackPageSource.includes("handleRewardGrant") &&
|
||||||
feedbackPageSource.includes("发放奖励") &&
|
feedbackPageSource.includes("发放奖励") &&
|
||||||
feedbackPageSource.includes("奖励原因") &&
|
|
||||||
feedbackPageSource.includes("opsApi.grantFeedbackReward"),
|
feedbackPageSource.includes("opsApi.grantFeedbackReward"),
|
||||||
"ops feedback page must provide per-feedback reward grant controls",
|
"ops feedback page must provide fixed-point reward grant controls",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
feedbackPageSource.includes('value: 100') &&
|
||||||
|
feedbackPageSource.includes('value: 300') &&
|
||||||
|
feedbackPageSource.includes('value: 500') &&
|
||||||
|
feedbackPageSource.includes('value: 1000') &&
|
||||||
|
feedbackPageSource.includes('value: 1500'),
|
||||||
|
"ops feedback page must use fixed reward point options",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!feedbackPageSource.includes("奖励原因") &&
|
||||||
|
!feedbackPageSource.includes("rewardDrafts") &&
|
||||||
|
!feedbackPageSource.includes("reason:"),
|
||||||
|
"ops feedback page must not ask operators for reward reasons",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
feedbackPageSource.includes("已发放") &&
|
feedbackPageSource.includes("已发放") &&
|
||||||
feedbackPageSource.includes("reward_points") &&
|
feedbackPageSource.includes("reward_points"),
|
||||||
feedbackPageSource.includes("reward_reason"),
|
|
||||||
"ops feedback page must show existing feedback reward details",
|
"ops feedback page must show existing feedback reward details",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,19 +18,22 @@ const STATUS_OPTIONS = [
|
|||||||
|
|
||||||
const STATUS_UPDATE_OPTIONS = STATUS_OPTIONS.filter((item) => item.key);
|
const STATUS_UPDATE_OPTIONS = STATUS_OPTIONS.filter((item) => item.key);
|
||||||
|
|
||||||
const REWARD_GUIDELINES = [
|
const REWARD_POINT_OPTIONS = [
|
||||||
{ points: "0", title: "无效/重复", detail: "重复反馈、无法复现、非问题" },
|
{ value: 100, label: "100 分", title: "轻量提醒" },
|
||||||
{ points: "50-100", title: "轻量提醒", detail: "文案、体验、小范围提示" },
|
{ value: 300, label: "300 分", title: "可复现 Bug" },
|
||||||
{ points: "200-300", title: "可复现 Bug", detail: "加载失败、操作异常、局部影响" },
|
{ value: 500, label: "500 分", title: "有效数据问题" },
|
||||||
{ points: "500", title: "有效数据问题", detail: "城市数据、图表、关键变量异常" },
|
{ value: 1000, label: "1000 分", title: "高影响问题" },
|
||||||
{ points: "800-1000", title: "高影响问题", detail: "支付、账号、订阅、核心终端异常" },
|
{ value: 1500, label: "1500 分", title: "重大事故" },
|
||||||
{ points: "1500+", title: "重大事故", detail: "大面积不可用或严重业务损失,谨慎使用" },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type RewardDraft = {
|
const REWARD_GUIDELINES = [
|
||||||
points: string;
|
{ points: "0", title: "无效/重复", detail: "重复反馈、无法复现、非问题" },
|
||||||
reason: string;
|
{ points: "100", title: "轻量提醒", detail: "文案、体验、小范围提示" },
|
||||||
};
|
{ points: "300", title: "可复现 Bug", detail: "加载失败、操作异常、局部影响" },
|
||||||
|
{ points: "500", title: "有效数据问题", detail: "城市数据、图表、关键变量异常" },
|
||||||
|
{ points: "1000", title: "高影响问题", detail: "支付、账号、订阅、核心终端异常" },
|
||||||
|
{ points: "1500", title: "重大事故", detail: "大面积不可用或严重业务损失,谨慎使用" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
function compactDate(value?: string) {
|
function compactDate(value?: string) {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
@@ -82,7 +85,7 @@ export function FeedbackPageClient() {
|
|||||||
const [payload, setPayload] = useState<UserFeedbackPayload | null>(null);
|
const [payload, setPayload] = useState<UserFeedbackPayload | null>(null);
|
||||||
const [updatingId, setUpdatingId] = useState<number | null>(null);
|
const [updatingId, setUpdatingId] = useState<number | null>(null);
|
||||||
const [rewardingId, setRewardingId] = useState<number | null>(null);
|
const [rewardingId, setRewardingId] = useState<number | null>(null);
|
||||||
const [rewardDrafts, setRewardDrafts] = useState<Record<number, RewardDraft>>({});
|
const [rewardPointsById, setRewardPointsById] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -127,21 +130,16 @@ export function FeedbackPageClient() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateRewardDraft = (rowId: number, patch: Partial<RewardDraft>) => {
|
const updateRewardPoints = (rowId: number, points: string) => {
|
||||||
setRewardDrafts((prev) => ({
|
setRewardPointsById((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[rowId]: {
|
[rowId]: points,
|
||||||
points: prev[rowId]?.points || "",
|
|
||||||
reason: prev[rowId]?.reason || "",
|
|
||||||
...patch,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRewardGrant = async (row: UserFeedbackEntry) => {
|
const handleRewardGrant = async (row: UserFeedbackEntry) => {
|
||||||
const draft = rewardDrafts[row.id] || { points: "", reason: "" };
|
const selectedPoints = rewardPointsById[row.id] || String(REWARD_POINT_OPTIONS[1].value);
|
||||||
const points = Number.parseInt(draft.points, 10);
|
const points = Number.parseInt(selectedPoints, 10);
|
||||||
const reason = draft.reason.trim();
|
|
||||||
if (!row.user_email) {
|
if (!row.user_email) {
|
||||||
setError("这条反馈没有绑定用户邮箱,不能从反馈页直接发放积分。");
|
setError("这条反馈没有绑定用户邮箱,不能从反馈页直接发放积分。");
|
||||||
return;
|
return;
|
||||||
@@ -150,15 +148,11 @@ export function FeedbackPageClient() {
|
|||||||
setError("请输入有效的奖励积分。");
|
setError("请输入有效的奖励积分。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!reason) {
|
|
||||||
setError("请输入奖励原因,用户账户页会展示这条原因。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRewardingId(row.id);
|
setRewardingId(row.id);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await opsApi.grantFeedbackReward(row.id, points, reason);
|
await opsApi.grantFeedbackReward(row.id, points);
|
||||||
setRewardDrafts((prev) => {
|
setRewardPointsById((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[row.id];
|
delete next[row.id];
|
||||||
return next;
|
return next;
|
||||||
@@ -253,7 +247,7 @@ export function FeedbackPageClient() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-slate-500">
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
先确认反馈是否有效;未复现的问题可先标为已确认/处理中,奖励原因后续发放时需要写清楚。
|
先确认反馈是否有效;未复现的问题可先标为已确认/处理中,奖励发放后会自动记录到用户账户页。
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -301,9 +295,8 @@ export function FeedbackPageClient() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => {
|
{rows.map((row) => {
|
||||||
const draft = rewardDrafts[row.id] || { points: "", reason: "" };
|
const selectedPoints = rewardPointsById[row.id] || String(REWARD_POINT_OPTIONS[1].value);
|
||||||
const rewardPoints = Number(row.reward_points || 0);
|
const rewardPoints = Number(row.reward_points || 0);
|
||||||
const rewardReason = String(row.reward_reason || "").trim();
|
|
||||||
const rewardStatus = String(row.reward_status || "").toLowerCase();
|
const rewardStatus = String(row.reward_status || "").toLowerCase();
|
||||||
const hasReward = rewardStatus === "granted" && rewardPoints > 0;
|
const hasReward = rewardStatus === "granted" && rewardPoints > 0;
|
||||||
return (
|
return (
|
||||||
@@ -330,39 +323,28 @@ export function FeedbackPageClient() {
|
|||||||
{row.user_email || row.user_id || "—"}
|
{row.user_email || row.user_id || "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap py-3 pr-4 text-xs text-slate-500">{compactDate(row.created_at)}</td>
|
<td className="whitespace-nowrap py-3 pr-4 text-xs text-slate-500">{compactDate(row.created_at)}</td>
|
||||||
<td className="min-w-[260px] py-3 pr-4">
|
<td className="min-w-[170px] py-3 pr-4">
|
||||||
{hasReward ? (
|
{hasReward ? (
|
||||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs">
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs">
|
||||||
<div className="font-black text-emerald-700">
|
<div className="font-black text-emerald-700">
|
||||||
已发放 +{rewardPoints.toLocaleString()} 分
|
已发放 +{rewardPoints.toLocaleString()} 分
|
||||||
</div>
|
</div>
|
||||||
{rewardReason && (
|
|
||||||
<div className="mt-1 leading-4 text-emerald-700">
|
|
||||||
{rewardReason}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex gap-1.5">
|
<select
|
||||||
<input
|
value={selectedPoints}
|
||||||
type="number"
|
onChange={(event) => updateRewardPoints(row.id, event.target.value)}
|
||||||
min={1}
|
disabled={rewardingId === row.id || !row.user_email}
|
||||||
max={100000}
|
className="h-8 w-full rounded border border-slate-200 bg-white px-2 text-xs font-bold text-slate-700 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
value={draft.points}
|
aria-label="奖励积分"
|
||||||
onChange={(event) => updateRewardDraft(row.id, { points: event.target.value })}
|
>
|
||||||
placeholder="积分"
|
{REWARD_POINT_OPTIONS.map((item) => (
|
||||||
aria-label="奖励积分"
|
<option key={item.value} value={item.value}>
|
||||||
className="h-8 w-20 rounded border border-slate-200 bg-white px-2 text-xs font-bold text-slate-700 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-100"
|
{item.label} · {item.title}
|
||||||
/>
|
</option>
|
||||||
<input
|
))}
|
||||||
value={draft.reason}
|
</select>
|
||||||
onChange={(event) => updateRewardDraft(row.id, { reason: event.target.value })}
|
|
||||||
placeholder="奖励原因"
|
|
||||||
aria-label="奖励原因"
|
|
||||||
className="h-8 min-w-0 flex-1 rounded border border-slate-200 bg-white px-2 text-xs text-slate-700 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -121,11 +121,11 @@ export const opsApi = {
|
|||||||
body: JSON.stringify({ status }),
|
body: JSON.stringify({ status }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
grantFeedbackReward(feedbackId: string | number, points: number, reason: string) {
|
grantFeedbackReward(feedbackId: string | number, points: number) {
|
||||||
return opsFetch<Record<string, unknown>>(`/api/ops/feedback/${feedbackId}/reward`, {
|
return opsFetch<Record<string, unknown>>(`/api/ops/feedback/${feedbackId}/reward`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ points, reason }),
|
body: JSON.stringify({ points }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
resolveIncident(eventId: string | number) {
|
resolveIncident(eventId: string | number) {
|
||||||
|
|||||||
@@ -1309,8 +1309,6 @@ class DBManager:
|
|||||||
if safe_points <= 0:
|
if safe_points <= 0:
|
||||||
return {"ok": False, "reason": "invalid_amount"}
|
return {"ok": False, "reason": "invalid_amount"}
|
||||||
normalized_reason = str(reason or "").strip()[:500]
|
normalized_reason = str(reason or "").strip()[:500]
|
||||||
if not normalized_reason:
|
|
||||||
return {"ok": False, "reason": "missing_reward_reason"}
|
|
||||||
|
|
||||||
now = datetime.now().isoformat()
|
now = datetime.now().isoformat()
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ def test_feedback_reward_grant_adds_points_and_marks_feedback(tmp_path):
|
|||||||
result = db.grant_feedback_reward(
|
result = db.grant_feedback_reward(
|
||||||
created["id"],
|
created["id"],
|
||||||
points=300,
|
points=300,
|
||||||
reason="Valid stale METAR report",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["ok"] is True
|
assert result["ok"] is True
|
||||||
@@ -126,14 +125,13 @@ def test_feedback_reward_grant_adds_points_and_marks_feedback(tmp_path):
|
|||||||
assert result["points_added"] == 300
|
assert result["points_added"] == 300
|
||||||
assert result["points_after"] == 350
|
assert result["points_after"] == 350
|
||||||
assert result["feedback"]["reward_points"] == 300
|
assert result["feedback"]["reward_points"] == 300
|
||||||
assert result["feedback"]["reward_reason"] == "Valid stale METAR report"
|
assert result["feedback"]["reward_reason"] == ""
|
||||||
assert result["feedback"]["reward_status"] == "granted"
|
assert result["feedback"]["reward_status"] == "granted"
|
||||||
assert db.get_points_by_supabase_email("pilot@example.com") == 350
|
assert db.get_points_by_supabase_email("pilot@example.com") == 350
|
||||||
|
|
||||||
duplicate = db.grant_feedback_reward(
|
duplicate = db.grant_feedback_reward(
|
||||||
created["id"],
|
created["id"],
|
||||||
points=300,
|
points=300,
|
||||||
reason="Duplicate grant should not apply",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert duplicate["ok"] is False
|
assert duplicate["ok"] is False
|
||||||
@@ -143,7 +141,7 @@ def test_feedback_reward_grant_adds_points_and_marks_feedback(tmp_path):
|
|||||||
|
|
||||||
def test_ops_feedback_reward_service_returns_operator(monkeypatch):
|
def test_ops_feedback_reward_service_returns_operator(monkeypatch):
|
||||||
class FakeDB:
|
class FakeDB:
|
||||||
def grant_feedback_reward(self, feedback_id, *, points, reason):
|
def grant_feedback_reward(self, feedback_id, *, points, reason=""):
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"feedback_id": feedback_id,
|
"feedback_id": feedback_id,
|
||||||
@@ -164,13 +162,12 @@ def test_ops_feedback_reward_service_returns_operator(monkeypatch):
|
|||||||
object(),
|
object(),
|
||||||
feedback_id=42,
|
feedback_id=42,
|
||||||
points=500,
|
points=500,
|
||||||
reason="High impact chart bug",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert payload["ok"] is True
|
assert payload["ok"] is True
|
||||||
assert payload["operator_email"] == "ops@example.com"
|
assert payload["operator_email"] == "ops@example.com"
|
||||||
assert payload["feedback"]["reward_points"] == 500
|
assert payload["feedback"]["reward_points"] == 500
|
||||||
assert payload["feedback"]["reward_reason"] == "High impact chart bug"
|
assert payload["feedback"]["reward_reason"] == ""
|
||||||
|
|
||||||
|
|
||||||
def test_user_feedback_identity_filter_returns_only_matching_user(tmp_path):
|
def test_user_feedback_identity_filter_returns_only_matching_user(tmp_path):
|
||||||
|
|||||||
+1
-1
@@ -506,7 +506,7 @@ class GrantPointsRequest(BaseModel):
|
|||||||
|
|
||||||
class FeedbackRewardRequest(BaseModel):
|
class FeedbackRewardRequest(BaseModel):
|
||||||
points: int = Field(..., gt=0, le=100000)
|
points: int = Field(..., gt=0, le=100000)
|
||||||
reason: str = Field(..., min_length=2, max_length=500)
|
reason: str = Field(default="", max_length=500)
|
||||||
|
|
||||||
|
|
||||||
def _sf(v) -> Optional[float]:
|
def _sf(v) -> Optional[float]:
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ def grant_ops_feedback_reward(
|
|||||||
*,
|
*,
|
||||||
feedback_id: int,
|
feedback_id: int,
|
||||||
points: int,
|
points: int,
|
||||||
reason: str,
|
reason: str = "",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
admin = _require_ops(request) or {}
|
admin = _require_ops(request) or {}
|
||||||
db = DBManager()
|
db = DBManager()
|
||||||
|
|||||||
Reference in New Issue
Block a user