diff --git a/prisma/migrations/20260314100000_add_plan_product_name/migration.sql b/prisma/migrations/20260314100000_add_plan_product_name/migration.sql new file mode 100644 index 0000000..5e79eaf --- /dev/null +++ b/prisma/migrations/20260314100000_add_plan_product_name/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "subscription_plans" ADD COLUMN "product_name" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 96f45f3..a9248b2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -116,6 +116,7 @@ model SubscriptionPlan { validityDays Int @default(30) @map("validity_days") validityUnit String @default("day") @map("validity_unit") // day | week | month features String? @db.Text + productName String? @map("product_name") forSale Boolean @default(false) @map("for_sale") sortOrder Int @default(0) @map("sort_order") createdAt DateTime @default(now()) @map("created_at") diff --git a/src/__tests__/lib/easy-pay/client.test.ts b/src/__tests__/lib/easy-pay/client.test.ts new file mode 100644 index 0000000..33f82ac --- /dev/null +++ b/src/__tests__/lib/easy-pay/client.test.ts @@ -0,0 +1,349 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockGetEnv } = vi.hoisted(() => ({ + mockGetEnv: vi.fn(() => ({ + EASY_PAY_PID: '1001', + EASY_PAY_PKEY: 'test-merchant-secret-key', + EASY_PAY_API_BASE: 'https://pay.example.com', + EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easy-pay/notify', + EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result', + EASY_PAY_CID: undefined, + EASY_PAY_CID_ALIPAY: undefined, + EASY_PAY_CID_WXPAY: undefined, + })), +})); +vi.mock('@/lib/config', () => ({ + getEnv: mockGetEnv, +})); + +const { mockGenerateSign, signCallSnapshots } = vi.hoisted(() => { + const snapshots: Record[][] = []; + return { + signCallSnapshots: snapshots, + mockGenerateSign: vi.fn((...args: unknown[]) => { + // Snapshot params at call time (before caller mutates the object) + snapshots.push(args.map((a) => (typeof a === 'object' && a ? { ...a } : a)) as Record[]); + return 'mocked-sign-value'; + }), + }; +}); +vi.mock('@/lib/easy-pay/sign', () => ({ + generateSign: mockGenerateSign, +})); + +import { createPayment, queryOrder } from '@/lib/easy-pay/client'; + +describe('EasyPay client', () => { + beforeEach(() => { + vi.clearAllMocks(); + signCallSnapshots.length = 0; + }); + + describe('createPayment', () => { + it('should build correct params and POST to mapi.php', async () => { + global.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 1, + trade_no: 'EP20260313000001', + payurl: 'https://pay.example.com/pay/EP20260313000001', + }), + { headers: { 'content-type': 'application/json' } }, + ), + ) as typeof fetch; + + const result = await createPayment({ + outTradeNo: 'order-001', + amount: '10.00', + paymentType: 'alipay', + clientIp: '127.0.0.1', + productName: 'Test Product', + }); + + expect(result.code).toBe(1); + expect(result.trade_no).toBe('EP20260313000001'); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url, init] = (global.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('https://pay.example.com/mapi.php'); + expect(init.method).toBe('POST'); + expect(init.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' }); + + const body = new URLSearchParams(init.body as string); + expect(body.get('pid')).toBe('1001'); + expect(body.get('type')).toBe('alipay'); + expect(body.get('out_trade_no')).toBe('order-001'); + expect(body.get('money')).toBe('10.00'); + expect(body.get('name')).toBe('Test Product'); + expect(body.get('clientip')).toBe('127.0.0.1'); + expect(body.get('notify_url')).toBe('https://pay.example.com/api/easy-pay/notify'); + expect(body.get('return_url')).toBe('https://pay.example.com/pay/result'); + expect(body.get('sign')).toBe('mocked-sign-value'); + expect(body.get('sign_type')).toBe('MD5'); + }); + + it('should call generateSign with correct params (without sign/sign_type)', async () => { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), { + headers: { 'content-type': 'application/json' }, + }), + ) as typeof fetch; + + await createPayment({ + outTradeNo: 'order-002', + amount: '20.00', + paymentType: 'wxpay', + clientIp: '10.0.0.1', + productName: 'Another Product', + }); + + expect(mockGenerateSign).toHaveBeenCalledTimes(1); + const [signParams, pkey] = signCallSnapshots[signCallSnapshots.length - 1] as [Record, string]; + expect(pkey).toBe('test-merchant-secret-key'); + // sign and sign_type should not be in the params passed to generateSign + expect(signParams).not.toHaveProperty('sign'); + expect(signParams).not.toHaveProperty('sign_type'); + expect(signParams.type).toBe('wxpay'); + }); + + it('should throw when API returns code !== 1', async () => { + global.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ code: -1, msg: 'Invalid parameter' }), + { headers: { 'content-type': 'application/json' } }, + ), + ) as typeof fetch; + + await expect( + createPayment({ + outTradeNo: 'order-003', + amount: '10.00', + paymentType: 'alipay', + clientIp: '127.0.0.1', + productName: 'Product', + }), + ).rejects.toThrow('EasyPay create payment failed: Invalid parameter'); + }); + + it('should throw with "unknown error" when msg is absent', async () => { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 0 }), { + headers: { 'content-type': 'application/json' }, + }), + ) as typeof fetch; + + await expect( + createPayment({ + outTradeNo: 'order-004', + amount: '10.00', + paymentType: 'alipay', + clientIp: '127.0.0.1', + productName: 'Product', + }), + ).rejects.toThrow('EasyPay create payment failed: unknown error'); + }); + + it('should not include cid when no CID env vars are set', async () => { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), { + headers: { 'content-type': 'application/json' }, + }), + ) as typeof fetch; + + await createPayment({ + outTradeNo: 'order-005', + amount: '10.00', + paymentType: 'alipay', + clientIp: '127.0.0.1', + productName: 'Product', + }); + + const [, init] = (global.fetch as ReturnType).mock.calls[0]; + const body = new URLSearchParams(init.body as string); + expect(body.has('cid')).toBe(false); + }); + }); + + describe('createPayment CID routing', () => { + it('should use EASY_PAY_CID_ALIPAY for alipay payment type', async () => { + mockGetEnv.mockReturnValue({ + EASY_PAY_PID: '1001', + EASY_PAY_PKEY: 'test-merchant-secret-key', + EASY_PAY_API_BASE: 'https://pay.example.com', + EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easy-pay/notify', + EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result', + EASY_PAY_CID: '100', + EASY_PAY_CID_ALIPAY: '200', + EASY_PAY_CID_WXPAY: '300', + }); + + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), { + headers: { 'content-type': 'application/json' }, + }), + ) as typeof fetch; + + await createPayment({ + outTradeNo: 'order-cid-1', + amount: '10.00', + paymentType: 'alipay', + clientIp: '127.0.0.1', + productName: 'Product', + }); + + const [, init] = (global.fetch as ReturnType).mock.calls[0]; + const body = new URLSearchParams(init.body as string); + expect(body.get('cid')).toBe('200'); + }); + + it('should use EASY_PAY_CID_WXPAY for wxpay payment type', async () => { + mockGetEnv.mockReturnValue({ + EASY_PAY_PID: '1001', + EASY_PAY_PKEY: 'test-merchant-secret-key', + EASY_PAY_API_BASE: 'https://pay.example.com', + EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easy-pay/notify', + EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result', + EASY_PAY_CID: '100', + EASY_PAY_CID_ALIPAY: '200', + EASY_PAY_CID_WXPAY: '300', + }); + + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), { + headers: { 'content-type': 'application/json' }, + }), + ) as typeof fetch; + + await createPayment({ + outTradeNo: 'order-cid-2', + amount: '10.00', + paymentType: 'wxpay', + clientIp: '127.0.0.1', + productName: 'Product', + }); + + const [, init] = (global.fetch as ReturnType).mock.calls[0]; + const body = new URLSearchParams(init.body as string); + expect(body.get('cid')).toBe('300'); + }); + + it('should fall back to EASY_PAY_CID when channel-specific CID is not set', async () => { + mockGetEnv.mockReturnValue({ + EASY_PAY_PID: '1001', + EASY_PAY_PKEY: 'test-merchant-secret-key', + EASY_PAY_API_BASE: 'https://pay.example.com', + EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easy-pay/notify', + EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result', + EASY_PAY_CID: '100', + EASY_PAY_CID_ALIPAY: undefined, + EASY_PAY_CID_WXPAY: undefined, + }); + + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), { + headers: { 'content-type': 'application/json' }, + }), + ) as typeof fetch; + + await createPayment({ + outTradeNo: 'order-cid-3', + amount: '10.00', + paymentType: 'alipay', + clientIp: '127.0.0.1', + productName: 'Product', + }); + + const [, init] = (global.fetch as ReturnType).mock.calls[0]; + const body = new URLSearchParams(init.body as string); + expect(body.get('cid')).toBe('100'); + }); + }); + + describe('queryOrder', () => { + it('should call GET api.php with correct query parameters', async () => { + global.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 1, + trade_no: 'EP20260313000001', + out_trade_no: 'order-001', + type: 'alipay', + pid: '1001', + addtime: '2026-03-13 10:00:00', + endtime: '2026-03-13 10:01:00', + name: 'Test Product', + money: '10.00', + status: 1, + }), + { headers: { 'content-type': 'application/json' } }, + ), + ) as typeof fetch; + + const result = await queryOrder('order-001'); + + expect(result.code).toBe(1); + expect(result.trade_no).toBe('EP20260313000001'); + expect(result.status).toBe(1); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url] = (global.fetch as ReturnType).mock.calls[0]; + expect(url).toContain('https://pay.example.com/api.php'); + expect(url).toContain('act=order'); + expect(url).toContain('pid=1001'); + expect(url).toContain('key=test-merchant-secret-key'); + expect(url).toContain('out_trade_no=order-001'); + }); + + it('should throw when API returns code !== 1', async () => { + global.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ code: -1, msg: 'Order not found' }), + { headers: { 'content-type': 'application/json' } }, + ), + ) as typeof fetch; + + await expect(queryOrder('nonexistent-order')).rejects.toThrow( + 'EasyPay query order failed: Order not found', + ); + }); + + it('should throw with "unknown error" when msg is absent', async () => { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 0 }), { + headers: { 'content-type': 'application/json' }, + }), + ) as typeof fetch; + + await expect(queryOrder('order-err')).rejects.toThrow( + 'EasyPay query order failed: unknown error', + ); + }); + + it('should parse all response fields correctly', async () => { + const mockResponse = { + code: 1, + trade_no: 'EP20260313000002', + out_trade_no: 'order-010', + type: 'wxpay', + pid: '1001', + addtime: '2026-03-13 12:00:00', + endtime: '2026-03-13 12:05:00', + name: 'Premium Plan', + money: '99.00', + status: 1, + param: 'custom-param', + buyer: 'buyer@example.com', + }; + + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(mockResponse), { + headers: { 'content-type': 'application/json' }, + }), + ) as typeof fetch; + + const result = await queryOrder('order-010'); + + expect(result).toEqual(mockResponse); + }); + }); +}); diff --git a/src/__tests__/lib/easy-pay/sign.test.ts b/src/__tests__/lib/easy-pay/sign.test.ts new file mode 100644 index 0000000..300ffe4 --- /dev/null +++ b/src/__tests__/lib/easy-pay/sign.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest'; +import crypto from 'crypto'; +import { generateSign, verifySign } from '@/lib/easy-pay/sign'; + +const TEST_PKEY = 'test-merchant-secret-key'; + +describe('EasyPay MD5 Sign', () => { + const testParams: Record = { + pid: '1001', + type: 'alipay', + out_trade_no: 'order-001', + notify_url: 'https://pay.example.com/api/easy-pay/notify', + return_url: 'https://pay.example.com/pay/result', + name: 'Test Product', + money: '10.00', + clientip: '127.0.0.1', + }; + + describe('generateSign', () => { + it('should generate a valid MD5 hex string', () => { + const sign = generateSign(testParams, TEST_PKEY); + expect(sign).toBeTruthy(); + expect(sign).toMatch(/^[0-9a-f]{32}$/); + }); + + it('should produce consistent signatures for same input', () => { + const sign1 = generateSign(testParams, TEST_PKEY); + const sign2 = generateSign(testParams, TEST_PKEY); + expect(sign1).toBe(sign2); + }); + + it('should sort parameters alphabetically', () => { + const reversed: Record = {}; + const keys = Object.keys(testParams).reverse(); + for (const key of keys) { + reversed[key] = testParams[key]; + } + const sign1 = generateSign(testParams, TEST_PKEY); + const sign2 = generateSign(reversed, TEST_PKEY); + expect(sign1).toBe(sign2); + }); + + it('should filter out empty values', () => { + const paramsWithEmpty = { ...testParams, empty_field: '' }; + const sign1 = generateSign(testParams, TEST_PKEY); + const sign2 = generateSign(paramsWithEmpty, TEST_PKEY); + expect(sign1).toBe(sign2); + }); + + it('should exclude sign field from signing', () => { + const paramsWithSign = { ...testParams, sign: 'old_sign' }; + const sign1 = generateSign(testParams, TEST_PKEY); + const sign2 = generateSign(paramsWithSign, TEST_PKEY); + expect(sign1).toBe(sign2); + }); + + it('should exclude sign_type field from signing', () => { + const paramsWithSignType = { ...testParams, sign_type: 'MD5' }; + const sign1 = generateSign(testParams, TEST_PKEY); + const sign2 = generateSign(paramsWithSignType, TEST_PKEY); + expect(sign1).toBe(sign2); + }); + + it('should produce correct MD5 hash for known input', () => { + // Manually compute expected: sorted keys → query string → append pkey → MD5 + const sorted = Object.entries(testParams) + .filter(([, v]) => v !== '') + .sort(([a], [b]) => a.localeCompare(b)); + const queryString = sorted.map(([k, v]) => `${k}=${v}`).join('&'); + const expected = crypto + .createHash('md5') + .update(queryString + TEST_PKEY) + .digest('hex'); + + const sign = generateSign(testParams, TEST_PKEY); + expect(sign).toBe(expected); + }); + + it('should produce different signatures for different pkeys', () => { + const sign1 = generateSign(testParams, TEST_PKEY); + const sign2 = generateSign(testParams, 'different-key'); + expect(sign1).not.toBe(sign2); + }); + + it('should produce different signatures for different params', () => { + const sign1 = generateSign(testParams, TEST_PKEY); + const modified = { ...testParams, money: '99.99' }; + const sign2 = generateSign(modified, TEST_PKEY); + expect(sign1).not.toBe(sign2); + }); + }); + + describe('verifySign', () => { + it('should return true for a valid signature', () => { + const sign = generateSign(testParams, TEST_PKEY); + const valid = verifySign(testParams, TEST_PKEY, sign); + expect(valid).toBe(true); + }); + + it('should return false for an invalid signature', () => { + const valid = verifySign(testParams, TEST_PKEY, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + expect(valid).toBe(false); + }); + + it('should return false for tampered params', () => { + const sign = generateSign(testParams, TEST_PKEY); + const tampered = { ...testParams, money: '999.99' }; + const valid = verifySign(tampered, TEST_PKEY, sign); + expect(valid).toBe(false); + }); + + it('should return false for wrong pkey', () => { + const sign = generateSign(testParams, TEST_PKEY); + const valid = verifySign(testParams, 'wrong-key', sign); + expect(valid).toBe(false); + }); + + it('should return false when sign length differs (timing-safe guard)', () => { + const valid = verifySign(testParams, TEST_PKEY, 'short'); + expect(valid).toBe(false); + }); + + it('should use timing-safe comparison (same length, different content)', () => { + const sign = generateSign(testParams, TEST_PKEY); + // Flip the first character to create a same-length but different sign + const flipped = (sign[0] === 'a' ? 'b' : 'a') + sign.slice(1); + const valid = verifySign(testParams, TEST_PKEY, flipped); + expect(valid).toBe(false); + }); + }); +}); diff --git a/src/app/admin/channels/page.tsx b/src/app/admin/channels/page.tsx index 6d2df3e..9b34ed8 100644 --- a/src/app/admin/channels/page.tsx +++ b/src/app/admin/channels/page.tsx @@ -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(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([]); @@ -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() { )} + {/* Recharge config card (R5, R6) */} +
+

+ {t.rechargeConfig} +

+
+
+ + setRcPrefix(e.target.value)} + className={inputCls} + placeholder="Sub2API" + /> +
+
+ + setRcSuffix(e.target.value)} + className={inputCls} + placeholder="CNY" + /> +
+
+ +
+ {`${rcPrefix.trim() || 'Sub2API'} 100 ${rcSuffix.trim() || 'CNY'}`.trim()} +
+
+
+
+
+ + + {t.enableBalanceRecharge} + +
+ +
+
+ {/* Channel table */}
([]); + const [searchDropdownOpen, setSearchDropdownOpen] = useState(false); + const [searchTimer, setSearchTimer] = useState | null>(null); const [subs, setSubs] = useState([]); const [subsUser, setSubsUser] = useState(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) */}
- setSubsUserId(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && fetchSubs()} - placeholder={t.searchUserId} - className={[inputCls, 'max-w-xs'].join(' ')} - /> +
+ 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 && ( +
+ {searchResults.map((u) => ( + + ))} +
+ )} +
+ {/* Product Name (R3) */} +
+ + setFormProductName(e.target.value)} + placeholder={t.fieldProductNamePlaceholder} + className={inputCls} + /> +
+ {/* Enabled */}
)} + {/* R7: 所有入口关闭提示 */} + {allEntriesClosed && (activeMobileTab === 'pay' || !isMobile) && ( +
+
+ + + + +
+

+ {pickLocaleText(locale, '充值/订阅入口已被管理员关闭', 'Recharge / Subscription entry has been closed by admin')} +

+

+ {pickLocaleText(locale, '如有疑问,请联系管理员', 'Please contact the administrator if you have questions')} +

+
+ )} + {/* ── 有渠道配置:新版UI ── */} {channelsLoaded && showMainTabs && (activeMobileTab === 'pay' || !isMobile) && !selectedPlan && !showTopUpForm && ( <> - + - {mainTab === 'topup' && ( + {mainTab === 'topup' && !balanceDisabled && (
{/* 按量付费说明 banner */}
0 && !selectedPlan && ( + {channelsLoaded && !showMainTabs && !balanceDisabled && config.enabledPaymentTypes.length > 0 && !selectedPlan && ( <> {isMobile ? ( activeMobileTab === 'pay' ? ( diff --git a/src/components/MainTabs.tsx b/src/components/MainTabs.tsx index f4b0b96..c88aa5b 100644 --- a/src/components/MainTabs.tsx +++ b/src/components/MainTabs.tsx @@ -8,17 +8,22 @@ interface MainTabsProps { activeTab: 'topup' | 'subscribe'; onTabChange: (tab: 'topup' | 'subscribe') => void; showSubscribeTab: boolean; + showTopUpTab?: boolean; isDark: boolean; locale: Locale; } -export default function MainTabs({ activeTab, onTabChange, showSubscribeTab, isDark, locale }: MainTabsProps) { +export default function MainTabs({ activeTab, onTabChange, showSubscribeTab, showTopUpTab = true, isDark, locale }: MainTabsProps) { if (!showSubscribeTab) return null; - const tabs: { key: 'topup' | 'subscribe'; label: string }[] = [ - { key: 'topup', label: pickLocaleText(locale, '按量付费', 'Pay-as-you-go') }, - { key: 'subscribe', label: pickLocaleText(locale, '包月套餐', 'Subscription') }, - ]; + const tabs: { key: 'topup' | 'subscribe'; label: string }[] = []; + if (showTopUpTab) { + tabs.push({ key: 'topup', label: pickLocaleText(locale, '按量付费', 'Pay-as-you-go') }); + } + tabs.push({ key: 'subscribe', label: pickLocaleText(locale, '包月套餐', 'Subscription') }); + + // 只有一个 tab 时不显示切换器 + if (tabs.length <= 1) return null; return (
{ + const env = getEnv(); + const response = await fetch( + `${env.SUB2API_BASE_URL}/api/v1/admin/users?search=${encodeURIComponent(keyword)}&page=1&page_size=30`, + { + headers: getHeaders(), + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + }, + ); + + if (!response.ok) { + throw new Error(`Failed to search users: ${response.status}`); + } + + const data = await response.json(); + return (data.data ?? []) as { id: number; email: string; username: string; notes?: string }[]; +} + export async function addBalance(userId: number, amount: number, notes: string, idempotencyKey: string): Promise { const env = getEnv(); const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`, {