feat: 订阅管理增强、商品名称配置、余额充值开关

- R1: 用户订阅搜索改为模糊关键词(邮箱/用户名/备注/APIKey)
- R2: "分组状态"列名改为"Sub2API 状态"
- R3: 订阅套餐可配置支付商品名称(productName)
- R4: 订阅订单校验 subscription_type 必须为 subscription
- R5: 渠道管理配置余额充值商品名前缀/后缀
- R6: 渠道管理可关闭余额充值,前端隐藏入口,API 拒绝
- R7: 所有入口关闭时显示"入口被管理员关闭"提示
- fix: easy-pay client 测试 mock 方式修复(vi.fn + 参数快照)
This commit is contained in:
erio
2026-03-14 00:43:00 +08:00
parent 1bb11ee32b
commit 6c61c3f877
16 changed files with 873 additions and 32 deletions

View File

@@ -110,6 +110,15 @@ function getTexts(locale: Locale) {
syncImportSuccess: (n: number) => `Successfully imported ${n} channel(s)`,
yes: 'Yes',
no: 'No',
rechargeConfig: 'Recharge Configuration',
productNamePrefix: 'Product Name Prefix',
productNameSuffix: 'Product Name Suffix',
preview: 'Preview',
enableBalanceRecharge: 'Enable Balance Recharge',
saveConfig: 'Save',
savingConfig: 'Saving...',
configSaved: 'Configuration saved',
configSaveFailed: 'Failed to save configuration',
}
: {
missingToken: '缺少管理员凭证',
@@ -163,6 +172,15 @@ function getTexts(locale: Locale) {
syncImportSuccess: (n: number) => `成功导入 ${n} 个渠道`,
yes: '是',
no: '否',
rechargeConfig: '充值配置',
productNamePrefix: '商品名前缀',
productNameSuffix: '商品名后缀',
preview: '预览',
enableBalanceRecharge: '启用余额充值',
saveConfig: '保存',
savingConfig: '保存中...',
configSaved: '配置已保存',
configSaveFailed: '保存配置失败',
};
}
@@ -224,6 +242,12 @@ function ChannelsContent() {
const [form, setForm] = useState<ChannelFormData>(emptyForm);
const [saving, setSaving] = useState(false);
// Recharge config state (R5, R6)
const [rcPrefix, setRcPrefix] = useState('');
const [rcSuffix, setRcSuffix] = useState('');
const [rcBalanceEnabled, setRcBalanceEnabled] = useState(true);
const [rcSaving, setRcSaving] = useState(false);
// Sync modal state
const [syncModalOpen, setSyncModalOpen] = useState(false);
const [syncGroups, setSyncGroups] = useState<Sub2ApiGroup[]>([]);
@@ -254,9 +278,55 @@ function ChannelsContent() {
}
}, [token]);
// Fetch recharge config
const fetchRechargeConfig = useCallback(async () => {
if (!token) return;
try {
const res = await fetch(`/api/admin/config?token=${encodeURIComponent(token)}`);
if (res.ok) {
const data = await res.json();
const configs: { key: string; value: string }[] = data.configs ?? [];
for (const c of configs) {
if (c.key === 'PRODUCT_NAME_PREFIX') setRcPrefix(c.value);
if (c.key === 'PRODUCT_NAME_SUFFIX') setRcSuffix(c.value);
if (c.key === 'BALANCE_PAYMENT_DISABLED') setRcBalanceEnabled(c.value !== 'true');
}
}
} catch { /* ignore */ }
}, [token]);
const saveRechargeConfig = async () => {
setRcSaving(true);
setError('');
try {
const res = await fetch('/api/admin/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
configs: [
{ key: 'PRODUCT_NAME_PREFIX', value: rcPrefix.trim(), group: 'payment', label: '商品名前缀' },
{ key: 'PRODUCT_NAME_SUFFIX', value: rcSuffix.trim(), group: 'payment', label: '商品名后缀' },
{ key: 'BALANCE_PAYMENT_DISABLED', value: rcBalanceEnabled ? 'false' : 'true', group: 'payment', label: '余额充值禁用' },
],
}),
});
if (!res.ok) {
setError(t.configSaveFailed);
}
} catch {
setError(t.configSaveFailed);
} finally {
setRcSaving(false);
}
};
useEffect(() => {
fetchChannels();
}, [fetchChannels]);
fetchRechargeConfig();
}, [fetchChannels, fetchRechargeConfig]);
// ── Missing token ──
@@ -537,6 +607,76 @@ function ChannelsContent() {
</div>
)}
{/* Recharge config card (R5, R6) */}
<div
className={[
'mb-4 rounded-xl border p-4',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}
>
<h3 className={['text-sm font-semibold mb-3', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
{t.rechargeConfig}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className={labelCls}>{t.productNamePrefix}</label>
<input
type="text"
value={rcPrefix}
onChange={(e) => setRcPrefix(e.target.value)}
className={inputCls}
placeholder="Sub2API"
/>
</div>
<div>
<label className={labelCls}>{t.productNameSuffix}</label>
<input
type="text"
value={rcSuffix}
onChange={(e) => setRcSuffix(e.target.value)}
className={inputCls}
placeholder="CNY"
/>
</div>
<div>
<label className={labelCls}>{t.preview}</label>
<div className={['rounded-lg border px-3 py-2 text-sm', isDark ? 'border-slate-600 bg-slate-700 text-slate-300' : 'border-slate-300 bg-slate-50 text-slate-600'].join(' ')}>
{`${rcPrefix.trim() || 'Sub2API'} 100 ${rcSuffix.trim() || 'CNY'}`.trim()}
</div>
</div>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setRcBalanceEnabled(!rcBalanceEnabled)}
className={[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors',
rcBalanceEnabled ? 'bg-emerald-500' : isDark ? 'bg-slate-600' : 'bg-slate-300',
].join(' ')}
>
<span
className={[
'inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform',
rcBalanceEnabled ? 'translate-x-4.5' : 'translate-x-0.5',
].join(' ')}
/>
</button>
<span className={['text-sm', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ')}>
{t.enableBalanceRecharge}
</span>
</div>
<button
type="button"
onClick={saveRechargeConfig}
disabled={rcSaving}
className="inline-flex items-center rounded-lg bg-emerald-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
>
{rcSaving ? t.savingConfig : t.saveConfig}
</button>
</div>
</div>
{/* Channel table */}
<div
className={[

View File

@@ -27,6 +27,7 @@ interface SubscriptionPlan {
groupWeeklyLimit: number | null;
groupMonthlyLimit: number | null;
groupModelScopes: string[] | null;
productName: string | null;
}
interface Sub2ApiGroup {
@@ -103,7 +104,7 @@ function buildText(locale: Locale) {
colOriginalPrice: 'Original Price',
colValidDays: 'Validity',
colEnabled: 'For Sale',
colGroupStatus: 'Group Status',
colGroupStatus: 'Sub2API Status',
colActions: 'Actions',
edit: 'Edit',
delete: 'Delete',
@@ -112,10 +113,12 @@ function buildText(locale: Locale) {
groupExists: 'Exists',
groupMissing: 'Missing',
noPlans: 'No plans configured',
searchUserId: 'Search by user ID',
searchUserId: 'Email / Username / Notes / API Key',
search: 'Search',
noSubs: 'No subscription records found',
enterUserId: 'Enter a user ID to search',
enterUserId: 'Enter a keyword to search users',
fieldProductName: 'Payment Product Name',
fieldProductNamePlaceholder: 'Leave empty for default',
saveFailed: 'Failed to save plan',
deleteFailed: 'Failed to delete plan',
loadFailed: 'Failed to load data',
@@ -183,7 +186,7 @@ function buildText(locale: Locale) {
colOriginalPrice: '原价',
colValidDays: '有效期',
colEnabled: '启用售卖',
colGroupStatus: '分组状态',
colGroupStatus: 'Sub2API 状态',
colActions: '操作',
edit: '编辑',
delete: '删除',
@@ -192,10 +195,12 @@ function buildText(locale: Locale) {
groupExists: '存在',
groupMissing: '缺失',
noPlans: '暂无套餐配置',
searchUserId: '按用户 ID 搜索',
searchUserId: '邮箱/用户名/备注/API Key',
search: '搜索',
noSubs: '未找到订阅记录',
enterUserId: '输入用户 ID 进行搜索',
enterUserId: '输入关键词搜索用户',
fieldProductName: '支付商品名称',
fieldProductNamePlaceholder: '留空使用默认名称',
saveFailed: '保存套餐失败',
deleteFailed: '删除套餐失败',
loadFailed: '加载数据失败',
@@ -332,10 +337,15 @@ function SubscriptionsContent() {
const [formFeatures, setFormFeatures] = useState('');
const [formSortOrder, setFormSortOrder] = useState('0');
const [formEnabled, setFormEnabled] = useState(true);
const [formProductName, setFormProductName] = useState('');
const [saving, setSaving] = useState(false);
/* --- subs state --- */
const [subsUserId, setSubsUserId] = useState('');
const [subsKeyword, setSubsKeyword] = useState('');
const [searchResults, setSearchResults] = useState<{ id: number; email: string; username: string; notes?: string }[]>([]);
const [searchDropdownOpen, setSearchDropdownOpen] = useState(false);
const [searchTimer, setSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const [subs, setSubs] = useState<Sub2ApiSubscription[]>([]);
const [subsUser, setSubsUser] = useState<SubsUserInfo | null>(null);
const [subsLoading, setSubsLoading] = useState(false);
@@ -396,6 +406,7 @@ function SubscriptionsContent() {
setFormFeatures('');
setFormSortOrder('0');
setFormEnabled(true);
setFormProductName('');
setModalOpen(true);
};
@@ -411,6 +422,7 @@ function SubscriptionsContent() {
setFormFeatures((plan.features ?? []).join('\n'));
setFormSortOrder(String(plan.sortOrder));
setFormEnabled(plan.enabled);
setFormProductName(plan.productName ?? '');
setModalOpen(true);
};
@@ -438,6 +450,7 @@ function SubscriptionsContent() {
.filter(Boolean),
sort_order: parseInt(formSortOrder, 10) || 0,
for_sale: formEnabled,
product_name: formProductName.trim() || null,
};
try {
const url = editingPlan
@@ -502,6 +515,39 @@ function SubscriptionsContent() {
}
};
/* --- search users (R1) --- */
const handleKeywordChange = (value: string) => {
setSubsKeyword(value);
if (searchTimer) clearTimeout(searchTimer);
if (!value.trim()) {
setSearchResults([]);
setSearchDropdownOpen(false);
return;
}
const timer = setTimeout(async () => {
try {
const res = await fetch(
`/api/admin/sub2api/search-users?token=${encodeURIComponent(token)}&keyword=${encodeURIComponent(value.trim())}`,
);
if (res.ok) {
const data = await res.json();
setSearchResults(data.users ?? []);
setSearchDropdownOpen(true);
}
} catch {
/* ignore */
}
}, 300);
setSearchTimer(timer);
};
const selectUser = (user: { id: number; email: string; username: string }) => {
setSubsUserId(String(user.id));
setSubsKeyword(`${user.email} #${user.id}`);
setSearchDropdownOpen(false);
setSearchResults([]);
};
/* --- fetch user subs --- */
const fetchSubs = async () => {
if (!token || !subsUserId.trim()) return;
@@ -883,19 +929,54 @@ function SubscriptionsContent() {
{/* ====== Tab: User Subscriptions ====== */}
{activeTab === 'subs' && (
<>
{/* Search bar */}
{/* Search bar (R1: fuzzy search) */}
<div className="mb-4 flex gap-2">
<input
type="text"
value={subsUserId}
onChange={(e) => setSubsUserId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchSubs()}
placeholder={t.searchUserId}
className={[inputCls, 'max-w-xs'].join(' ')}
/>
<div className="relative max-w-sm flex-1">
<input
type="text"
value={subsKeyword}
onChange={(e) => handleKeywordChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setSearchDropdownOpen(false);
fetchSubs();
}
}}
onFocus={() => { if (searchResults.length > 0) setSearchDropdownOpen(true); }}
placeholder={t.searchUserId}
className={inputCls}
/>
{/* Dropdown */}
{searchDropdownOpen && searchResults.length > 0 && (
<div
className={[
'absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-y-auto rounded-lg border shadow-lg',
isDark ? 'border-slate-600 bg-slate-800' : 'border-slate-200 bg-white',
].join(' ')}
>
{searchResults.map((u) => (
<button
key={u.id}
type="button"
onClick={() => selectUser(u)}
className={[
'w-full px-3 py-2 text-left text-sm transition-colors',
isDark ? 'hover:bg-slate-700 text-slate-200' : 'hover:bg-slate-50 text-slate-800',
].join(' ')}
>
<div className="font-medium">{u.email}</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>
{u.username} #{u.id}
{u.notes && <span className="ml-2 opacity-70">({u.notes})</span>}
</div>
</button>
))}
</div>
)}
</div>
<button
type="button"
onClick={fetchSubs}
onClick={() => { setSearchDropdownOpen(false); fetchSubs(); }}
disabled={subsLoading || !subsUserId.trim()}
className={[
'inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50',
@@ -1192,6 +1273,18 @@ function SubscriptionsContent() {
/>
</div>
{/* Product Name (R3) */}
<div>
<label className={labelCls}>{t.fieldProductName}</label>
<input
type="text"
value={formProductName}
onChange={(e) => setFormProductName(e.target.value)}
placeholder={t.fieldProductNamePlaceholder}
className={inputCls}
/>
</div>
{/* Enabled */}
<div className="flex items-center gap-2">
<button