会员配置改为可视化表单管理
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-14 14:40:42 +08:00
parent 95630e735d
commit 86c6a8d169

View File

@ -70,6 +70,8 @@ import { Switch } from "@/components/ui/switch";
import {
defaultMembershipConfig,
defaultMembershipUserOverrides,
type MembershipFaqItem,
type MembershipPlanConfig,
normalizeMembershipConfig,
normalizeMembershipUserOverrides,
} from "@/lib/membership/config";
@ -440,12 +442,9 @@ export function AdminSettingsClient() {
membershipConfig: defaultMembershipConfig,
membershipUserOverrides: defaultMembershipUserOverrides,
});
const [membershipConfigDraft, setMembershipConfigDraft] = useState(
JSON.stringify(defaultMembershipConfig, null, 2)
);
const [membershipOverridesDraft, setMembershipOverridesDraft] = useState(
JSON.stringify(defaultMembershipUserOverrides, null, 2)
);
const [newOverrideUserId, setNewOverrideUserId] = useState("");
const [newOverrideEmail, setNewOverrideEmail] = useState("");
const [newOverridePlanId, setNewOverridePlanId] = useState("");
useEffect(() => {
if (siteSettingsData) {
@ -513,8 +512,6 @@ export function AdminSettingsClient() {
membershipConfig: normalizedMembershipConfig,
membershipUserOverrides: normalizedMembershipOverrides,
});
setMembershipConfigDraft(JSON.stringify(normalizedMembershipConfig, null, 2));
setMembershipOverridesDraft(JSON.stringify(normalizedMembershipOverrides, null, 2));
}
}, [siteSettingsData]);
@ -1037,26 +1034,10 @@ export function AdminSettingsClient() {
// --- 网站设置逻辑 ---
const handleSaveSiteSettings = async () => {
try {
let parsedMembershipConfig: ReturnType<typeof normalizeMembershipConfig>;
let parsedMembershipOverrides: ReturnType<typeof normalizeMembershipUserOverrides>;
try {
parsedMembershipConfig = normalizeMembershipConfig(
JSON.parse(membershipConfigDraft)
);
} catch {
toast.error("会员配置 JSON 格式错误,请检查后再保存");
return;
}
try {
parsedMembershipOverrides = normalizeMembershipUserOverrides(
JSON.parse(membershipOverridesDraft)
);
} catch {
toast.error("会员用户覆盖 JSON 格式错误,请检查后再保存");
return;
}
const parsedMembershipConfig = normalizeMembershipConfig(siteSettings.membershipConfig);
const parsedMembershipOverrides = normalizeMembershipUserOverrides(
siteSettings.membershipUserOverrides
);
const parsedPointsPackages = normalizePointsPackages(siteSettings.pointsPackages);
@ -1074,8 +1055,6 @@ export function AdminSettingsClient() {
});
if (!response.ok) throw new Error("保存失败");
setSiteSettings(payload);
setMembershipConfigDraft(JSON.stringify(parsedMembershipConfig, null, 2));
setMembershipOverridesDraft(JSON.stringify(parsedMembershipOverrides, null, 2));
toast.success("网站设置保存成功");
mutateSiteSettings();
} catch (error) {
@ -1120,6 +1099,165 @@ export function AdminSettingsClient() {
});
};
const updateMembershipConfigState = (
updater: (config: ReturnType<typeof normalizeMembershipConfig>) => ReturnType<typeof normalizeMembershipConfig>
) => {
setSiteSettings((prev) => ({
...prev,
membershipConfig: updater(prev.membershipConfig),
}));
};
const updateMembershipOverridesState = (
updater: (
overrides: ReturnType<typeof normalizeMembershipUserOverrides>
) => ReturnType<typeof normalizeMembershipUserOverrides>
) => {
setSiteSettings((prev) => ({
...prev,
membershipUserOverrides: updater(prev.membershipUserOverrides),
}));
};
const updateMembershipPlan = (
index: number,
updater: (plan: MembershipPlanConfig) => MembershipPlanConfig
) => {
updateMembershipConfigState((config) => {
const plans = [...config.plans];
if (!plans[index]) return config;
plans[index] = updater(plans[index]);
return { ...config, plans };
});
};
const addMembershipPlan = () => {
updateMembershipConfigState((config) => {
const nextIndex = config.plans.length + 1;
const id = `plan-${Date.now()}`;
const plans = [
...config.plans,
{
id,
name: `套餐${nextIndex}`,
monthlyPrice: 0,
annualPrice: 0,
monthlyCredits: 0,
dailyFreeCredits: 0,
features: [],
},
];
return {
...config,
plans,
defaultPlanId: config.defaultPlanId || id,
};
});
};
const removeMembershipPlan = (index: number) => {
updateMembershipConfigState((config) => {
if (config.plans.length <= 1) {
toast.error("至少保留一个会员套餐");
return config;
}
const target = config.plans[index];
if (!target) return config;
const plans = [...config.plans];
plans.splice(index, 1);
const nextDefaultPlanId =
config.defaultPlanId === target.id ? plans[0]?.id || config.defaultPlanId : config.defaultPlanId;
return {
...config,
plans,
defaultPlanId: nextDefaultPlanId,
};
});
};
const updateMembershipFaq = (
index: number,
updater: (faq: MembershipFaqItem) => MembershipFaqItem
) => {
updateMembershipConfigState((config) => {
const faqs = [...config.faqs];
if (!faqs[index]) return config;
faqs[index] = updater(faqs[index]);
return { ...config, faqs };
});
};
const addMembershipFaq = () => {
updateMembershipConfigState((config) => ({
...config,
faqs: [...config.faqs, { question: "", answer: "" }],
}));
};
const removeMembershipFaq = (index: number) => {
updateMembershipConfigState((config) => {
if (config.faqs.length <= 1) {
toast.error("至少保留一条 FAQ");
return config;
}
const faqs = [...config.faqs];
faqs.splice(index, 1);
return { ...config, faqs };
});
};
const addMembershipOverride = (kind: "id" | "email") => {
const key = kind === "id" ? newOverrideUserId.trim() : newOverrideEmail.trim().toLowerCase();
const planId = newOverridePlanId.trim();
if (!key || !planId) {
toast.error("请先填写标识并选择套餐");
return;
}
updateMembershipOverridesState((overrides) => {
if (kind === "id") {
return {
...overrides,
userPlansById: {
...overrides.userPlansById,
[key]: planId,
},
};
}
return {
...overrides,
userPlansByEmail: {
...overrides.userPlansByEmail,
[key]: planId,
},
};
});
if (kind === "id") {
setNewOverrideUserId("");
} else {
setNewOverrideEmail("");
}
};
const removeMembershipOverride = (kind: "id" | "email", key: string) => {
updateMembershipOverridesState((overrides) => {
if (kind === "id") {
const next = { ...overrides.userPlansById };
delete next[key];
return { ...overrides, userPlansById: next };
}
const next = { ...overrides.userPlansByEmail };
delete next[key];
return { ...overrides, userPlansByEmail: next };
});
};
// --- 抠图API配置逻辑 ---
const handleSaveRmbgConfig = async () => {
try {
@ -2949,37 +3087,387 @@ export function AdminSettingsClient() {
variant="outline"
size="sm"
onClick={() => {
setMembershipConfigDraft(JSON.stringify(defaultMembershipConfig, null, 2));
setMembershipOverridesDraft(
JSON.stringify(defaultMembershipUserOverrides, null, 2)
);
setSiteSettings({
...siteSettings,
membershipConfig: defaultMembershipConfig,
membershipUserOverrides: defaultMembershipUserOverrides,
});
toast.success("已恢复会员配置默认模板");
}}
>
恢复默认模板
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="membershipConfigDraft">会员配置 JSON</Label>
<Textarea
id="membershipConfigDraft"
value={membershipConfigDraft}
onChange={(e) => setMembershipConfigDraft(e.target.value)}
rows={12}
className="font-mono text-xs"
/>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="membershipEnabled">启用会员系统</Label>
<div className="text-xs text-muted-foreground">控制升级弹窗及会员规则是否生效</div>
</div>
<Switch
id="membershipEnabled"
checked={siteSettings.membershipConfig.enabled !== false}
onCheckedChange={(checked) =>
updateMembershipConfigState((config) => ({
...config,
enabled: checked,
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">货币符号</Label>
<Input
value={siteSettings.membershipConfig.currency}
onChange={(e) =>
updateMembershipConfigState((config) => ({
...config,
currency: e.target.value || "$",
}))
}
placeholder="$"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">年付折扣标签</Label>
<Input
value={siteSettings.membershipConfig.annualDiscountLabel}
onChange={(e) =>
updateMembershipConfigState((config) => ({
...config,
annualDiscountLabel: e.target.value,
}))
}
placeholder="限时 5 折优惠"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">默认套餐</Label>
<Select
value={siteSettings.membershipConfig.defaultPlanId}
onValueChange={(value) =>
updateMembershipConfigState((config) => ({
...config,
defaultPlanId: value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="选择默认套餐" />
</SelectTrigger>
<SelectContent>
{siteSettings.membershipConfig.plans.map((plan) => (
<SelectItem key={plan.id} value={plan.id}>
{plan.name || plan.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="membershipOverridesDraft">用户套餐覆盖 JSON</Label>
<Textarea
id="membershipOverridesDraft"
value={membershipOverridesDraft}
onChange={(e) => setMembershipOverridesDraft(e.target.value)}
rows={7}
className="font-mono text-xs"
/>
<div className="text-xs text-muted-foreground">
可用字段:`userPlansById`(用户ID到planId)和 `userPlansByEmail`(邮箱到planId)。
<div className="flex items-center justify-between">
<Label>会员套餐</Label>
<Button type="button" variant="outline" size="sm" onClick={addMembershipPlan}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
新增套餐
</Button>
</div>
<div className="space-y-3">
{siteSettings.membershipConfig.plans.map((plan, index) => (
<div key={`${plan.id}-${index}`} className="rounded-lg border p-3 space-y-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-7">
<div className="space-y-1 md:col-span-2">
<Label className="text-xs text-muted-foreground">套餐名称</Label>
<Input
value={plan.name}
onChange={(e) =>
updateMembershipPlan(index, (item) => ({
...item,
name: e.target.value,
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">月付</Label>
<Input
type="number"
min={0}
step="0.01"
value={plan.monthlyPrice}
onChange={(e) =>
updateMembershipPlan(index, (item) => ({
...item,
monthlyPrice: Math.max(0, Number.parseFloat(e.target.value || "0")),
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">年付</Label>
<Input
type="number"
min={0}
step="0.01"
value={plan.annualPrice}
onChange={(e) =>
updateMembershipPlan(index, (item) => ({
...item,
annualPrice: Math.max(0, Number.parseFloat(e.target.value || "0")),
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">月积分</Label>
<Input
type="number"
min={0}
value={plan.monthlyCredits}
onChange={(e) =>
updateMembershipPlan(index, (item) => ({
...item,
monthlyCredits: Math.max(0, Number.parseInt(e.target.value || "0", 10)),
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">每日免费积分</Label>
<Input
type="number"
min={0}
value={plan.dailyFreeCredits}
onChange={(e) =>
updateMembershipPlan(index, (item) => ({
...item,
dailyFreeCredits: Math.max(0, Number.parseInt(e.target.value || "0", 10)),
}))
}
/>
</div>
<div className="flex items-end justify-end">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removeMembershipPlan(index)}
title="删除套餐"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">套餐ID</Label>
<Input
value={plan.id}
onChange={(e) =>
updateMembershipPlan(index, (item) => ({
...item,
id: e.target.value.replace(/\s+/g, "-"),
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">徽章文案</Label>
<Input
value={plan.badge || ""}
onChange={(e) =>
updateMembershipPlan(index, (item) => ({
...item,
badge: e.target.value || undefined,
}))
}
placeholder="如:热门"
/>
</div>
<div className="flex items-end">
<div className="flex items-center gap-2 rounded-md border px-2 py-1.5">
<Switch
checked={plan.highlighted === true}
onCheckedChange={(checked) =>
updateMembershipPlan(index, (item) => ({
...item,
highlighted: checked,
}))
}
/>
<span className="text-xs text-muted-foreground">高亮展示</span>
</div>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">权益(每行一条)</Label>
<Textarea
value={(plan.features || []).join("\n")}
onChange={(e) =>
updateMembershipPlan(index, (item) => ({
...item,
features: e.target.value
.split("\n")
.map((line) => line.trim())
.filter(Boolean),
}))
}
rows={3}
placeholder="访问所有图片模型&#10;优先队列"
/>
</div>
</div>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>FAQ</Label>
<Button type="button" variant="outline" size="sm" onClick={addMembershipFaq}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
新增 FAQ
</Button>
</div>
<div className="space-y-2">
{siteSettings.membershipConfig.faqs.map((faq, index) => (
<div key={`faq-${index}`} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<Input
value={faq.question}
onChange={(e) =>
updateMembershipFaq(index, (item) => ({
...item,
question: e.target.value,
}))
}
placeholder="问题"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removeMembershipFaq(index)}
title="删除 FAQ"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Textarea
value={faq.answer}
onChange={(e) =>
updateMembershipFaq(index, (item) => ({
...item,
answer: e.target.value,
}))
}
rows={2}
placeholder="回答"
/>
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label>用户套餐覆盖</Label>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="rounded-lg border p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground">按用户ID覆盖</div>
<div className="flex gap-2">
<Input
value={newOverrideUserId}
onChange={(e) => setNewOverrideUserId(e.target.value)}
placeholder="用户ID"
/>
<Select value={newOverridePlanId} onValueChange={setNewOverridePlanId}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="套餐" />
</SelectTrigger>
<SelectContent>
{siteSettings.membershipConfig.plans.map((plan) => (
<SelectItem key={`id-plan-${plan.id}`} value={plan.id}>
{plan.name || plan.id}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" onClick={() => addMembershipOverride("id")}>
添加
</Button>
</div>
<div className="space-y-1">
{Object.entries(siteSettings.membershipUserOverrides.userPlansById || {}).map(
([key, planId]) => (
<div key={`id-${key}`} className="flex items-center justify-between rounded-md border px-2 py-1.5 text-xs">
<span className="truncate pr-2">{key}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{planId}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeMembershipOverride("id", key)}
className="h-6 w-6"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
)}
</div>
</div>
<div className="rounded-lg border p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground">按邮箱覆盖</div>
<div className="flex gap-2">
<Input
value={newOverrideEmail}
onChange={(e) => setNewOverrideEmail(e.target.value)}
placeholder="用户邮箱"
/>
<Select value={newOverridePlanId} onValueChange={setNewOverridePlanId}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="套餐" />
</SelectTrigger>
<SelectContent>
{siteSettings.membershipConfig.plans.map((plan) => (
<SelectItem key={`email-plan-${plan.id}`} value={plan.id}>
{plan.name || plan.id}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" onClick={() => addMembershipOverride("email")}>
添加
</Button>
</div>
<div className="space-y-1">
{Object.entries(siteSettings.membershipUserOverrides.userPlansByEmail || {}).map(
([key, planId]) => (
<div key={`email-${key}`} className="flex items-center justify-between rounded-md border px-2 py-1.5 text-xs">
<span className="truncate pr-2">{key}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{planId}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeMembershipOverride("email", key)}
className="h-6 w-6"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
)}
</div>
</div>
</div>
</div>
</div>