diff --git a/app/activate/__tests__/page.test.tsx b/app/activate/__tests__/page.test.tsx index 7783c6e..5876f7f 100644 --- a/app/activate/__tests__/page.test.tsx +++ b/app/activate/__tests__/page.test.tsx @@ -19,16 +19,57 @@ jest.mock('next/navigation', () => ({ // Mock fetch global.fetch = jest.fn(); +const createJsonResponse = (data: any, init?: { ok?: boolean; status?: number; statusText?: string }) => ({ + ok: init?.ok ?? true, + status: init?.status ?? 200, + statusText: init?.statusText ?? 'OK', + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), +}); + +const mockFetchRoutes = (routes: Record) => { + (global.fetch as jest.Mock).mockImplementation((input: RequestInfo | URL) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + + const handler = routes[url] ?? routes['*']; + if (handler instanceof Error) { + return Promise.reject(handler); + } + + if (!handler) { + throw new Error(`Unhandled fetch request: ${url}`); + } + + return Promise.resolve(typeof handler === 'function' ? handler() : handler); + }); +}; + +const renderPage = async () => { + await act(async () => { + render(); + }); + await screen.findByText('FLNXI 素材系统'); +}; + describe('ActivatePage', () => { beforeEach(() => { jest.clearAllMocks(); (global.fetch as jest.Mock).mockReset(); + mockFetchRoutes({ + '/api/license/check': createJsonResponse({ activated: false }), + '*': createJsonResponse({ success: true }), + }); }); describe('渲染测试', () => { - it('应该正确渲染页面标题和表单', () => { - render(); - + it('应该正确渲染页面标题和表单', async () => { + await renderPage(); + expect(screen.getByText('FLNXI 素材系统')).toBeInTheDocument(); expect(screen.getByText('请激活您的许可证')).toBeInTheDocument(); expect(screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX')).toBeInTheDocument(); @@ -36,98 +77,148 @@ describe('ActivatePage', () => { expect(screen.getByText('免费试用')).toBeInTheDocument(); }); - it('应该显示免费版限制说明', () => { - render(); + it('应该显示免费版限制说明', async () => { + await renderPage(); expect(screen.getByText('免费版限制:10 个用户、100 个素材')).toBeInTheDocument(); }); - it('应该显示联系信息', () => { - render(); + it('应该显示联系信息', async () => { + await renderPage(); expect(screen.getByText('需要购买许可证?请联系我们')).toBeInTheDocument(); }); }); describe('许可证密钥格式化', () => { - it('应该自动将输入转换为大写', () => { - render(); + it('应该自动将输入转换为大写', async () => { + await renderPage(); const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX') as HTMLInputElement; - + fireEvent.change(input, { target: { value: 'abcd' } }); expect(input.value).toBe('ABCD'); }); - it('应该自动添加横杠分隔符', () => { - render(); + it('应该自动添加横杠分隔符', async () => { + await renderPage(); const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX') as HTMLInputElement; - + fireEvent.change(input, { target: { value: 'abcd1234' } }); expect(input.value).toBe('ABCD-1234'); }); - it('应该过滤非字母数字字符', () => { - render(); + it('应该过滤非字母数字字符', async () => { + await renderPage(); const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX') as HTMLInputElement; - + fireEvent.change(input, { target: { value: 'ab!@#cd12' } }); expect(input.value).toBe('ABCD-12'); }); - it('应该限制最大长度为19个字符(含横杠)', () => { - render(); + it('应该限制最大长度为19个字符(含横杠)', async () => { + await renderPage(); const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX') as HTMLInputElement; - + fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678EXTRA' } }); expect(input.value).toBe('ABCD-1234-EFGH-5678'); }); }); describe('激活按钮状态', () => { - it('密钥不完整时激活按钮应该禁用', () => { - render(); + it('密钥不完整时激活按钮应该禁用', async () => { + await renderPage(); const button = screen.getByText('激活许可证'); expect(button).toBeDisabled(); }); - it('密钥完整时激活按钮应该启用', () => { - render(); + it('密钥完整时激活按钮应该启用', async () => { + await renderPage(); const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX'); - + fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } }); - + const button = screen.getByText('激活许可证'); expect(button).not.toBeDisabled(); }); }); describe('许可证激活', () => { - it('激活成功后应该跳转到首页', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - json: () => Promise.resolve({ success: true }), + it('激活成功后应该显示成功页面', async () => { + mockFetchRoutes({ + '/api/license/check': createJsonResponse({ activated: false }), + '/api/license/activate': createJsonResponse({ + success: true, + initialAdmin: { + email: 'admin@local.flnxi', + username: 'admin', + password: 'Generated-Password-123', + mustChangePassword: true, + }, + }), }); - render(); + await renderPage(); const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX'); fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } }); - + const button = screen.getByText('激活许可证'); await act(async () => { fireEvent.click(button); }); await waitFor(() => { - expect(mockPush).toHaveBeenCalledWith('/'); + expect(screen.getByText('激活成功')).toBeInTheDocument(); + expect(screen.getByText('系统已准备就绪')).toBeInTheDocument(); + expect(screen.getByText('初始管理员账号')).toBeInTheDocument(); + expect(screen.getByText('admin@local.flnxi')).toBeInTheDocument(); + expect(screen.getByText('Generated-Password-123')).toBeInTheDocument(); + expect(screen.getByText('前往登录')).toBeInTheDocument(); }); }); - it('激活失败应该显示错误信息', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - json: () => Promise.resolve({ success: false, error: '许可证无效' }), + it('点击前往登录按钮应该跳转到登录页', async () => { + mockFetchRoutes({ + '/api/license/check': createJsonResponse({ activated: false }), + '/api/license/activate': createJsonResponse({ + success: true, + initialAdmin: { + email: 'admin@local.flnxi', + username: 'admin', + password: 'Generated-Password-123', + mustChangePassword: true, + }, + }), }); - render(); + await renderPage(); const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX'); fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } }); - + + const activateButton = screen.getByText('激活许可证'); + await act(async () => { + fireEvent.click(activateButton); + }); + + await waitFor(() => { + expect(screen.getByText('前往登录')).toBeInTheDocument(); + }); + + const loginButton = screen.getByText('前往登录'); + await act(async () => { + fireEvent.click(loginButton); + }); + + expect(mockPush).toHaveBeenCalledWith('/login'); + }); + + it('激活失败应该显示错误信息', async () => { + mockFetchRoutes({ + '/api/license/check': createJsonResponse({ activated: false }), + '/api/license/activate': createJsonResponse({ success: false, error: '许可证无效' }), + }); + + await renderPage(); + const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX'); + fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } }); + const button = screen.getByText('激活许可证'); await act(async () => { fireEvent.click(button); @@ -139,12 +230,15 @@ describe('ActivatePage', () => { }); it('网络错误应该显示网络错误提示', async () => { - (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error')); + mockFetchRoutes({ + '/api/license/check': createJsonResponse({ activated: false }), + '/api/license/activate': new Error('Network error'), + }); - render(); + await renderPage(); const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX'); fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } }); - + const button = screen.getByText('激活许可证'); await act(async () => { fireEvent.click(button); @@ -157,76 +251,257 @@ describe('ActivatePage', () => { it('激活时应该显示加载状态', async () => { let resolvePromise: (value: unknown) => void; - (global.fetch as jest.Mock).mockImplementation(() => - new Promise(resolve => { resolvePromise = resolve; }) - ); + mockFetchRoutes({ + '/api/license/check': createJsonResponse({ activated: false }), + '/api/license/activate': () => + new Promise((resolve) => { + resolvePromise = resolve; + }), + }); - render(); + await renderPage(); const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX'); fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } }); - + const button = screen.getByText('激活许可证'); await act(async () => { fireEvent.click(button); }); expect(screen.getByText('激活中...')).toBeInTheDocument(); - + // 清理 await act(async () => { - resolvePromise!({ json: () => Promise.resolve({ success: true }) }); + resolvePromise!(createJsonResponse({ + success: true, + initialAdmin: { + email: 'admin@local.flnxi', + username: 'admin', + password: 'Generated-Password-123', + mustChangePassword: true, + }, + })); }); }); }); describe('免费试用', () => { - it('免费试用成功后应该跳转到首页', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - json: () => Promise.resolve({ success: true }), - }); - - render(); + it('点击免费试用按钮应该显示客户名称输入弹窗', async () => { + await renderPage(); const button = screen.getByText('免费试用'); + await act(async () => { fireEvent.click(button); }); + expect(screen.getByText('申请免费试用')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('请输入您的公司或个人名称')).toBeInTheDocument(); + expect(screen.getByText('确认申请')).toBeInTheDocument(); + expect(screen.getByText('取消')).toBeInTheDocument(); + }); + + it('未输入客户名称时确认申请按钮应该禁用', async () => { + await renderPage(); + const freeTrialButton = screen.getByText('免费试用'); + + await act(async () => { + fireEvent.click(freeTrialButton); + }); + + const confirmButton = screen.getByText('确认申请'); + expect(confirmButton).toBeDisabled(); + }); + + it('只输入客户名称未输入联系方式时确认申请按钮应该禁用', async () => { + await renderPage(); + const freeTrialButton = screen.getByText('免费试用'); + + await act(async () => { + fireEvent.click(freeTrialButton); + }); + + const nameInput = screen.getByPlaceholderText('请输入您的公司或个人名称'); + fireEvent.change(nameInput, { target: { value: '测试公司' } }); + + const confirmButton = screen.getByText('确认申请'); + expect(confirmButton).toBeDisabled(); + }); + + it('输入客户名称和联系方式后确认申请按钮应该启用', async () => { + await renderPage(); + const freeTrialButton = screen.getByText('免费试用'); + + await act(async () => { + fireEvent.click(freeTrialButton); + }); + + const nameInput = screen.getByPlaceholderText('请输入您的公司或个人名称'); + const contactInput = screen.getByPlaceholderText('手机号/微信/邮箱'); + fireEvent.change(nameInput, { target: { value: '测试公司' } }); + fireEvent.change(contactInput, { target: { value: '13800138000' } }); + + const confirmButton = screen.getByText('确认申请'); + expect(confirmButton).not.toBeDisabled(); + }); + + it('点击取消按钮应该关闭弹窗', async () => { + await renderPage(); + const freeTrialButton = screen.getByText('免费试用'); + + await act(async () => { + fireEvent.click(freeTrialButton); + }); + + expect(screen.getByText('申请免费试用')).toBeInTheDocument(); + + const cancelButton = screen.getByText('取消'); + await act(async () => { + fireEvent.click(cancelButton); + }); + + expect(screen.queryByText('申请免费试用')).not.toBeInTheDocument(); + }); + + it('免费试用成功后应该显示成功页面', async () => { + mockFetchRoutes({ + '/api/license/check': createJsonResponse({ activated: false }), + '/api/license/activate-free': createJsonResponse({ + success: true, + initialAdmin: { + email: 'admin@local.flnxi', + username: 'admin', + password: 'Generated-Password-123', + mustChangePassword: true, + }, + }), + }); + + await renderPage(); + + // 点击免费试用按钮打开弹窗 + const freeTrialButton = screen.getByText('免费试用'); + await act(async () => { + fireEvent.click(freeTrialButton); + }); + + // 输入客户名称和联系方式 + const nameInput = screen.getByPlaceholderText('请输入您的公司或个人名称'); + const contactInput = screen.getByPlaceholderText('手机号/微信/邮箱'); + fireEvent.change(nameInput, { target: { value: '测试公司' } }); + fireEvent.change(contactInput, { target: { value: '13800138000' } }); + + // 点击确认申请 + const confirmButton = screen.getByText('确认申请'); + await act(async () => { + fireEvent.click(confirmButton); + }); + await waitFor(() => { - expect(mockPush).toHaveBeenCalledWith('/'); + expect(screen.getByText('激活成功')).toBeInTheDocument(); + expect(screen.getByText('前往登录')).toBeInTheDocument(); }); expect(global.fetch).toHaveBeenCalledWith('/api/license/activate-free', { method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ customerName: '测试公司', contactInfo: '13800138000' }), }); }); it('免费试用失败应该显示错误信息', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - json: () => Promise.resolve({ success: false, error: '试用已过期' }), + mockFetchRoutes({ + '/api/license/check': createJsonResponse({ activated: false }), + '/api/license/activate-free': createJsonResponse({ success: false, error: '试用已过期' }), }); - render(); - const button = screen.getByText('免费试用'); + await renderPage(); + + // 点击免费试用按钮打开弹窗 + const freeTrialButton = screen.getByText('免费试用'); await act(async () => { - fireEvent.click(button); + fireEvent.click(freeTrialButton); + }); + + // 输入客户名称和联系方式 + const nameInput = screen.getByPlaceholderText('请输入您的公司或个人名称'); + const contactInput = screen.getByPlaceholderText('手机号/微信/邮箱'); + fireEvent.change(nameInput, { target: { value: '测试公司' } }); + fireEvent.change(contactInput, { target: { value: '13800138000' } }); + + // 点击确认申请 + const confirmButton = screen.getByText('确认申请'); + await act(async () => { + fireEvent.click(confirmButton); }); await waitFor(() => { expect(screen.getByText('试用已过期')).toBeInTheDocument(); }); }); + + it('网络错误应该显示网络错误提示', async () => { + mockFetchRoutes({ + '/api/license/check': createJsonResponse({ activated: false }), + '/api/license/activate-free': new Error('Network error'), + }); + + await renderPage(); + + // 点击免费试用按钮打开弹窗 + const freeTrialButton = screen.getByText('免费试用'); + await act(async () => { + fireEvent.click(freeTrialButton); + }); + + // 输入客户名称和联系方式 + const nameInput = screen.getByPlaceholderText('请输入您的公司或个人名称'); + const contactInput = screen.getByPlaceholderText('手机号/微信/邮箱'); + fireEvent.change(nameInput, { target: { value: '测试公司' } }); + fireEvent.change(contactInput, { target: { value: '13800138000' } }); + + // 点击确认申请 + const confirmButton = screen.getByText('确认申请'); + await act(async () => { + fireEvent.click(confirmButton); + }); + + await waitFor(() => { + expect(screen.getByText('网络错误,请检查网络连接')).toBeInTheDocument(); + }); + }); + + it('应该显示联系方式输入框', async () => { + await renderPage(); + const freeTrialButton = screen.getByText('免费试用'); + + await act(async () => { + fireEvent.click(freeTrialButton); + }); + + expect(screen.getByPlaceholderText('手机号/微信/邮箱')).toBeInTheDocument(); + expect(screen.getByText('联系方式')).toBeInTheDocument(); + }); }); describe('API 调用', () => { it('激活时应该发送正确的请求', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - json: () => Promise.resolve({ success: true }), + mockFetchRoutes({ + '/api/license/check': createJsonResponse({ activated: false }), + '/api/license/activate': createJsonResponse({ + success: true, + initialAdmin: { + email: 'admin@local.flnxi', + username: 'admin', + password: 'Generated-Password-123', + mustChangePassword: true, + }, + }), }); - render(); + await renderPage(); const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX'); fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } }); - + const button = screen.getByText('激活许可证'); await act(async () => { fireEvent.click(button); diff --git a/app/activate/page.tsx b/app/activate/page.tsx index 1573f89..0036529 100644 --- a/app/activate/page.tsx +++ b/app/activate/page.tsx @@ -1,13 +1,47 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; export default function ActivatePage() { const router = useRouter(); + const [initialAdmin, setInitialAdmin] = useState<{ + email: string; + username: string; + password: string | null; + mustChangePassword: boolean; + passwordSource?: string | null; + } | null>(null); const [licenseKey, setLicenseKey] = useState(''); const [loading, setLoading] = useState(false); + const [checking, setChecking] = useState(true); const [error, setError] = useState(''); + const [activated, setActivated] = useState(false); + const [showFreeTrialModal, setShowFreeTrialModal] = useState(false); + const [customerName, setCustomerName] = useState(''); + const [contactInfo, setContactInfo] = useState(''); + + // 页面加载时检查数据库中是否已有许可证 + useEffect(() => { + const checkLicense = async () => { + try { + const res = await fetch('/api/license/check'); + const data = await res.json(); + + if (data.activated) { + // 数据库中已有许可证,cookie 已自动恢复,重定向到首页 + router.push('/'); + return; + } + } catch (error) { + console.error('检查许可证状态失败:', error); + } finally { + setChecking(false); + } + }; + + checkLicense(); + }, [router]); // 格式化许可证密钥输入(自动添加横杠) const formatLicenseKey = (value: string) => { @@ -44,7 +78,8 @@ export default function ActivatePage() { const data = await res.json(); if (data.success) { - router.push('/'); + setInitialAdmin(data.initialAdmin || null); + setActivated(true); } else { setError(data.error || '激活失败,请检查许可证密钥'); } @@ -55,43 +90,160 @@ export default function ActivatePage() { } }; + // 打开免费试用弹窗 + const openFreeTrialModal = () => { + setCustomerName(''); + setContactInfo(''); + setShowFreeTrialModal(true); + }; + // 免费试用 const handleFreeTrial = async () => { + const trimmedName = customerName.trim(); + const trimmedContact = contactInfo.trim(); + + if (!trimmedName) { + setError('请输入客户名称'); + return; + } + if (!trimmedContact) { + setError('请输入联系方式'); + return; + } + setLoading(true); setError(''); + setShowFreeTrialModal(false); try { const res = await fetch('/api/license/activate-free', { method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + customerName: trimmedName, + contactInfo: trimmedContact + }), }); - const data = await res.json(); + let data; + try { + data = await res.json(); + } catch (jsonError) { + // 如果响应不是JSON(可能是HTML重定向),显示详细错误 + const text = await res.text(); + console.error('激活响应不是JSON:', text.substring(0, 200)); + setError(`服务器返回了无效响应(${res.status}),请检查网络连接`); + return; + } if (data.success) { - router.push('/'); + setInitialAdmin(data.initialAdmin || null); + setActivated(true); } else { - setError(data.error || '激活失败'); + // 处理各种错误消息格式 + let errorMsg = data.error || '激活失败,请检查输入信息'; + if (errorMsg === 'Required' || errorMsg.includes('required')) { + errorMsg = '请填写完整的客户信息(客户名称和联系方式都是必填项)'; + } + setError(errorMsg); } - } catch { + } catch (error) { + console.error('激活请求失败:', error); setError('网络错误,请检查网络连接'); } finally { setLoading(false); } }; + // 检查中或已激活,显示加载状态 + if (checking) { + return ( +
+
+
+

正在检查许可证状态...

+
+
+ ); + } + + // 激活成功页面 + if (activated) { + return ( +
+
+
+
+
+ + + +
+

激活成功

+

系统已准备就绪

+
+ + {initialAdmin ? ( + <> +
+

初始管理员账号

+
+
+ 邮箱: + {initialAdmin.email} +
+ {initialAdmin.password ? ( +
+ 密码: + {initialAdmin.password} +
+ ) : initialAdmin.passwordSource === 'environment' ? ( +

初始密码已由部署配置提供,系统不会在页面或日志中回显明文。

+ ) : ( +

管理员账号已存在,请使用现有管理员密码登录。

+ )} +
+
+ +
+

+ {initialAdmin.password + ? '初始密码只在当前页面展示一次,首次登录后将强制修改密码。' + : '首次登录后将强制修改密码。'} +

+
+ + ) : ( +
+

系统已激活,请使用现有管理员账号登录。

+
+ )} + + +
+
+
+ ); + } + return ( -
+
-
+
{/* Logo */}
-

FLNXI 素材系统

-

请激活您的许可证

+

FLNXI 素材系统

+

请激活您的许可证

{/* 许可证输入 */}
-
{/* 联系信息 */} -

+

需要购买许可证?请联系我们

+ + {/* 免费试用弹窗 */} + {showFreeTrialModal && ( +
+
{ + e.preventDefault(); + handleFreeTrial(); + }} + > +

申请免费试用

+
+ + setCustomerName(e.target.value)} + placeholder="请输入您的公司或个人名称" + className="w-full px-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700 focus:border-slate-900 dark:focus:border-slate-500 bg-white dark:bg-slate-800 text-slate-900 dark:text-white" + autoFocus + required + /> +
+
+ + setContactInfo(e.target.value)} + placeholder="手机号/微信/邮箱" + className="w-full px-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700 focus:border-slate-900 dark:focus:border-slate-500 bg-white dark:bg-slate-800 text-slate-900 dark:text-white" + required + /> +

用于许可证管理和售后服务

+
+
+ + +
+
+
+ )}
); } diff --git a/app/admin/_components/AppearanceSettingsPanel.tsx b/app/admin/_components/AppearanceSettingsPanel.tsx index 9eba55e..4bcd9ef 100644 --- a/app/admin/_components/AppearanceSettingsPanel.tsx +++ b/app/admin/_components/AppearanceSettingsPanel.tsx @@ -3,8 +3,10 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react' import Image from 'next/image' import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles' -import { animations, gradientBackgrounds, glowEffects, textAnimations, scrollToElementWithSpring } from '@/lib/ui-animations' import type { SessionUser } from './types' +import RestrictedToggle from '@/components/ui/RestrictedToggle' +import { useLicenseStatus } from '@/hooks/useLicenseStatus' +import StorageEditConfirmModal from '@/components/ui/StorageEditConfirmModal' type AppearanceSettingsPanelProps = { sessionUser: SessionUser | null | undefined @@ -18,7 +20,7 @@ type ToastState = { const sectionConfig = [ { key: 'brand', label: '品牌与主题', hint: '站点名称与主题色' }, - { key: 'logos', label: 'LOGO 管理', hint: '导航栏、登录页 LOGO' }, + { key: 'logos', label: '标识管理', hint: '导航栏、登录页标识' }, { key: 'toggles', label: '功能开关', hint: '敏感功能开关' }, { key: 'storage', label: '对象存储', hint: 'S3 / MinIO 配置' }, ] as const @@ -27,13 +29,14 @@ type SectionKey = (typeof sectionConfig)[number]['key'] // 默认 LOGO 路径:与后端 /lib/config.ts 中的 defaultLogos 保持一致 const DEFAULT_HEADER_LOGO = - process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '/logo/logo1.png' + process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '' const DEFAULT_LOGIN_LOGO = - process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '/logo/logo2.png' + process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '' export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedded' }: AppearanceSettingsPanelProps) { const styles = useThemeStyles() const theme = useTheme() + const { isFree, isFeatureEnabled } = useLicenseStatus() const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [headerBgColor, setHeaderBgColor] = useState('#1f2937') @@ -62,22 +65,15 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd const [toast, setToast] = useState(null) const [lastSavedAt, setLastSavedAt] = useState(null) - const [heroGlowPos, setHeroGlowPos] = useState({ x: 50, y: 50 }) const [activeNav, setActiveNav] = useState('brand') - const [visibleSections, setVisibleSections] = useState>({ - brand: true, - logos: true, - toggles: true, - storage: true, - }) - const [isDarkMode, setIsDarkMode] = useState(false) + // 对象存储配置编辑状态 + const [storageEditEnabled, setStorageEditEnabled] = useState(false) + const [showStorageConfirmModal, setShowStorageConfirmModal] = useState(false) const brandRef = useRef(null) const logoRef = useRef(null) const toggleRef = useRef(null) const storageRef = useRef(null) - const navCardRef = useRef(null) - const [navCardHeight, setNavCardHeight] = useState(0) const sectionRefs: Record<(typeof sectionConfig)[number]['key'], RefObject> = useMemo( () => ({ @@ -94,64 +90,6 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd setTimeout(() => setToast(null), 3600) }, []) - useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - const key = entry.target.getAttribute('data-section') as SectionKey | null - if (!key) return - if (entry.isIntersecting) { - setVisibleSections((prev) => (prev[key] ? prev : { ...prev, [key]: true })) - setActiveNav(key) - } - }) - }, - { threshold: 0.35, rootMargin: '0px 0px -10% 0px' } - ) - const nodes = Object.values(sectionRefs) - .map((ref) => ref.current) - .filter((node): node is HTMLDivElement => Boolean(node)) - nodes.forEach((node) => observer.observe(node)) - return () => nodes.forEach((node) => observer.unobserve(node)) - }, [sectionRefs]) - - useEffect(() => { - if (typeof document === 'undefined') return - const root = document.documentElement - const update = () => setIsDarkMode(root.classList.contains('dark')) - update() - const observer = new MutationObserver(update) - observer.observe(root, { attributes: true, attributeFilter: ['class'] }) - return () => observer.disconnect() - }, []) - - // 动态计算导航卡片高度,用于设置状态卡片位置 - useEffect(() => { - const updateNavCardHeight = () => { - if (navCardRef.current) { - // 使用 getBoundingClientRect 获取更准确的高度 - const rect = navCardRef.current.getBoundingClientRect() - const height = rect.height - setNavCardHeight(height) - document.documentElement.style.setProperty('--nav-card-height', `${height}px`) - } - } - // 初始计算 - updateNavCardHeight() - // 延迟再次计算,确保 DOM 完全渲染 - const timeoutId = setTimeout(updateNavCardHeight, 100) - const resizeObserver = new ResizeObserver(updateNavCardHeight) - if (navCardRef.current) { - resizeObserver.observe(navCardRef.current) - } - window.addEventListener('resize', updateNavCardHeight) - return () => { - clearTimeout(timeoutId) - resizeObserver.disconnect() - window.removeEventListener('resize', updateNavCardHeight) - } - }, []) - const fetchSettings = useCallback(async () => { setLoading(true) try { @@ -296,50 +234,14 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd const scrollToSection = (key: SectionKey) => { const ref = sectionRefs[key] if (!ref?.current) return - - // 使用苹果风格的缓入缓出滚动(页面级滚动,和分类管理保持一致) - scrollToElementWithSpring(ref.current, null, { - offset: 96, // 考虑 sticky top-24 (6rem) 的高度,和左侧小卡片上面对齐 - duration: 600, - easing: 'ease-in-out', + + ref.current.scrollIntoView({ + behavior: 'smooth', + block: 'start', }) setActiveNav(key) } - const [isMouseOver, setIsMouseOver] = useState(false) - - const handleHeroMouseMove = (event: React.MouseEvent) => { - const rect = event.currentTarget.getBoundingClientRect() - const x = ((event.clientX - rect.left) / rect.width) * 100 - const y = ((event.clientY - rect.top) / rect.height) * 100 - setHeroGlowPos({ x, y }) - setIsMouseOver(true) - } - - const handleHeroMouseLeave = () => { - setIsMouseOver(false) - // 鼠标离开时,光晕应该平滑消失,而不是闪现到中心 - // 通过设置 isMouseOver 为 false,背景渐变会自动消失 - } - - // 统一的 Hero 卡片背景渐变 - 使用统一颜色定义(与「组织脉络」卡片相同) - const heroDynamicBackground = { - backgroundImage: isMouseOver - ? `radial-gradient(circle 300px at ${heroGlowPos.x}% ${heroGlowPos.y}%, ${ - isDarkMode - ? 'rgba(255,255,255,0.2), rgba(255,255,255,0.1), rgba(255,255,255,0.05), transparent 75%' - : 'rgba(255,255,255,0.3), rgba(255,255,255,0.15), rgba(255,255,255,0.08), transparent 75%' - }), ${isDarkMode ? gradientBackgrounds.heroBase.dark : gradientBackgrounds.heroBase.light}` - : isDarkMode ? gradientBackgrounds.heroBase.dark : gradientBackgrounds.heroBase.light, - transition: 'background-image 0.4s ease-out', - } - - const sectionRevealClass = (key: SectionKey) => - cn( - 'transition-all duration-700 ease-out transform', - visibleSections[key] ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-6 scale-95' - ) - const navButtonClass = (key: SectionKey) => cn( 'w-full text-left group', @@ -347,28 +249,11 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd ) // 更精致的光晕效果 - 使用更相近的颜色,减少数量 - const floatingOrbs = ( - <> - {/* 精致光球 - 右上角 - 使用相近的蓝色系 */} -
- - {/* 精致光球 - 左下角 - 使用相近的紫色系 */} -
- - ) - - const staggerDelays: Record = { - brand: 'delay-[120ms]', - logos: 'delay-[220ms]', - toggles: 'delay-[320ms]', - storage: 'delay-[420ms]', - } - if (!sessionUser || sessionUser.role !== 'ADMIN') { return (
@@ -390,24 +275,20 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
- {floatingOrbs} -
+
-

Appearance Studio

-

外观设置工作台

+

+ 外观 +

+

品牌、标识与系统开关

- 沿用模板管理的分区式结构,这里集中处理品牌主题、LOGO、功能开关与对象存储。每次保存都会即时同步到后台界面与素材上传入口。 + 这里集中处理品牌主题、LOGO、功能开关与对象存储。保存后会同步到后台界面与素材上传入口。

{lastSavedAt && (

@@ -422,10 +303,9 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd onClick={fetchSettings} disabled={saving} className={cn( - 'inline-flex items-center gap-2 rounded-xl border px-5 py-3 text-sm font-medium transition-all', - theme === 'dark' - ? 'border-white/20 bg-white/5 text-white/80 hover:bg-white/10 hover:bg-white/20 disabled:opacity-60' - : 'border-slate-300/50 bg-slate-50/80 text-slate-700 hover:bg-slate-100/80 disabled:opacity-60' + styles.buttonStyles.secondary, + styles.buttonModifiers.compact, + 'inline-flex items-center gap-2 disabled:opacity-60' )} > 重新拉取 @@ -439,9 +319,9 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd onClick={handleSave} disabled={saving} className={cn( - 'inline-flex items-center gap-2 rounded-xl bg-white/90 px-6 py-3 font-semibold shadow-lg shadow-indigo-500/25 hover:bg-white transition-all', - theme === 'dark' ? 'text-indigo-200' : 'text-indigo-700', - 'disabled:opacity-60' + styles.buttonStyles.primary, + styles.buttonModifiers.compact, + 'inline-flex items-center gap-2 disabled:opacity-60' )} > {saving ? ( @@ -465,15 +345,14 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd

-
-
+
@@ -912,7 +848,8 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd value={s3Region} onChange={(e) => setS3Region(e.target.value)} placeholder="us-east-1" - className={cn(styles.inputStyles.base, 'text-sm')} + disabled={!storageEditEnabled} + className={cn(styles.inputStyles.base, 'text-sm', !storageEditEnabled && 'opacity-60 cursor-not-allowed')} />
@@ -934,8 +871,13 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
+ + {/* 对象存储修改确认弹窗 */} + setShowStorageConfirmModal(false)} + onConfirm={() => setStorageEditEnabled(true)} + />
) } - - diff --git a/app/admin/_components/DepartmentsTab.tsx b/app/admin/_components/DepartmentsTab.tsx index df519ac..c001bf4 100644 --- a/app/admin/_components/DepartmentsTab.tsx +++ b/app/admin/_components/DepartmentsTab.tsx @@ -1,11 +1,11 @@ /** * DepartmentsTab 组件 - * + * * 部门管理Tab组件 * 从主页面拆分出来,负责部门管理功能 * 包括部门树形结构、创建、删除、重命名等 * 使用三层架构样式系统,支持深浅色主题 - * + * * @created 2025-01-XX(拆分自 page.tsx) * @component */ @@ -17,6 +17,9 @@ import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles' import { useDialog } from '@/components/ui/DialogProvider' import type { Department, DepartmentNode, ToastFn } from './types' import { buildDepartmentTree, filterDepartmentTree } from '../_utils/departmentTree' +import ProBadge from '@/components/ui/ProBadge' +import UpgradeModal from '@/components/ui/UpgradeModal' +import { useLicenseStatus } from '@/hooks/useLicenseStatus' interface DepartmentsTabProps { departments: Department[] @@ -36,28 +39,21 @@ export default function DepartmentsTab({ const styles = useThemeStyles() const theme = useTheme() const { confirm, prompt } = useDialog() + const { isFree, isFeatureEnabled } = useLicenseStatus() const [departmentSearchTerm, setDepartmentSearchTerm] = useState('') const [collapsedDepartments, setCollapsedDepartments] = useState>(new Set()) const [renamingDepartmentId, setRenamingDepartmentId] = useState(null) const [renamingDepartmentName, setRenamingDepartmentName] = useState('') const [addingDepartment, setAddingDepartment] = useState(false) + const [showUpgradeModal, setShowUpgradeModal] = useState(false) + + // 免费版限制:不能添加子分支(部门隔离功能) + const canAddSubDepartment = !isFree && isFeatureEnabled('departmentIsolation') - // 根据主题选择颜色类名 - const bgIndigo50 = theme === 'dark' ? 'bg-indigo-900/50' : 'bg-indigo-50/90' - const bgIndigo30 = theme === 'dark' ? 'bg-indigo-900/30' : 'bg-indigo-50/70' - const bgIndigo20 = theme === 'dark' ? 'bg-indigo-900/20' : 'bg-indigo-50/50' - const borderIndigo400 = theme === 'dark' ? 'border-indigo-400' : 'border-indigo-400' - const borderIndigo300 = theme === 'dark' ? 'border-indigo-500/80' : 'border-indigo-300' - const borderIndigo200 = theme === 'dark' ? 'border-indigo-700/80' : 'border-indigo-200' - const shadowDark = theme === 'dark' ? 'shadow-[0_1px_3px_rgba(15,23,42,0.55)]' : 'shadow-[0_1px_3px_rgba(15,23,42,0.06)]' - const hoverBgIndigo = theme === 'dark' ? 'hover:bg-indigo-700/80' : 'hover:bg-indigo-200/80' - const textIndigo = theme === 'dark' ? 'text-indigo-300' : 'text-indigo-600' const borderInputIndigo = theme === 'dark' ? 'border-indigo-600' : 'border-indigo-300' const bgInput = theme === 'dark' ? 'bg-slate-900' : 'bg-white' const textTitle = theme === 'dark' ? 'text-white' : 'text-slate-900' const textDesc = theme === 'dark' ? 'text-slate-300' : 'text-slate-600' - const textTitle90 = theme === 'dark' ? 'text-white/90' : 'text-slate-900' - const bgGradient = theme === 'dark' ? 'from-slate-900 via-slate-800 to-indigo-900' : 'from-slate-50 via-blue-50 to-indigo-50' const departmentTree = useMemo( () => buildDepartmentTree(departments), @@ -155,16 +151,16 @@ export default function DepartmentsTab({ const level = Math.min(depth, 2) const depthContainerClasses = level === 0 - ? 'bg-indigo-50/90 dark:bg-indigo-900/50 border-indigo-400 dark:border-indigo-400' + ? 'bg-slate-50 dark:bg-slate-900/60 border-slate-200 dark:border-slate-700' : level === 1 - ? 'bg-indigo-50/70 dark:bg-indigo-900/30 border-indigo-300 dark:border-indigo-500/80' - : 'bg-indigo-50/50 dark:bg-indigo-900/20 border-indigo-200 dark:border-indigo-700/80' + ? 'bg-slate-50/80 dark:bg-slate-900/40 border-slate-200 dark:border-slate-700' + : 'bg-slate-50/60 dark:bg-slate-900/30 border-slate-200 dark:border-slate-800' return (
{ e.stopPropagation() @@ -182,7 +178,7 @@ export default function DepartmentsTab({ > { e.stopPropagation() + if (!canAddSubDepartment) { + setShowUpgradeModal(true) + return + } void handleAddSubDepartment(node.fullName) }} className={cn( styles.buttonStyles.primary, styles.buttonModifiers.compact, - 'bg-indigo-600 hover:bg-indigo-700 text-white' // 显式添加背景色和文字色 + 'bg-indigo-600 hover:bg-indigo-700 text-white relative', // 显式添加背景色和文字色 + !canAddSubDepartment && 'opacity-80' )} > 添加子分支 + {!canAddSubDepartment && }
-

- Architecture Management +

+ 部门管理

-

组织脉络

+

组织结构

树形结构展示上下级,配合搜索框即可迅速过滤任意分支或定位负责人。

@@ -369,17 +371,17 @@ export default function DepartmentsTab({
-
+
@@ -427,7 +441,13 @@ export default function DepartmentsTab({
+ + {/* 升级提示弹窗 */} + setShowUpgradeModal(false)} + featureKey="departmentIsolation" + />
) } - diff --git a/app/admin/_components/TemplateManager.tsx b/app/admin/_components/TemplateManager.tsx index 1ca246f..fd3edd1 100644 --- a/app/admin/_components/TemplateManager.tsx +++ b/app/admin/_components/TemplateManager.tsx @@ -5,7 +5,9 @@ import { formatDateTimeShort } from '../_utils/formatters' import { useThemeStyles, cn } from '@/lib/ui-styles' import { useDialog } from '@/components/ui/DialogProvider' import Modal from '@/components/ui/Modal' +import UpgradeModal from '@/components/ui/UpgradeModal' import { animations } from '@/lib/ui-animations' +import { useLicenseStatus } from '@/hooks/useLicenseStatus' import TemplateManagerHeader from './template-manager/TemplateManagerHeader' import BuiltInTemplateGrid from './template-manager/BuiltInTemplateGrid' import CustomTemplateSection from './template-manager/CustomTemplateSection' @@ -15,6 +17,10 @@ import TemplateFieldsEditor from './template-manager/editor/TemplateFieldsEditor import TemplateEditorFooter from './template-manager/editor/TemplateEditorFooter' import TemplatePreviewPanel from './template-manager/editor/TemplatePreviewPanel' import type { ToastFn } from './types' +import { + BUILTIN_TEMPLATE_KEYS, + createBuiltinTemplate, +} from '@/lib/upload-template-presets' import type { BuiltinTemplateConfig, DisplayTemplate, @@ -280,6 +286,38 @@ const STANDARD_FIELD_TEMPLATES: Record = { conditions: '', autoLabel: false, }, + audioCategory: { + key: 'audioCategory', + label: '音频分类', + type: 'select', + required: true, + layoutWidth: 'full', + visibleOnHomeFilter: true, + isSearchable: true, + isReadOnly: false, + addToCategory: true, + validations: '', + optionsStatic: JSON.stringify(['默认分类']), + allowedExtensions: '', + conditions: '', + autoLabel: false, + }, + audioScene: { + key: 'audioScene', + label: '音频使用场景', + type: 'select', + required: true, + layoutWidth: 'full', + visibleOnHomeFilter: true, + isSearchable: true, + isReadOnly: false, + addToCategory: true, + validations: '', + optionsStatic: JSON.stringify(['默认场景']), + allowedExtensions: '', + conditions: '', + autoLabel: false, + }, } const STANDARD_FIELDS: Array<{ key: string; label: string }> = Object.keys(STANDARD_FIELD_TEMPLATES).map((key) => ({ @@ -309,107 +347,6 @@ const getDefaultTemplateFields = (): FieldForm[] => { return defaultKeys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[] } - -const applySimplifiedFieldFlags = ( - fields: FieldForm[], - options: { requireRenderer?: boolean } = {} -) => { - const { requireRenderer = false } = options - - fields.forEach((field) => { - if (field.key === 'productModel') { - field.required = true - field.isSearchable = true - } else if (field.key === 'categoryName' || field.key === 'sceneName') { - field.required = true - field.visibleOnHomeFilter = true - field.isSearchable = true - } else if (field.key === 'renderer') { - field.required = requireRenderer - field.isSearchable = true - } - }) - - return fields -} - -// 内置常用模板(使用老版本简化字段) -// 全新版本仅保留 3DMAX、PSD、音频、视频、模特、其他 六种预制模板 -const BUILTIN_TEMPLATES: BuiltinTemplateConfig[] = [ - { - key: '3dmax-material', - name: '3DMAX 素材模板', - description: '适用于 3D 模型类素材,覆盖渲染器、SKU 与品牌等核心字段。', - materialType: '3DMAX', - materialKind: 'IMAGE', - getFields: () => { - const keys = ['productModel', 'categoryName', 'sceneName', 'renderer'] - const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[] - return applySimplifiedFieldFlags(fields, { requireRenderer: true }) - }, - }, - { - key: 'psd-material', - name: 'PSD 素材模板', - description: '针对平面设计 PSD 素材,包含品牌、颜色模式和输出分辨率。', - materialType: 'PSD', - materialKind: 'IMAGE', - getFields: () => { - const keys = ['productModel', 'categoryName', 'sceneName'] - const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[] - return applySimplifiedFieldFlags(fields) - }, - }, - { - key: 'audio-material', - name: '音频素材模板', - description: '适用于音频文件素材,支持 MP3、WAV、FLAC、AAC 等格式。', - materialType: '音频', - materialKind: 'AUDIO' as const, - getFields: () => { - const keys = ['productModel', 'categoryName', 'sceneName'] - const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[] - return applySimplifiedFieldFlags(fields) - }, - }, - { - key: 'video-material', - name: '视频素材模板', - description: '适用于视频文件素材。', - materialType: '视频', - materialKind: 'VIDEO', - getFields: () => { - const keys = ['productModel', 'categoryName', 'sceneName'] - const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[] - return applySimplifiedFieldFlags(fields) - }, - }, - { - key: 'model-material', - name: '模特素材模板', - description: '用于模特/人像素材,关注拍摄场景与负责作者。', - materialType: '模特', - materialKind: 'IMAGE', - getFields: () => { - const keys = ['productModel', 'categoryName', 'sceneName'] - const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[] - return applySimplifiedFieldFlags(fields) - }, - }, - { - key: 'other-material', - name: '其他素材模板', - description: '适用于其他类型的素材。', - materialType: '其他', - materialKind: 'OTHER', - getFields: () => { - const keys = ['productModel', 'categoryName', 'sceneName'] - const fields = keys.map((key) => cloneFieldTemplate(key)).filter(Boolean) as FieldForm[] - return applySimplifiedFieldFlags(fields) - }, - }, -] - const safeJsonStringify = (value: any) => { if (value === null || value === undefined || value === '') return '' try { @@ -419,6 +356,40 @@ const safeJsonStringify = (value: any) => { } } +const stringifyFieldConfig = (value: unknown) => { + if (value === null || value === undefined || value === '') return '' + return typeof value === 'string' ? value : safeJsonStringify(value) +} + +const toFieldForm = (field: ReturnType['fields'][number]): FieldForm => ({ + key: field.key, + label: field.label, + type: field.type, + required: field.required, + layoutWidth: field.layoutWidth || 'full', + visibleOnHomeFilter: field.visibleOnHomeFilter, + isSearchable: field.isSearchable, + isReadOnly: field.isReadOnly ?? false, + addToCategory: field.addToCategory ?? false, + validations: stringifyFieldConfig(field.validations), + optionsStatic: stringifyFieldConfig(field.optionsStatic), + allowedExtensions: stringifyFieldConfig(field.allowedExtensions), + conditions: stringifyFieldConfig(field.conditions), + autoLabel: false, +}) + +const BUILTIN_TEMPLATES: BuiltinTemplateConfig[] = BUILTIN_TEMPLATE_KEYS.map((key) => { + const preset = createBuiltinTemplate(key) + return { + key, + name: preset.name, + description: preset.description, + materialType: preset.materialType, + materialKind: preset.materialKind, + getFields: () => createBuiltinTemplate(key).fields.map((field) => toFieldForm(field)), + } +}) + const parseJson = (raw: string, message: string) => { const trimmed = raw.trim() if (!trimmed) return null @@ -482,6 +453,7 @@ const requestJson = async (input: RequestInfo, init?: RequestInit): Promise< export default function TemplateManager({ showToast }: { showToast: ToastFn }) { const styles = useThemeStyles() const { confirm } = useDialog() + const { isFree, isFeatureEnabled } = useLicenseStatus() const [templates, setTemplates] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -501,9 +473,11 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { const [previewTemplate, setPreviewTemplate] = useState(null) const [templateSearchTerm, setTemplateSearchTerm] = useState('') const [enabledBuiltinTemplates, setEnabledBuiltinTemplates] = useState>(new Set()) + const [showUpgradeModal, setShowUpgradeModal] = useState(false) // 分类字段集合同步自后台配置,默认使用常量兜底 const [categoryFieldKeys, setCategoryFieldKeys] = useState(Array.from(GLOBAL_CATEGORY_FIELDS)) const ALL_BUILTIN_KEYS = useMemo(() => BUILTIN_TEMPLATES.map(t => t.key), []) // 所有预制模板key + const canUseCustomTemplates = !isFree && isFeatureEnabled('customTemplates') // 素材迁移弹窗状态 const [migrationDialogOpen, setMigrationDialogOpen] = useState(false) @@ -735,6 +709,10 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { }, [fetchTemplates]) const openCreate = (presetKey?: string) => { + if (!canUseCustomTemplates) { + setShowUpgradeModal(true) + return + } const preset = (presetKey && BUILTIN_TEMPLATES.find((item) => item.key === presetKey)) || BUILTIN_TEMPLATES[0] @@ -773,6 +751,10 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { } const openEdit = (tpl: UploadTemplateDto) => { + if (!canUseCustomTemplates) { + setShowUpgradeModal(true) + return + } setForm({ id: tpl.id, version: tpl.version, @@ -813,6 +795,10 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { } const openDuplicate = (tpl: UploadTemplateDto) => { + if (!canUseCustomTemplates) { + setShowUpgradeModal(true) + return + } setForm({ name: `${tpl.name} - 副本`, description: tpl.description || '', @@ -1126,6 +1112,10 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { }, []) const createTemplateFromPreset = async (presetKey: string, status: 'draft' | 'active' = 'draft') => { + if (!canUseCustomTemplates) { + setShowUpgradeModal(true) + return + } const preset = BUILTIN_TEMPLATES.find((item) => item.key === presetKey) if (!preset) return @@ -1308,7 +1298,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { return (
@@ -1369,7 +1359,13 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { // 如果没有显式设置 materialKind,则按照 materialType 粗略推断一个 materialKind: form.materialKind || - (form.materialType === '视频' ? 'VIDEO' : form.materialType === '其他' ? 'OTHER' : 'IMAGE'), + (form.materialType === '视频' + ? 'VIDEO' + : form.materialType === '音频' + ? 'AUDIO' + : form.materialType === '其他' + ? 'OTHER' + : 'IMAGE'), } try { @@ -1776,6 +1772,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { presets={BUILTIN_TEMPLATES.filter(p => enabledBuiltinTemplates.has(p.key))} onPreview={openPresetPreview} onQuickCreate={(presetKey) => createTemplateFromPreset(presetKey, 'draft')} + canQuickCreate={canUseCustomTemplates} + onRequestUpgrade={() => setShowUpgradeModal(true)} onToggleEnabled={toggleBuiltinTemplateEnabled} onEnableAll={enableAllBuiltinTemplates} enabledTemplates={enabledBuiltinTemplates} @@ -1788,6 +1786,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { templates={displayTemplates} filteredTemplates={filteredTemplates} deletingId={deletingId} + canManageCustomTemplates={canUseCustomTemplates} + onRequestUpgrade={() => setShowUpgradeModal(true)} onCreate={openCreate} onPreview={openCustomPreview} onActivate={handleActivate} @@ -1808,27 +1808,27 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { closeOnEscape={false} >
-

- {isPreviewMode ? '预览模板' : form.id ? '编辑模板' : '新建模板'} +

+ {isPreviewMode ? '模板预览' : form.id ? '编辑模板' : '新建模板'}

{isPreviewMode ? previewTemplate - ? '查看自定义模板的真实上传界面(预览模式不可编辑)' - : '预览预制模板的实际效果(预览模式不可编辑)' - : '左侧配置字段,右侧实时预览上传面板效果'} + ? '查看自定义模板的上传效果' + : '查看预制模板的上传效果' + : '左侧配置字段,右侧查看预览'}

-
-
-
+
{/* 左侧:字段配置面板 */} {!isPreviewMode && ( -
-
+
+
-
-
+ /> +
+
)} )} + + setShowUpgradeModal(false)} + featureKey="customTemplates" + featureName="自定义模板" + featureDescription="创建、复制、编辑和迁移自定义模板需要升级后才能使用。" + /> {/* 素材迁移弹窗 */} {migrationDialogOpen && migrationSourceTemplate && ( @@ -1932,7 +1938,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { closeOnOverlayClick={!migrating} closeOnEscape={!migrating} > -
+
@@ -1951,7 +1957,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {

- 模板 "{migrationSourceTemplate.name}" 有{' '} + 模板 “{migrationSourceTemplate.name}” 有{' '} {migrationSourceTemplate.materialCount}{' '} 个素材正在使用。

diff --git a/app/admin/_components/template-manager/BuiltInTemplateGrid.tsx b/app/admin/_components/template-manager/BuiltInTemplateGrid.tsx index 4d57c49..c3661dd 100644 --- a/app/admin/_components/template-manager/BuiltInTemplateGrid.tsx +++ b/app/admin/_components/template-manager/BuiltInTemplateGrid.tsx @@ -7,6 +7,8 @@ interface BuiltInTemplateGridProps { presets: BuiltinTemplateConfig[] onPreview: (presetKey: string) => void onQuickCreate: (presetKey: string) => void + canQuickCreate: boolean + onRequestUpgrade: () => void onToggleEnabled?: (presetKey: string) => void onEnableAll?: () => void enabledTemplates?: Set @@ -17,6 +19,8 @@ export default function BuiltInTemplateGrid({ presets, onPreview, onQuickCreate, + canQuickCreate, + onRequestUpgrade, onToggleEnabled, onEnableAll, enabledTemplates = new Set(), @@ -37,6 +41,7 @@ export default function BuiltInTemplateGrid({
{hasDisabledTemplates && onEnableAll && (
diff --git a/app/admin/_components/template-manager/CustomTemplateSection.tsx b/app/admin/_components/template-manager/CustomTemplateSection.tsx index 1fb00cf..3d8e081 100644 --- a/app/admin/_components/template-manager/CustomTemplateSection.tsx +++ b/app/admin/_components/template-manager/CustomTemplateSection.tsx @@ -1,6 +1,7 @@ 'use client' import { useThemeStyles, cn } from '@/lib/ui-styles' +import ProBadge from '@/components/ui/ProBadge' import type { DisplayTemplate } from './types' interface CustomTemplateSectionProps { @@ -9,6 +10,8 @@ interface CustomTemplateSectionProps { templates: DisplayTemplate[] filteredTemplates: DisplayTemplate[] deletingId: string | null + canManageCustomTemplates: boolean + onRequestUpgrade: () => void onCreate: () => void onPreview: (template: DisplayTemplate) => void onActivate: (id: string) => void @@ -24,6 +27,8 @@ export default function CustomTemplateSection({ templates, filteredTemplates, deletingId, + canManageCustomTemplates, + onRequestUpgrade, onCreate, onPreview, onActivate, @@ -33,13 +38,28 @@ export default function CustomTemplateSection({ onDelete, }: CustomTemplateSectionProps) { const styles = useThemeStyles() + + const runTemplateAction = (action: () => void) => { + if (!canManageCustomTemplates) { + onRequestUpgrade() + return + } + action() + } + return (
-

自定义模板

-

这里列出你自己创建的模板,可继续编辑、复制或删除

-

这些模板才会被真实上传面板使用,确保状态为“启用”后用户才能看到。

+
+

自定义模板

+ {!canManageCustomTemplates && } +
+

+ {canManageCustomTemplates + ? '这里列出你自己创建的模板,可继续编辑、复制或删除' + : '当前许可证未开放自定义模板功能,升级后可新建、复制、编辑和迁移模板'} +

@@ -51,17 +71,32 @@ export default function CustomTemplateSection({
@@ -76,10 +111,9 @@ export default function CustomTemplateSection({ onClick={() => onPreview(tpl)} className={cn(styles.cardStyles.hover, 'group relative overflow-hidden cursor-pointer')} > -
-

{tpl.name}

+

{tpl.name}

{tpl.description && (

{tpl.description}

)} @@ -90,15 +124,12 @@ export default function CustomTemplateSection({ tpl.status === 'active' ? 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300' : tpl.status === 'draft' - ? 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300' - : 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300' + ? 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300' + : 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300' }`} > {tpl.statusLabel} - - 预览 -
@@ -107,9 +138,14 @@ export default function CustomTemplateSection({ type="button" onClick={(e) => { e.stopPropagation() - onDeactivate(tpl.id) + runTemplateAction(() => onDeactivate(tpl.id)) }} - className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compactXs, styles.buttonModifiers.pill)} + className={cn( + styles.buttonStyles.secondary, + styles.buttonModifiers.compactXs, + styles.buttonModifiers.pill, + !canManageCustomTemplates && 'opacity-70' + )} > 取消启用 @@ -118,9 +154,14 @@ export default function CustomTemplateSection({ type="button" onClick={(e) => { e.stopPropagation() - onActivate(tpl.id) + runTemplateAction(() => onActivate(tpl.id)) }} - className={cn(styles.buttonStyles.success, styles.buttonModifiers.compactXs, styles.buttonModifiers.pill)} + className={cn( + styles.buttonStyles.success, + styles.buttonModifiers.compactXs, + styles.buttonModifiers.pill, + !canManageCustomTemplates && 'opacity-70' + )} > 启用 @@ -129,9 +170,14 @@ export default function CustomTemplateSection({ type="button" onClick={(e) => { e.stopPropagation() - onEdit(tpl) + runTemplateAction(() => onEdit(tpl)) }} - className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compactXs, styles.buttonModifiers.pill)} + className={cn( + styles.buttonStyles.secondary, + styles.buttonModifiers.compactXs, + styles.buttonModifiers.pill, + !canManageCustomTemplates && 'opacity-70' + )} > 编辑 @@ -139,13 +185,14 @@ export default function CustomTemplateSection({ type="button" onClick={(e) => { e.stopPropagation() - onDuplicate(tpl) + runTemplateAction(() => onDuplicate(tpl)) }} className={cn( styles.buttonStyles.secondary, styles.buttonModifiers.compactXs, styles.buttonModifiers.pill, - 'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色、边框和文字色 + 'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200', + !canManageCustomTemplates && 'opacity-70' )} > 复制 @@ -154,14 +201,15 @@ export default function CustomTemplateSection({ type="button" onClick={(e) => { e.stopPropagation() - onDelete(tpl.id) + runTemplateAction(() => onDelete(tpl.id)) }} disabled={deletingId === tpl.id} className={cn( styles.buttonStyles.danger, styles.buttonModifiers.compactXs, styles.buttonModifiers.pill, - 'bg-red-500 hover:bg-red-600 text-white', // 显式添加背景色和文字色,确保在浅色模式下可见 + 'bg-red-500 hover:bg-red-600 text-white', + !canManageCustomTemplates && 'opacity-70', deletingId === tpl.id && 'opacity-70 cursor-wait', )} > @@ -179,5 +227,3 @@ export default function CustomTemplateSection({
) } - - diff --git a/app/admin/_components/template-manager/TemplateManagerHeader.tsx b/app/admin/_components/template-manager/TemplateManagerHeader.tsx index 2213504..ac02817 100644 --- a/app/admin/_components/template-manager/TemplateManagerHeader.tsx +++ b/app/admin/_components/template-manager/TemplateManagerHeader.tsx @@ -15,25 +15,21 @@ export default function TemplateManagerHeader({ const theme = useTheme() const textTitle = theme === 'dark' ? 'text-white' : 'text-slate-900' const textDesc = theme === 'dark' ? 'text-slate-400' : 'text-slate-500' - + return (
-
-
-

- Template Management -

-

模板工作台

-

- 预制模板仅供参考和复制,自定义模板启用后才会对上传面板生效。 +

+
+

模板管理

+

+ 预制模板可预览和启用,自定义模板按许可证开放。

@@ -44,7 +40,7 @@ export default function TemplateManagerHeader({ placeholder="搜索模板名称..." className={cn(styles.inputStyles.base, 'pl-11 pr-10')} /> - + {searchTerm && ( @@ -68,5 +64,3 @@ export default function TemplateManagerHeader({
) } - - diff --git a/app/admin/_components/template-manager/__tests__/CustomTemplateSection.test.tsx b/app/admin/_components/template-manager/__tests__/CustomTemplateSection.test.tsx new file mode 100644 index 0000000..126dbc7 --- /dev/null +++ b/app/admin/_components/template-manager/__tests__/CustomTemplateSection.test.tsx @@ -0,0 +1,114 @@ +/** + * @jest-environment jsdom + */ + +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import CustomTemplateSection from '../CustomTemplateSection' +import type { DisplayTemplate } from '../types' + +jest.mock('@/lib/ui-styles', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), + useThemeStyles: () => ({ + cardStyles: { + flat: 'card-flat', + hover: 'card-hover', + }, + buttonStyles: { + secondary: 'button-secondary', + success: 'button-success', + danger: 'button-danger', + }, + buttonModifiers: { + compactXs: 'button-compact-xs', + pill: 'button-pill', + }, + }), + useTheme: () => 'light', +})) + +describe('CustomTemplateSection 授权提示', () => { + const callbacks = { + onRequestUpgrade: jest.fn(), + onCreate: jest.fn(), + onPreview: jest.fn(), + onActivate: jest.fn(), + onDeactivate: jest.fn(), + onEdit: jest.fn(), + onDuplicate: jest.fn(), + onDelete: jest.fn(), + } + + const customTemplate: DisplayTemplate = { + id: 'template-1', + name: '客户自定义模板', + description: '用于测试授权限制', + status: 'active', + materialType: '3DMAX', + materialKind: 'IMAGE', + version: 1, + fieldGroups: null, + displayConfig: null, + createdAt: '2026-05-25T00:00:00.000Z', + updatedAt: '2026-05-25T00:00:00.000Z', + fields: [], + updatedLabel: '刚刚更新', + fieldCount: 0, + statusLabel: '已启用', + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + function renderSection(canManageCustomTemplates: boolean) { + return render( + + ) + } + + it('许可证不包含自定义模板时应显示前置授权提示并拦截新建入口', () => { + renderSection(false) + + expect(screen.getByText('当前许可证未开放自定义模板功能,升级后可新建、复制、编辑和迁移模板')).toBeInTheDocument() + expect(screen.getByTestId('pro-badge')).toBeInTheDocument() + + fireEvent.click(screen.getByText('升级后新建模板')) + + expect(callbacks.onRequestUpgrade).toHaveBeenCalledTimes(1) + expect(callbacks.onCreate).not.toHaveBeenCalled() + }) + + it('许可证允许自定义模板时应直接执行新建动作', () => { + renderSection(true) + + expect(screen.getByText('这里列出你自己创建的模板,可继续编辑、复制或删除')).toBeInTheDocument() + + fireEvent.click(screen.getByText('新建自定义模板')) + + expect(callbacks.onCreate).toHaveBeenCalledTimes(1) + expect(callbacks.onRequestUpgrade).not.toHaveBeenCalled() + }) + + it('许可证不包含自定义模板时应拦截编辑、复制和启停动作', () => { + renderSection(false) + + fireEvent.click(screen.getByText('取消启用')) + fireEvent.click(screen.getByText('编辑')) + fireEvent.click(screen.getByText('复制')) + + expect(callbacks.onRequestUpgrade).toHaveBeenCalledTimes(3) + expect(callbacks.onDeactivate).not.toHaveBeenCalled() + expect(callbacks.onEdit).not.toHaveBeenCalled() + expect(callbacks.onDuplicate).not.toHaveBeenCalled() + }) +}) diff --git a/app/admin/_components/template-manager/editor/TemplateBasicsForm.tsx b/app/admin/_components/template-manager/editor/TemplateBasicsForm.tsx index 0393c60..6cd722f 100644 --- a/app/admin/_components/template-manager/editor/TemplateBasicsForm.tsx +++ b/app/admin/_components/template-manager/editor/TemplateBasicsForm.tsx @@ -24,6 +24,13 @@ export default function TemplateBasicsForm({ onChange, onSelectPreset, }: TemplateBasicsFormProps) { + const inferMaterialKind = (materialType: string) => { + if (materialType === '视频') return 'VIDEO' + if (materialType === '音频') return 'AUDIO' + if (materialType === '其他') return 'OTHER' + return 'IMAGE' + } + return (
@@ -74,7 +81,7 @@ export default function TemplateBasicsForm({ setActivationCode(e.target.value.toUpperCase())} + placeholder="FLNXI-PRO-XXXX-XXXX-XXXX-XXXX" + className={cn( + 'w-full px-4 py-3 rounded-xl border text-sm font-mono', + 'bg-slate-50 dark:bg-slate-900', + 'border-slate-200 dark:border-slate-700', + 'text-slate-900 dark:text-white', + 'placeholder:text-slate-400 dark:placeholder:text-slate-500', + 'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent', + 'transition-all' + )} + autoFocus + /> +

+ 激活码格式:FLNXI-PRO-XXXX-XXXX-XXXX-XXXX +

+
+ + {error && ( +
+

{error}

+
+ )} + +
+ +
+ + + {/* 底部 */} +
+
+

+ 还没有激活码? +

+ +
+
+
+
+ ); +} diff --git a/components/ui/ProBadge.tsx b/components/ui/ProBadge.tsx new file mode 100644 index 0000000..a054ccb --- /dev/null +++ b/components/ui/ProBadge.tsx @@ -0,0 +1,47 @@ +'use client' + +/** + * ProBadge 组件 + * + * 用于标识受限功能的小徽章 + * 支持 small、medium、large 三种尺寸 + */ + +import { useTheme, cn } from '@/lib/ui-styles' + +export type ProBadgeSize = 'xs' | 'small' | 'medium' | 'large' + +interface ProBadgeProps { + /** 徽章尺寸 */ + size?: ProBadgeSize + /** 自定义类名 */ + className?: string +} + +const sizeStyles: Record = { + xs: 'text-[8px] px-1 py-0.5', + small: 'text-[10px] px-1.5 py-0.5', + medium: 'text-xs px-2 py-0.5', + large: 'text-sm px-2.5 py-1', +} + +export default function ProBadge({ size = 'small', className }: ProBadgeProps) { + const theme = useTheme() + + return ( + + PRO + + ) +} diff --git a/components/ui/RestrictedToggle.tsx b/components/ui/RestrictedToggle.tsx new file mode 100644 index 0000000..342d053 --- /dev/null +++ b/components/ui/RestrictedToggle.tsx @@ -0,0 +1,153 @@ +'use client' + +/** + * RestrictedToggle 组件 + * + * 受限功能开关组件,集成 PRO 徽章和点击拦截逻辑 + * 当功能受限时显示禁用状态,点击时触发升级弹窗 + * + * @created 2025-12-16 + * @component + */ + +import { useState } from 'react' +import { useTheme, cn } from '@/lib/ui-styles' +import ProBadge from './ProBadge' +import UpgradeModal from './UpgradeModal' +import type { FeatureKey } from '@/lib/license/types' + +interface RestrictedToggleProps { + /** 开关标签 */ + label: string + /** 功能键,用于检查是否受限 */ + featureKey: FeatureKey + /** 当前值 */ + value: boolean + /** 值变化回调 */ + onChange: (value: boolean) => void + /** 功能描述(可选) */ + description?: string + /** 是否受限(由父组件传入,基于许可证状态) */ + isRestricted: boolean + /** 自定义类名 */ + className?: string +} + +export default function RestrictedToggle({ + label, + featureKey, + value, + onChange, + description, + isRestricted, + className, +}: RestrictedToggleProps) { + const theme = useTheme() + const [showUpgradeModal, setShowUpgradeModal] = useState(false) + + const handleClick = () => { + if (isRestricted) { + setShowUpgradeModal(true) + } else { + onChange(!value) + } + } + + return ( + <> +
+ {/* 标签和描述 */} +
+
+ + {label} + + {isRestricted && } +
+ {description && ( +

+ {description} +

+ )} + {isRestricted && ( +

+ 升级到付费版解锁此功能 +

+ )} +
+ + {/* 开关 */} + +
+ + {/* 升级弹窗 */} + setShowUpgradeModal(false)} + featureKey={featureKey} + /> + + ) +} diff --git a/components/ui/StorageEditConfirmModal.tsx b/components/ui/StorageEditConfirmModal.tsx new file mode 100644 index 0000000..56aaa27 --- /dev/null +++ b/components/ui/StorageEditConfirmModal.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useState } from 'react'; +import { cn } from '@/lib/ui-styles'; + +interface StorageEditConfirmModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; +} + +export default function StorageEditConfirmModal({ + isOpen, + onClose, + onConfirm, +}: StorageEditConfirmModalProps) { + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + if (!isOpen) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!password.trim()) { + setError('请输入密码'); + return; + } + + setLoading(true); + + try { + const response = await fetch('/api/auth/verify-admin-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + + const data = await response.json(); + + if (data.success) { + setPassword(''); + onConfirm(); + onClose(); + } else { + setError(data.error || '密码验证失败'); + } + } catch (err) { + setError('网络错误,请稍后重试'); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setPassword(''); + setError(null); + onClose(); + }; + + return ( +
+ {/* 背景遮罩 */} +
+ + {/* 弹窗内容 */} +
+ {/* 头部 */} +
+
+
+
+ + + +
+

+ 安全验证 +

+
+ +
+
+ + {/* 警告信息 */} +
+

+ 对象存储配置是系统核心参数,修改不当可能导致素材无法上传或访问。请确认您了解修改的影响。 +

+
+ + {/* 内容 */} +
+
+ + setPassword(e.target.value)} + placeholder="请输入超级管理员密码" + className={cn( + 'w-full px-4 py-3 rounded-xl border text-sm', + 'bg-slate-50 dark:bg-slate-900', + 'border-slate-200 dark:border-slate-700', + 'text-slate-900 dark:text-white', + 'placeholder:text-slate-400 dark:placeholder:text-slate-500', + 'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent', + 'transition-all' + )} + autoFocus + /> +
+ + {error && ( +
+

{error}

+
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/components/ui/UpgradeModal.tsx b/components/ui/UpgradeModal.tsx new file mode 100644 index 0000000..4098103 --- /dev/null +++ b/components/ui/UpgradeModal.tsx @@ -0,0 +1,128 @@ +'use client' + +/** + * UpgradeModal 组件 + * + * 升级提示弹窗,当免费版用户点击受限功能时显示 + * 基于现有 Modal 组件构建,显示功能名称、限制说明和升级好处 + * + * @created 2025-12-16 + * @component + */ + +import Modal from './Modal' +import { useThemeStyles, cn } from '@/lib/ui-styles' +import { getFeatureDescription } from '@/lib/license/feature-descriptions' +import type { FeatureKey } from '@/lib/license/types' + +interface UpgradeModalProps { + /** 是否显示弹窗 */ + isOpen: boolean + /** 关闭弹窗回调 */ + onClose: () => void + /** 功能键,用于获取功能描述 */ + featureKey: FeatureKey + /** 自定义功能名称(可选,覆盖默认值) */ + featureName?: string + /** 自定义功能描述(可选,覆盖默认值) */ + featureDescription?: string +} + +export default function UpgradeModal({ + isOpen, + onClose, + featureKey, + featureName, + featureDescription, +}: UpgradeModalProps) { + const styles = useThemeStyles() + const feature = getFeatureDescription(featureKey) + + const displayName = featureName || feature.name + const displayDescription = featureDescription || feature.description + + const handleUpgradeClick = () => { + // TODO: 跳转到升级页面或显示联系方式 + // 目前先关闭弹窗 + onClose() + } + + return ( + +
+ {/* 图标和标题 */} +
+
+ + + +
+

+ {displayName} +

+

+ 当前许可证未包含此功能,请联系管理员处理授权 +

+
+ + {/* 功能描述 */} +
+

+ {displayDescription} +

+
+ + {/* 按钮 */} +
+ + +
+
+
+ ) +} diff --git a/components/ui/__tests__/ActivationModal.test.tsx b/components/ui/__tests__/ActivationModal.test.tsx new file mode 100644 index 0000000..737a212 --- /dev/null +++ b/components/ui/__tests__/ActivationModal.test.tsx @@ -0,0 +1,295 @@ +/** + * ActivationModal 组件单元测试 + * + * 测试激活码输入弹窗的功能和交互行为 + * + * @jest-environment jsdom + */ + +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' +import ActivationModal from '../ActivationModal' + +// Mock cn function +jest.mock('@/lib/ui-styles', () => ({ + cn: (...classes: (string | boolean | undefined)[]) => + classes.filter(Boolean).join(' '), +})) + +// Mock fetch +const mockFetch = jest.fn() +global.fetch = mockFetch + +// Mock window.open +const mockOpen = jest.fn() +Object.defineProperty(window, 'open', { + value: mockOpen, + writable: true, +}) + +describe('ActivationModal 组件', () => { + const mockOnClose = jest.fn() + const mockOnSuccess = jest.fn() + + beforeEach(() => { + mockOnClose.mockClear() + mockOnSuccess.mockClear() + mockFetch.mockClear() + mockOpen.mockClear() + }) + + describe('基础渲染', () => { + it('当 isOpen 为 true 时应该渲染弹窗', () => { + render( + + ) + expect(screen.getByText('激活许可证')).toBeInTheDocument() + }) + + it('当 isOpen 为 false 时不应该渲染弹窗', () => { + render( + + ) + expect(screen.queryByText('激活许可证')).not.toBeInTheDocument() + }) + + it('应该显示激活码输入框', () => { + render( + + ) + expect(screen.getByPlaceholderText('FLNXI-PRO-XXXX-XXXX-XXXX-XXXX')).toBeInTheDocument() + }) + + it('应该显示格式提示', () => { + render( + + ) + expect(screen.getByText('激活码格式:FLNXI-PRO-XXXX-XXXX-XXXX-XXXX')).toBeInTheDocument() + }) + + it('应该显示立即激活按钮', () => { + render( + + ) + expect(screen.getByText('立即激活')).toBeInTheDocument() + }) + + it('应该显示购买链接', () => { + render( + + ) + expect(screen.getByText('还没有激活码?')).toBeInTheDocument() + expect(screen.getByText('前往购买 →')).toBeInTheDocument() + }) + }) + + describe('输入交互', () => { + it('输入应该自动转换为大写', () => { + render( + + ) + const input = screen.getByPlaceholderText('FLNXI-PRO-XXXX-XXXX-XXXX-XXXX') as HTMLInputElement + fireEvent.change(input, { target: { value: 'flnxi-pro-abcd-1234-efgh-5678' } }) + expect(input.value).toBe('FLNXI-PRO-ABCD-1234-EFGH-5678') + }) + }) + + describe('表单验证', () => { + it('空激活码应该显示错误', async () => { + render( + + ) + fireEvent.click(screen.getByText('立即激活')) + expect(await screen.findByText('请输入激活码')).toBeInTheDocument() + }) + + it('格式不正确的激活码应该显示错误', async () => { + render( + + ) + const input = screen.getByPlaceholderText('FLNXI-PRO-XXXX-XXXX-XXXX-XXXX') + fireEvent.change(input, { target: { value: 'INVALID-CODE' } }) + fireEvent.click(screen.getByText('立即激活')) + expect(await screen.findByText('激活码格式不正确')).toBeInTheDocument() + }) + }) + + describe('API 调用', () => { + it('有效激活码应该调用 API', async () => { + mockFetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ success: true }), + }) + + render( + + ) + + const input = screen.getByPlaceholderText('FLNXI-PRO-XXXX-XXXX-XXXX-XXXX') + fireEvent.change(input, { target: { value: 'FLNXI-PRO-ABCD-1234-EFGH-5678' } }) + fireEvent.click(screen.getByText('立即激活')) + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith('/api/license/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ activationCode: 'FLNXI-PRO-ABCD-1234-EFGH-5678' }), + }) + }) + }) + + it('激活成功应该调用 onSuccess 和 onClose', async () => { + mockFetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ success: true }), + }) + + render( + + ) + + const input = screen.getByPlaceholderText('FLNXI-PRO-XXXX-XXXX-XXXX-XXXX') + fireEvent.change(input, { target: { value: 'FLNXI-PRO-ABCD-1234-EFGH-5678' } }) + fireEvent.click(screen.getByText('立即激活')) + + await waitFor(() => { + expect(mockOnSuccess).toHaveBeenCalled() + expect(mockOnClose).toHaveBeenCalled() + }) + }) + + it('激活失败应该显示错误信息', async () => { + mockFetch.mockResolvedValueOnce({ + json: () => Promise.resolve({ success: false, error: '激活码无效' }), + }) + + render( + + ) + + const input = screen.getByPlaceholderText('FLNXI-PRO-XXXX-XXXX-XXXX-XXXX') + fireEvent.change(input, { target: { value: 'FLNXI-PRO-ABCD-1234-EFGH-5678' } }) + fireEvent.click(screen.getByText('立即激活')) + + expect(await screen.findByText('激活码无效')).toBeInTheDocument() + }) + + it('网络错误应该显示错误信息', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + render( + + ) + + const input = screen.getByPlaceholderText('FLNXI-PRO-XXXX-XXXX-XXXX-XXXX') + fireEvent.change(input, { target: { value: 'FLNXI-PRO-ABCD-1234-EFGH-5678' } }) + fireEvent.click(screen.getByText('立即激活')) + + expect(await screen.findByText('网络错误,请稍后重试')).toBeInTheDocument() + }) + }) + + describe('加载状态', () => { + it('提交时应该显示加载状态', async () => { + mockFetch.mockImplementation(() => new Promise(() => {})) // 永不 resolve + + render( + + ) + + const input = screen.getByPlaceholderText('FLNXI-PRO-XXXX-XXXX-XXXX-XXXX') + fireEvent.change(input, { target: { value: 'FLNXI-PRO-ABCD-1234-EFGH-5678' } }) + fireEvent.click(screen.getByText('立即激活')) + + expect(await screen.findByText('激活中...')).toBeInTheDocument() + }) + }) + + describe('关闭交互', () => { + it('点击关闭按钮应该调用 onClose', () => { + render( + + ) + // 找到关闭按钮(SVG 的父元素) + const closeButton = screen.getByText('激活许可证').parentElement?.querySelector('button') + if (closeButton) { + fireEvent.click(closeButton) + expect(mockOnClose).toHaveBeenCalled() + } + }) + + it('点击背景遮罩应该调用 onClose', () => { + render( + + ) + // 点击背景遮罩 + const overlay = document.querySelector('.backdrop-blur-sm') + if (overlay) { + fireEvent.click(overlay) + expect(mockOnClose).toHaveBeenCalled() + } + }) + }) + + describe('购买链接', () => { + it('点击购买链接应该打开新窗口', () => { + render( + + ) + fireEvent.click(screen.getByText('前往购买 →')) + expect(mockOpen).toHaveBeenCalledWith('https://www.flnxi.com/pricing', '_blank') + }) + }) +}) diff --git a/components/ui/__tests__/ProBadge.test.tsx b/components/ui/__tests__/ProBadge.test.tsx new file mode 100644 index 0000000..41f8c58 --- /dev/null +++ b/components/ui/__tests__/ProBadge.test.tsx @@ -0,0 +1,184 @@ +/** + * ProBadge 组件单元测试 + * + * 测试 ProBadge 组件的尺寸变体和主题支持 + * + * **Feature: free-version-ui-restrictions** + * **Property 3: ProBadge 尺寸变体** + * **Property 4: ProBadge 主题支持** + * **Validates: Requirements 4.2, 4.3** + * + * @jest-environment jsdom + */ + +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import ProBadge, { ProBadgeSize } from '../ProBadge' + +// Mock useTheme hook +const mockUseTheme = jest.fn() +jest.mock('@/lib/ui-styles', () => ({ + useTheme: () => mockUseTheme(), + cn: (...classes: (string | boolean | undefined)[]) => + classes.filter(Boolean).join(' '), +})) + +describe('ProBadge 组件', () => { + beforeEach(() => { + mockUseTheme.mockReturnValue('light') + }) + + describe('基础渲染', () => { + it('应该正确渲染 PRO 文字', () => { + render() + expect(screen.getByText('PRO')).toBeInTheDocument() + }) + + it('应该有正确的 data-testid', () => { + render() + expect(screen.getByTestId('pro-badge')).toBeInTheDocument() + }) + + it('应该使用默认的 small 尺寸', () => { + render() + const badge = screen.getByTestId('pro-badge') + expect(badge).toHaveAttribute('data-size', 'small') + }) + + it('应该应用自定义 className', () => { + render() + const badge = screen.getByTestId('pro-badge') + expect(badge.className).toContain('custom-class') + }) + }) + + /** + * Property 3: ProBadge 尺寸变体 + * *For any* 有效的尺寸参数 (xs, small, medium, large),ProBadge 组件必须使用对应的尺寸样式渲染 + * **Feature: free-version-ui-restrictions, Property 3: ProBadge Size Variants** + * **Validates: Requirements 4.2** + */ + describe('Property 3: ProBadge 尺寸变体', () => { + const sizes: ProBadgeSize[] = ['xs', 'small', 'medium', 'large'] + + const sizeExpectations: Record = { + xs: { textClass: 'text-[8px]', paddingClass: 'px-1' }, + small: { textClass: 'text-[10px]', paddingClass: 'px-1.5' }, + medium: { textClass: 'text-xs', paddingClass: 'px-2' }, + large: { textClass: 'text-sm', paddingClass: 'px-2.5' }, + } + + sizes.forEach((size) => { + it(`应该正确渲染 ${size} 尺寸`, () => { + render() + const badge = screen.getByTestId('pro-badge') + expect(badge).toHaveAttribute('data-size', size) + }) + + it(`${size} 尺寸应该有正确的文字大小类`, () => { + render() + const badge = screen.getByTestId('pro-badge') + expect(badge.className).toContain(sizeExpectations[size].textClass) + }) + + it(`${size} 尺寸应该有正确的内边距类`, () => { + render() + const badge = screen.getByTestId('pro-badge') + expect(badge.className).toContain(sizeExpectations[size].paddingClass) + }) + }) + + it('不同尺寸应该产生不同的样式', () => { + const { rerender } = render() + const smallBadge = screen.getByTestId('pro-badge') + const smallClassName = smallBadge.className + + rerender() + const largeBadge = screen.getByTestId('pro-badge') + const largeClassName = largeBadge.className + + expect(smallClassName).not.toBe(largeClassName) + }) + }) + + /** + * Property 4: ProBadge 主题支持 + * *For any* 主题 (light 或 dark),ProBadge 组件必须以适当的对比度和可见性渲染 + * **Feature: free-version-ui-restrictions, Property 4: ProBadge Theme Support** + * **Validates: Requirements 4.3** + */ + describe('Property 4: ProBadge 主题支持', () => { + it('浅色主题应该有正确的阴影样式', () => { + mockUseTheme.mockReturnValue('light') + render() + const badge = screen.getByTestId('pro-badge') + expect(badge.className).toContain('bg-slate-100') + }) + + it('深色主题应该有正确的阴影样式', () => { + mockUseTheme.mockReturnValue('dark') + render() + const badge = screen.getByTestId('pro-badge') + expect(badge.className).toContain('bg-slate-800') + }) + + it('两种主题都应该有中性色背景', () => { + mockUseTheme.mockReturnValue('light') + const { rerender } = render() + let badge = screen.getByTestId('pro-badge') + expect(badge.className).toContain('bg-slate-100') + expect(badge.className).toContain('text-slate-700') + + mockUseTheme.mockReturnValue('dark') + rerender() + badge = screen.getByTestId('pro-badge') + expect(badge.className).toContain('bg-slate-800') + expect(badge.className).toContain('text-slate-200') + }) + + it('两种主题都应该有清晰文字', () => { + mockUseTheme.mockReturnValue('light') + const { rerender } = render() + let badge = screen.getByTestId('pro-badge') + expect(badge.className).toContain('text-slate-700') + + mockUseTheme.mockReturnValue('dark') + rerender() + badge = screen.getByTestId('pro-badge') + expect(badge.className).toContain('text-slate-200') + }) + }) + + describe('视觉样式', () => { + it('应该有圆角样式', () => { + render() + const badge = screen.getByTestId('pro-badge') + expect(badge.className).toContain('rounded-full') + }) + + it('应该有中性色背景', () => { + render() + const badge = screen.getByTestId('pro-badge') + expect(badge.className).toContain('bg-slate-100') + }) + + it('应该有阴影效果', () => { + render() + const badge = screen.getByTestId('pro-badge') + expect(badge.className).toContain('shadow-sm') + }) + + it('应该是 inline-flex 以便内联使用', () => { + render() + const badge = screen.getByTestId('pro-badge') + expect(badge.className).toContain('inline-flex') + }) + + it('应该有加粗字体', () => { + render() + const badge = screen.getByTestId('pro-badge') + expect(badge.className).toContain('font-semibold') + }) + }) +}) diff --git a/components/ui/__tests__/ProBadgeIntegration.test.tsx b/components/ui/__tests__/ProBadgeIntegration.test.tsx new file mode 100644 index 0000000..e92d512 --- /dev/null +++ b/components/ui/__tests__/ProBadgeIntegration.test.tsx @@ -0,0 +1,159 @@ +/** + * PRO 徽章集成测试 + * + * 测试 PRO 徽章在 RestrictedToggle 中的存在性 + * + * **Feature: free-version-ui-restrictions** + * **Property 2: 付费功能 PRO 徽章存在性** + * **Validates: Requirements 2.1, 2.2** + * + * @jest-environment jsdom + */ + +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import RestrictedToggle from '../RestrictedToggle' +import type { FeatureKey } from '@/lib/license/types' + +// Mock hooks +jest.mock('@/lib/ui-styles', () => ({ + useTheme: jest.fn(() => 'light'), + useThemeStyles: jest.fn(() => ({ + buttonStyles: { + secondary: 'mock-secondary-button', + }, + modalStyles: { + overlay: 'mock-overlay', + container: 'mock-container', + content: 'mock-content', + header: 'mock-header', + title: 'mock-title', + closeButton: 'mock-close-button', + body: 'mock-body', + footer: 'mock-footer', + sizes: { + small: 'mock-small', + }, + }, + })), + cn: (...classes: (string | boolean | undefined)[]) => + classes.filter(Boolean).join(' '), +})) + +// Mock createPortal +jest.mock('react-dom', () => ({ + ...jest.requireActual('react-dom'), + createPortal: (node: React.ReactNode) => node, +})) + +/** + * Property 2: 付费功能 PRO 徽章存在性 + * *For any* 需要 STANDARD 或 PRO 许可证的功能,UI 必须在功能标签旁显示 PRO 徽章 + * **Feature: free-version-ui-restrictions, Property 2: PRO Badge Presence on Premium Features** + * **Validates: Requirements 2.1, 2.2** + */ +describe('Property 2: 付费功能 PRO 徽章存在性', () => { + const premiumFeatures: { key: FeatureKey; label: string }[] = [ + { key: 'customTemplates', label: '自定义模板' }, + { key: 'departmentIsolation', label: '部门隔离' }, + { key: 'intranetPenetration', label: '内网穿透' }, + { key: 'apiAccess', label: 'API 访问' }, + { key: 'systemSettings', label: '系统设置' }, + ] + + premiumFeatures.forEach(({ key, label }) => { + it(`受限的 ${label} 功能应该显示 PRO 徽章`, () => { + render( + {}} + isRestricted={true} + /> + ) + + // 应该显示 PRO 徽章 + expect(screen.getByTestId('pro-badge')).toBeInTheDocument() + expect(screen.getByText('PRO')).toBeInTheDocument() + }) + + it(`不受限的 ${label} 功能不应该显示 PRO 徽章`, () => { + render( + {}} + isRestricted={false} + /> + ) + + // 不应该显示 PRO 徽章 + expect(screen.queryByTestId('pro-badge')).not.toBeInTheDocument() + }) + }) + + it('PRO 徽章应该使用一致的视觉样式', () => { + const { rerender } = render( + {}} + isRestricted={true} + /> + ) + + const badge1 = screen.getByTestId('pro-badge') + const badge1ClassName = badge1.className + + rerender( + {}} + isRestricted={true} + /> + ) + + const badge2 = screen.getByTestId('pro-badge') + const badge2ClassName = badge2.className + + // 两个徽章应该有相同的样式类 + expect(badge1ClassName).toBe(badge2ClassName) + }) + + it('PRO 徽章应该使用中性工作台样式', () => { + render( + {}} + isRestricted={true} + /> + ) + + const badge = screen.getByTestId('pro-badge') + expect(badge.className).toContain('bg-slate-100') + expect(badge.className).toContain('text-slate-700') + expect(badge.className).not.toContain('bg-gradient-to-r') + }) + + it('PRO 徽章应该包含 PRO 文字', () => { + render( + {}} + isRestricted={true} + /> + ) + + expect(screen.getByText('PRO')).toBeInTheDocument() + }) +}) diff --git a/components/ui/__tests__/RestrictedToggle.test.tsx b/components/ui/__tests__/RestrictedToggle.test.tsx new file mode 100644 index 0000000..bae5ee4 --- /dev/null +++ b/components/ui/__tests__/RestrictedToggle.test.tsx @@ -0,0 +1,254 @@ +/** + * RestrictedToggle 组件单元测试 + * + * 测试 RestrictedToggle 组件的点击拦截和禁用状态 + * + * **Feature: free-version-ui-restrictions** + * **Property 1: 免费版点击拦截** + * **Property 6: 禁用开关视觉状态** + * **Validates: Requirements 1.1, 5.1, 5.2, 5.4** + * + * @jest-environment jsdom + */ + +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' +import RestrictedToggle from '../RestrictedToggle' +import type { FeatureKey } from '@/lib/license/types' + +// Mock useTheme hook +jest.mock('@/lib/ui-styles', () => ({ + useTheme: jest.fn(() => 'light'), + cn: (...classes: (string | boolean | undefined)[]) => + classes.filter(Boolean).join(' '), +})) + +// Mock useThemeStyles for UpgradeModal +jest.mock('@/lib/ui-styles', () => ({ + useTheme: jest.fn(() => 'light'), + useThemeStyles: jest.fn(() => ({ + buttonStyles: { + secondary: 'mock-secondary-button', + }, + modalStyles: { + overlay: 'mock-overlay', + container: 'mock-container', + content: 'mock-content', + header: 'mock-header', + title: 'mock-title', + closeButton: 'mock-close-button', + body: 'mock-body', + footer: 'mock-footer', + sizes: { + small: 'mock-small', + }, + }, + })), + cn: (...classes: (string | boolean | undefined)[]) => + classes.filter(Boolean).join(' '), +})) + +// Mock createPortal +jest.mock('react-dom', () => ({ + ...jest.requireActual('react-dom'), + createPortal: (node: React.ReactNode) => node, +})) + +describe('RestrictedToggle 组件', () => { + const mockOnChange = jest.fn() + const defaultProps = { + label: '测试功能', + featureKey: 'customTemplates' as FeatureKey, + value: false, + onChange: mockOnChange, + isRestricted: false, + } + + beforeEach(() => { + mockOnChange.mockClear() + }) + + describe('基础渲染', () => { + it('应该正确渲染标签', () => { + render() + expect(screen.getByText('测试功能')).toBeInTheDocument() + }) + + it('应该正确渲染描述', () => { + render() + expect(screen.getByText('这是功能描述')).toBeInTheDocument() + }) + + it('应该有正确的 data-testid', () => { + render() + expect(screen.getByTestId('restricted-toggle')).toBeInTheDocument() + }) + }) + + /** + * Property 1: 免费版点击拦截 + * *For any* 受限功能开关和 FREE 许可证类型,点击开关必须显示升级弹窗且不改变开关值 + * **Feature: free-version-ui-restrictions, Property 1: Click Interception for FREE License** + * **Validates: Requirements 1.1, 5.2, 5.4** + */ + describe('Property 1: 免费版点击拦截', () => { + it('受限时点击应该显示升级弹窗', () => { + render() + + const toggleButton = screen.getByTestId('toggle-button') + fireEvent.click(toggleButton) + + // 应该显示升级弹窗 + expect(screen.getByTestId('upgrade-modal')).toBeInTheDocument() + }) + + it('受限时点击不应该调用 onChange', () => { + render() + + const toggleButton = screen.getByTestId('toggle-button') + fireEvent.click(toggleButton) + + // 不应该调用 onChange + expect(mockOnChange).not.toHaveBeenCalled() + }) + + it('受限时开关值不应该改变', () => { + const { rerender } = render( + + ) + + const toggleButton = screen.getByTestId('toggle-button') + fireEvent.click(toggleButton) + + // 值应该保持不变 + expect(mockOnChange).not.toHaveBeenCalled() + + // 重新渲染确认值没变 + rerender() + expect(toggleButton).toHaveAttribute('aria-checked', 'false') + }) + + it('不受限时点击应该调用 onChange', () => { + render() + + const toggleButton = screen.getByTestId('toggle-button') + fireEvent.click(toggleButton) + + // 应该调用 onChange + expect(mockOnChange).toHaveBeenCalledWith(true) + }) + + it('不受限时点击不应该显示升级弹窗', () => { + render() + + const toggleButton = screen.getByTestId('toggle-button') + fireEvent.click(toggleButton) + + // 不应该显示升级弹窗 + expect(screen.queryByTestId('upgrade-modal')).not.toBeInTheDocument() + }) + }) + + /** + * Property 6: 禁用开关视觉状态 + * *For any* 受限功能和 FREE 许可证,开关必须以禁用视觉状态显示,具有降低的不透明度或灰色外观 + * **Feature: free-version-ui-restrictions, Property 6: Disabled Toggle Visual State** + * **Validates: Requirements 5.1** + */ + describe('Property 6: 禁用开关视觉状态', () => { + it('受限时应该显示 PRO 徽章', () => { + render() + expect(screen.getByTestId('pro-badge')).toBeInTheDocument() + }) + + it('不受限时不应该显示 PRO 徽章', () => { + render() + expect(screen.queryByTestId('pro-badge')).not.toBeInTheDocument() + }) + + it('受限时开关应该有禁用样式', () => { + render() + const toggleButton = screen.getByTestId('toggle-button') + expect(toggleButton.className).toContain('opacity-50') + expect(toggleButton.className).toContain('cursor-not-allowed') + }) + + it('不受限时开关应该有正常样式', () => { + render() + const toggleButton = screen.getByTestId('toggle-button') + expect(toggleButton.className).toContain('cursor-pointer') + expect(toggleButton.className).not.toContain('opacity-50') + }) + + it('受限时应该显示升级提示文字', () => { + render() + expect(screen.getByTestId('restricted-hint')).toBeInTheDocument() + expect(screen.getByText('升级到付费版解锁此功能')).toBeInTheDocument() + }) + + it('不受限时不应该显示升级提示文字', () => { + render() + expect(screen.queryByTestId('restricted-hint')).not.toBeInTheDocument() + }) + + it('受限时标签应该有降低的不透明度', () => { + render() + const label = screen.getByText('测试功能') + expect(label.className).toContain('opacity-60') + }) + + it('受限时 data-restricted 属性应该为 true', () => { + render() + const toggle = screen.getByTestId('restricted-toggle') + expect(toggle).toHaveAttribute('data-restricted', 'true') + }) + + it('不受限时 data-restricted 属性应该为 false', () => { + render() + const toggle = screen.getByTestId('restricted-toggle') + expect(toggle).toHaveAttribute('data-restricted', 'false') + }) + }) + + describe('开关状态', () => { + it('value 为 true 时开关应该在右侧', () => { + render() + const toggleButton = screen.getByTestId('toggle-button') + expect(toggleButton).toHaveAttribute('aria-checked', 'true') + }) + + it('value 为 false 时开关应该在左侧', () => { + render() + const toggleButton = screen.getByTestId('toggle-button') + expect(toggleButton).toHaveAttribute('aria-checked', 'false') + }) + + it('受限时 aria-disabled 应该为 true', () => { + render() + const toggleButton = screen.getByTestId('toggle-button') + expect(toggleButton).toHaveAttribute('aria-disabled', 'true') + }) + + it('不受限时 aria-disabled 应该为 false', () => { + render() + const toggleButton = screen.getByTestId('toggle-button') + expect(toggleButton).toHaveAttribute('aria-disabled', 'false') + }) + }) + + describe('升级弹窗交互', () => { + it('关闭升级弹窗后应该隐藏弹窗', () => { + render() + + // 打开弹窗 + const toggleButton = screen.getByTestId('toggle-button') + fireEvent.click(toggleButton) + expect(screen.getByTestId('upgrade-modal')).toBeInTheDocument() + + // 关闭弹窗 + fireEvent.click(screen.getByText('关闭')) + expect(screen.queryByTestId('upgrade-modal')).not.toBeInTheDocument() + }) + }) +}) diff --git a/components/ui/__tests__/UpgradeModal.test.tsx b/components/ui/__tests__/UpgradeModal.test.tsx new file mode 100644 index 0000000..2367dc5 --- /dev/null +++ b/components/ui/__tests__/UpgradeModal.test.tsx @@ -0,0 +1,226 @@ +/** + * UpgradeModal 组件单元测试 + * + * 测试 UpgradeModal 组件的功能名称显示和交互行为 + * + * **Feature: free-version-ui-restrictions** + * **Property 5: UpgradeModal 功能名称显示** + * **Validates: Requirements 1.2, 3.2, 3.3, 3.4** + * + * @jest-environment jsdom + */ + +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' +import UpgradeModal from '../UpgradeModal' +import type { FeatureKey } from '@/lib/license/types' +import { FEATURE_DESCRIPTIONS } from '@/lib/license/feature-descriptions' + +// Mock useThemeStyles hook +jest.mock('@/lib/ui-styles', () => ({ + useThemeStyles: jest.fn(() => ({ + buttonStyles: { + secondary: 'mock-secondary-button', + }, + modalStyles: { + overlay: 'mock-overlay', + container: 'mock-container', + content: 'mock-content', + header: 'mock-header', + title: 'mock-title', + closeButton: 'mock-close-button', + body: 'mock-body', + footer: 'mock-footer', + sizes: { + small: 'mock-small', + }, + }, + })), + cn: (...classes: (string | boolean | undefined)[]) => + classes.filter(Boolean).join(' '), +})) + +// Mock createPortal +jest.mock('react-dom', () => ({ + ...jest.requireActual('react-dom'), + createPortal: (node: React.ReactNode) => node, +})) + +describe('UpgradeModal 组件', () => { + const mockOnClose = jest.fn() + + beforeEach(() => { + mockOnClose.mockClear() + }) + + describe('基础渲染', () => { + it('当 isOpen 为 true 时应该渲染弹窗', () => { + render( + + ) + expect(screen.getByTestId('upgrade-modal')).toBeInTheDocument() + }) + + it('当 isOpen 为 false 时不应该渲染弹窗', () => { + render( + + ) + expect(screen.queryByTestId('upgrade-modal')).not.toBeInTheDocument() + }) + }) + + /** + * Property 5: UpgradeModal 功能名称显示 + * *For any* 传递给 UpgradeModal 的功能名称,弹窗必须在其内容中显示该功能名称 + * **Feature: free-version-ui-restrictions, Property 5: UpgradeModal Feature Name Display** + * **Validates: Requirements 1.2, 3.2** + */ + describe('Property 5: UpgradeModal 功能名称显示', () => { + const featureKeys: FeatureKey[] = [ + 'customTemplates', + 'systemSettings', + 'departmentIsolation', + 'apiAccess', + 'intranetPenetration', + ] + + featureKeys.forEach((featureKey) => { + it(`应该显示 ${featureKey} 的功能名称`, () => { + render( + + ) + const expectedName = FEATURE_DESCRIPTIONS[featureKey].name + expect(screen.getByTestId('upgrade-modal-feature-name')).toHaveTextContent(expectedName) + }) + }) + + it('应该允许自定义功能名称覆盖默认值', () => { + const customName = '自定义功能名称' + render( + + ) + expect(screen.getByTestId('upgrade-modal-feature-name')).toHaveTextContent(customName) + }) + + it('应该显示功能描述', () => { + render( + + ) + const expectedDescription = FEATURE_DESCRIPTIONS.customTemplates.description + expect(screen.getByText(expectedDescription)).toBeInTheDocument() + }) + + it('应该允许自定义功能描述覆盖默认值', () => { + const customDescription = '自定义功能描述' + render( + + ) + expect(screen.getByText(customDescription)).toBeInTheDocument() + }) + }) + + describe('限制提示显示', () => { + it('应该显示当前许可证限制提示', () => { + render( + + ) + expect(screen.getByText('当前许可证未包含此功能,请联系管理员处理授权')).toBeInTheDocument() + }) + }) + + describe('按钮交互', () => { + it('点击"关闭"按钮应该调用 onClose', () => { + render( + + ) + fireEvent.click(screen.getByText('关闭')) + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('点击"联系管理员"按钮应该调用 onClose', () => { + render( + + ) + fireEvent.click(screen.getByText('联系管理员')) + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('应该显示两个按钮', () => { + render( + + ) + expect(screen.getByText('关闭')).toBeInTheDocument() + expect(screen.getByText('联系管理员')).toBeInTheDocument() + }) + }) + + describe('视觉元素', () => { + it('应该显示锁图标', () => { + render( + + ) + // 检查 SVG 图标存在 + const modal = screen.getByTestId('upgrade-modal') + const svgElements = modal.querySelectorAll('svg') + expect(svgElements.length).toBeGreaterThan(0) + }) + + it('应该显示限制说明文字', () => { + render( + + ) + expect(screen.getByText('当前许可证未包含此功能,请联系管理员处理授权')).toBeInTheDocument() + }) + }) +}) diff --git a/hooks/useLicenseStatus.ts b/hooks/useLicenseStatus.ts new file mode 100644 index 0000000..3bf06fb --- /dev/null +++ b/hooks/useLicenseStatus.ts @@ -0,0 +1,109 @@ +'use client' + +/** + * useLicenseStatus Hook + * + * 获取当前许可证状态,提供功能可用性检查 + * 用于在 UI 组件中判断功能是否受限 + */ + +import { useState, useEffect, useCallback } from 'react' +import type { FeatureKey, LicenseType } from '@/lib/license/types' + +interface LicenseData { + type: LicenseType + status: 'active' | 'expired' | 'grace' + expiresAt: string | null + usage: { + users: { current: number; max: number } + materials: { current: number; max: number } + } + features: Record + lastVerified: string +} + +interface LicenseStatus { + /** 许可证类型 */ + type: LicenseType + /** 是否正在加载 */ + loading: boolean + /** 错误信息 */ + error: string | null + /** 完整的许可证数据 */ + data: LicenseData | null + /** 检查功能是否可用 */ + isFeatureEnabled: (feature: FeatureKey) => boolean + /** 是否为免费版 */ + isFree: boolean + /** 刷新许可证状态 */ + refresh: () => Promise +} + +/** + * 获取许可证状态的 Hook + */ +export function useLicenseStatus(): LicenseStatus { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchLicenseStatus = useCallback(async () => { + try { + setLoading(true) + setError(null) + + const response = await fetch('/api/license/info') + + if (!response.ok) { + throw new Error('获取许可证信息失败') + } + + const licenseData: LicenseData = await response.json() + setData(licenseData) + } catch (err) { + console.error('获取许可证状态失败:', err) + setError(err instanceof Error ? err.message : '未知错误') + // 出错时默认为免费版 + setData({ + type: 'FREE', + status: 'active', + expiresAt: null, + usage: { + users: { current: 0, max: 10 }, + materials: { current: 0, max: 100 }, + }, + features: { + customTemplates: false, + systemSettings: false, + departmentIsolation: false, + apiAccess: false, + intranetPenetration: false, + }, + lastVerified: new Date().toISOString(), + }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchLicenseStatus() + }, [fetchLicenseStatus]) + + const isFeatureEnabled = useCallback((feature: FeatureKey): boolean => { + if (!data) return false + return data.features[feature] ?? false + }, [data]) + + return { + type: data?.type ?? 'FREE', + loading, + error, + data, + isFeatureEnabled, + isFree: data?.type === 'FREE', + refresh: fetchLicenseStatus, + } +} + +export default useLicenseStatus diff --git a/lib/__tests__/check-limits.test.ts b/lib/__tests__/check-limits.test.ts index 4ff6db8..7e39c9c 100644 --- a/lib/__tests__/check-limits.test.ts +++ b/lib/__tests__/check-limits.test.ts @@ -46,7 +46,7 @@ describe('canCreateUser', () => { it('当达到限制时(FREE 版本)应该返回升级提示', async () => { mockCheckLimit.mockResolvedValue({ allowed: false, current: 10, max: 10 }); - mockGetLicenseType.mockReturnValue('FREE'); + mockGetLicenseType.mockResolvedValue('FREE'); const result = await canCreateUser(); @@ -58,7 +58,7 @@ describe('canCreateUser', () => { it('当达到限制时(非 FREE 版本)应该返回联系管理员提示', async () => { mockCheckLimit.mockResolvedValue({ allowed: false, current: 100, max: 100 }); - mockGetLicenseType.mockReturnValue('STANDARD'); + mockGetLicenseType.mockResolvedValue('STANDARD'); const result = await canCreateUser(); @@ -86,7 +86,7 @@ describe('canUploadMaterial', () => { it('当达到限制时(FREE 版本)应该返回升级提示', async () => { mockCheckLimit.mockResolvedValue({ allowed: false, current: 100, max: 100 }); - mockGetLicenseType.mockReturnValue('FREE'); + mockGetLicenseType.mockResolvedValue('FREE'); const result = await canUploadMaterial(); @@ -98,7 +98,7 @@ describe('canUploadMaterial', () => { it('当达到限制时(非 FREE 版本)应该返回联系管理员提示', async () => { mockCheckLimit.mockResolvedValue({ allowed: false, current: 1000, max: 1000 }); - mockGetLicenseType.mockReturnValue('PRO'); + mockGetLicenseType.mockResolvedValue('PRO'); const result = await canUploadMaterial(); @@ -113,67 +113,67 @@ describe('功能开关检查', () => { }); describe('canUseCustomTemplates', () => { - it('当功能启用时应该返回 true', () => { - mockIsFeatureEnabled.mockReturnValue(true); - expect(canUseCustomTemplates()).toBe(true); + it('当功能启用时应该返回 true', async () => { + mockIsFeatureEnabled.mockResolvedValue(true); + expect(await canUseCustomTemplates()).toBe(true); expect(mockIsFeatureEnabled).toHaveBeenCalledWith('customTemplates'); }); - it('当功能禁用时应该返回 false', () => { - mockIsFeatureEnabled.mockReturnValue(false); - expect(canUseCustomTemplates()).toBe(false); + it('当功能禁用时应该返回 false', async () => { + mockIsFeatureEnabled.mockResolvedValue(false); + expect(await canUseCustomTemplates()).toBe(false); }); }); describe('canUseDepartmentIsolation', () => { - it('当功能启用时应该返回 true', () => { - mockIsFeatureEnabled.mockReturnValue(true); - expect(canUseDepartmentIsolation()).toBe(true); + it('当功能启用时应该返回 true', async () => { + mockIsFeatureEnabled.mockResolvedValue(true); + expect(await canUseDepartmentIsolation()).toBe(true); expect(mockIsFeatureEnabled).toHaveBeenCalledWith('departmentIsolation'); }); - it('当功能禁用时应该返回 false', () => { - mockIsFeatureEnabled.mockReturnValue(false); - expect(canUseDepartmentIsolation()).toBe(false); + it('当功能禁用时应该返回 false', async () => { + mockIsFeatureEnabled.mockResolvedValue(false); + expect(await canUseDepartmentIsolation()).toBe(false); }); }); describe('canUseIntranetPenetration', () => { - it('当功能启用时应该返回 true', () => { - mockIsFeatureEnabled.mockReturnValue(true); - expect(canUseIntranetPenetration()).toBe(true); + it('当功能启用时应该返回 true', async () => { + mockIsFeatureEnabled.mockResolvedValue(true); + expect(await canUseIntranetPenetration()).toBe(true); expect(mockIsFeatureEnabled).toHaveBeenCalledWith('intranetPenetration'); }); - it('当功能禁用时应该返回 false', () => { - mockIsFeatureEnabled.mockReturnValue(false); - expect(canUseIntranetPenetration()).toBe(false); + it('当功能禁用时应该返回 false', async () => { + mockIsFeatureEnabled.mockResolvedValue(false); + expect(await canUseIntranetPenetration()).toBe(false); }); }); describe('canUseSystemSettings', () => { - it('当功能启用时应该返回 true', () => { - mockIsFeatureEnabled.mockReturnValue(true); - expect(canUseSystemSettings()).toBe(true); + it('当功能启用时应该返回 true', async () => { + mockIsFeatureEnabled.mockResolvedValue(true); + expect(await canUseSystemSettings()).toBe(true); expect(mockIsFeatureEnabled).toHaveBeenCalledWith('systemSettings'); }); - it('当功能禁用时应该返回 false', () => { - mockIsFeatureEnabled.mockReturnValue(false); - expect(canUseSystemSettings()).toBe(false); + it('当功能禁用时应该返回 false', async () => { + mockIsFeatureEnabled.mockResolvedValue(false); + expect(await canUseSystemSettings()).toBe(false); }); }); describe('canUseApiAccess', () => { - it('当功能启用时应该返回 true', () => { - mockIsFeatureEnabled.mockReturnValue(true); - expect(canUseApiAccess()).toBe(true); + it('当功能启用时应该返回 true', async () => { + mockIsFeatureEnabled.mockResolvedValue(true); + expect(await canUseApiAccess()).toBe(true); expect(mockIsFeatureEnabled).toHaveBeenCalledWith('apiAccess'); }); - it('当功能禁用时应该返回 false', () => { - mockIsFeatureEnabled.mockReturnValue(false); - expect(canUseApiAccess()).toBe(false); + it('当功能禁用时应该返回 false', async () => { + mockIsFeatureEnabled.mockResolvedValue(false); + expect(await canUseApiAccess()).toBe(false); }); }); }); diff --git a/lib/license/check-limits.ts b/lib/license/check-limits.ts index beac9a8..d7107ce 100644 --- a/lib/license/check-limits.ts +++ b/lib/license/check-limits.ts @@ -19,7 +19,7 @@ export async function canCreateUser(): Promise { const result = await checkLimit('users'); if (!result.allowed) { - const licenseType = getLicenseType(); + const licenseType = await getLicenseType(); return { ...result, message: @@ -39,7 +39,7 @@ export async function canUploadMaterial(): Promise { const result = await checkLimit('materials'); if (!result.allowed) { - const licenseType = getLicenseType(); + const licenseType = await getLicenseType(); return { ...result, message: @@ -55,34 +55,34 @@ export async function canUploadMaterial(): Promise { /** * 检查自定义模板功能是否可用 */ -export function canUseCustomTemplates(): boolean { - return isFeatureEnabled('customTemplates'); +export async function canUseCustomTemplates(): Promise { + return await isFeatureEnabled('customTemplates'); } /** * 检查部门隔离功能是否可用 */ -export function canUseDepartmentIsolation(): boolean { - return isFeatureEnabled('departmentIsolation'); +export async function canUseDepartmentIsolation(): Promise { + return await isFeatureEnabled('departmentIsolation'); } /** * 检查内网穿透功能是否可用 */ -export function canUseIntranetPenetration(): boolean { - return isFeatureEnabled('intranetPenetration'); +export async function canUseIntranetPenetration(): Promise { + return await isFeatureEnabled('intranetPenetration'); } /** * 检查系统设置功能是否可用 */ -export function canUseSystemSettings(): boolean { - return isFeatureEnabled('systemSettings'); +export async function canUseSystemSettings(): Promise { + return await isFeatureEnabled('systemSettings'); } /** * 检查 API 访问功能是否可用 */ -export function canUseApiAccess(): boolean { - return isFeatureEnabled('apiAccess'); +export async function canUseApiAccess(): Promise { + return await isFeatureEnabled('apiAccess'); } diff --git a/lib/license/feature-descriptions.ts b/lib/license/feature-descriptions.ts new file mode 100644 index 0000000..ed4c4ca --- /dev/null +++ b/lib/license/feature-descriptions.ts @@ -0,0 +1,86 @@ +/** + * 功能描述映射 + * + * 定义每个付费功能的名称、描述和升级好处 + * 用于 UpgradeModal 和其他需要展示功能信息的地方 + */ + +import type { FeatureKey } from './types'; + +export interface FeatureDescription { + /** 功能名称 */ + name: string; + /** 功能描述 */ + description: string; + /** 升级好处列表 */ + benefits: string[]; +} + +/** + * 功能描述映射表 + */ +export const FEATURE_DESCRIPTIONS: Record = { + customTemplates: { + name: '自定义模板', + description: '创建和管理自定义上传模板,定制素材上传流程', + benefits: [ + '自定义字段配置', + '模板分类管理', + '批量模板操作', + '模板导入导出', + ], + }, + systemSettings: { + name: '高级系统设置', + description: '访问高级系统配置选项,自定义系统行为', + benefits: [ + '外观主题自定义', + '高级功能开关', + '系统参数调整', + '对象存储配置', + ], + }, + departmentIsolation: { + name: '部门隔离', + description: '按部门隔离素材访问权限,实现数据分离', + benefits: [ + '部门数据隔离', + '权限精细控制', + '多部门协作', + '跨部门共享管理', + ], + }, + apiAccess: { + name: 'API 访问', + description: '通过 API 接口访问系统功能,实现自动化集成', + benefits: [ + 'RESTful API 接口', + '自动化工作流', + '第三方系统对接', + '批量数据操作', + ], + }, + intranetPenetration: { + name: '内网穿透', + description: '从外网安全访问内网部署的系统', + benefits: [ + '远程安全访问', + '无需公网 IP', + '加密安全隧道', + '多设备同时访问', + ], + }, +}; + +/** + * 获取功能描述 + * @param featureKey 功能键 + * @returns 功能描述对象,如果不存在则返回默认值 + */ +export function getFeatureDescription(featureKey: FeatureKey): FeatureDescription { + return FEATURE_DESCRIPTIONS[featureKey] || { + name: '高级功能', + description: '此功能需要升级许可证才能使用', + benefits: ['解锁更多功能', '获得技术支持', '享受更新服务'], + }; +} diff --git a/lib/license/license-service.ts b/lib/license/license-service.ts index 9974867..ad2cead 100644 --- a/lib/license/license-service.ts +++ b/lib/license/license-service.ts @@ -1,6 +1,7 @@ import { prisma } from '@/lib/prisma'; import { generateFingerprint, getInstanceId } from './fingerprint-service'; import { verifyLicenseSignature, encryptLocal, decryptLocal } from './crypto-service'; +import { initializeSystem as initializeSystemCore } from '../system-init'; import type { LicenseConfig, SignedLicense, @@ -12,9 +13,6 @@ import type { } from './types'; import { LICENSE_CONFIGS } from './types'; -// 许可证服务器地址 -const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || 'http://45.249.208.118:3001'; - // 宽限期(7天) const GRACE_PERIOD_DAYS = 7; @@ -26,43 +24,97 @@ let cachedLicense: LicenseConfig | null = null; let lastVerified: Date | null = null; let heartbeatTimer: NodeJS.Timeout | null = null; +function isLicenseConfigExpired(license: LicenseConfig | null): boolean { + return Boolean(license?.expiresAt && new Date(license.expiresAt) < new Date()); +} + +function extractLicenseConfig(license: SignedLicense): LicenseConfig { + const { issuedAt: _issuedAt, ...config } = license.data; + return config; +} + +function normalizeLocalLicenseData(localData: LocalLicenseData): LocalLicenseData | null { + if ( + !localData || + typeof localData.licenseKey !== 'string' || + typeof localData.licenseData !== 'string' || + typeof localData.lastVerified !== 'string' + ) { + return null; + } + + try { + const decrypted = decryptLocal(localData.licenseData); + const signedLicense = JSON.parse(decrypted) as SignedLicense; + + if (!signedLicense?.data || !signedLicense?.signature) { + return null; + } + + if (!verifyLicenseSignature(signedLicense.data, signedLicense.signature)) { + return null; + } + + return { + ...localData, + cachedConfig: extractLicenseConfig(signedLicense), + }; + } catch (error) { + console.warn('[License] 本地许可证校验失败:', error); + return null; + } +} + +async function getCurrentLicenseConfig(): Promise { + const localLicense = await getLocalLicense(); + const currentConfig = localLicense?.cachedConfig ?? null; + + if (!localLicense || !currentConfig || isLicenseConfigExpired(currentConfig)) { + cachedLicense = null; + lastVerified = localLicense?.lastVerified ? new Date(localLicense.lastVerified) : null; + return null; + } + + cachedLicense = currentConfig; + lastVerified = new Date(localLicense.lastVerified); + return currentConfig; +} + /** * 激活许可证 + * + * ⚠️ 注意:此函数现在只用于客户端调用素材系统自己的API + * 实际的激活逻辑已移至 app/api/license/activate/route.ts(后端代理) + * + * 安全改进: + * - 客户端不再直接访问许可证服务器 + * - 硬件指纹在后端生成(更安全) + * - 许可证服务器地址完全隐藏 */ export async function activate(licenseKey: string): Promise<{ success: boolean; error?: string }> { try { - const fingerprint = await generateFingerprint(); - - const response = await fetch(`${LICENSE_SERVER_URL}/api/license/activate`, { + // ✅ 安全改进:调用素材系统自己的API(不再直接调用许可证服务器) + const response = await fetch('/api/license/activate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ licenseKey, - fingerprint, - timestamp: Date.now(), }), }); - const data: ActivateResponse = await response.json(); + const data = await response.json(); - if (!data.success || !data.license) { + if (!data.success) { return { success: false, error: data.error || '激活失败' }; } - // 验证签名 - if (!verifyLicenseSignature(data.license.data, data.license.signature)) { - return { success: false, error: '许可证签名验证失败' }; - } - - // 保存到本地 - await saveLicenseLocally(licenseKey, data.license); + // 重新加载许可证(因为后端已保存) + await loadLicenseFromLocal(); // 更新缓存 - cachedLicense = data.license.data; - lastVerified = new Date(); - - // 启动心跳 - startHeartbeat(); + if (cachedLicense) { + lastVerified = new Date(); + } return { success: true }; } catch (error) { @@ -73,78 +125,95 @@ export async function activate(licenseKey: string): Promise<{ success: boolean; /** * 激活免费试用版 + * + * ⚠️ 注意:此函数现在只用于客户端调用素材系统自己的API + * 实际的注册逻辑已移至 app/api/license/activate-free/route.ts(后端代理) + * + * 安全改进: + * - 客户端不再直接访问许可证服务器 + * - 硬件指纹在后端生成(更安全) + * - 许可证服务器地址完全隐藏 + * + * @param customerName 客户名称(用于许可证系统后台管理) + * @param contactInfo 联系方式(手机/微信/邮箱) */ -export async function activateFreeTrial(): Promise<{ success: boolean; error?: string }> { +export async function activateFreeTrial(customerName: string, contactInfo: string = ''): Promise<{ success: boolean; error?: string }> { try { - const instanceId = await getInstanceId(); - - // 创建免费版配置 - const freeConfig: LicenseConfig = { - ...LICENSE_CONFIGS.FREE, - expiresAt: null, - }; - - // 保存到本地(无需服务器验证) - const localData: LocalLicenseData = { - instanceId, - licenseKey: 'FREE-TRIAL', - licenseData: encryptLocal(JSON.stringify(freeConfig)), - lastVerified: new Date().toISOString(), - cachedConfig: freeConfig, - }; - - await prisma.systemConfig.upsert({ - where: { key: 'license' }, - update: { value: JSON.stringify(localData) }, - create: { key: 'license', value: JSON.stringify(localData) }, + // ✅ 安全改进:调用素材系统自己的API(不再直接调用许可证服务器) + const response = await fetch('/api/license/activate-free', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + customerName, + contactInfo, + }), }); + const data = await response.json(); + + if (!data.success) { + return { success: false, error: data.error || '激活失败,请检查网络连接' }; + } + + // 重新加载许可证(因为后端已保存) + await loadLicenseFromLocal(); + // 更新缓存 - cachedLicense = freeConfig; - lastVerified = new Date(); + if (cachedLicense) { + lastVerified = new Date(); + } return { success: true }; } catch (error) { console.error('免费试用激活失败:', error); - return { success: false, error: '激活失败' }; + return { success: false, error: '网络错误,请检查网络连接后重试' }; } } /** - * 验证许可证 + * 初始化系统(创建默认管理员、部门、店铺等) */ -export async function verify(): Promise<{ valid: boolean; error?: string }> { +export async function initializeSystem() { + return initializeSystemCore(); +} + +/** + * 验证许可证 + * + * ⚠️ 注意:此函数现在只用于客户端调用素材系统自己的API + * 实际的验证逻辑已移至 app/api/license/verify/route.ts(后端代理) + * + * 安全改进: + * - 客户端不再直接访问许可证服务器 + * - 硬件指纹在后端生成(更安全) + * - 许可证服务器地址完全隐藏 + */ +export async function verify(): Promise<{ valid: boolean; error?: string; revoked?: boolean }> { try { // 先检查本地缓存 const localLicense = await getLocalLicense(); - + if (!localLicense) { return { valid: false, error: '未找到许可证' }; } - // 免费试用版不需要在线验证 - if (localLicense.licenseKey === 'FREE-TRIAL') { - cachedLicense = localLicense.cachedConfig; - lastVerified = new Date(); - return { valid: true }; - } - - const fingerprint = await generateFingerprint(); - - const response = await fetch(`${LICENSE_SERVER_URL}/api/license/verify`, { + // ✅ 安全改进:调用素材系统自己的API(不再直接调用许可证服务器) + const response = await fetch('/api/license/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - instanceId: fingerprint.instanceId, - fingerprint, - timestamp: Date.now(), - }), }); - const data: VerifyResponse = await response.json(); + const data = await response.json(); - if (!data.valid || !data.license) { - // 检查宽限期 + if (!data.valid) { + // 检查是否是被吊销 + if (data.revoked) { + console.log('[License] 许可证已被吊销:', data.error); + cachedLicense = null; + return { valid: false, error: data.error || '许可证已被吊销', revoked: true }; + } + + // 其他错误,检查宽限期 if (isWithinGracePeriod(localLicense.lastVerified)) { cachedLicense = localLicense.cachedConfig; return { valid: true }; @@ -152,17 +221,13 @@ export async function verify(): Promise<{ valid: boolean; error?: string }> { return { valid: false, error: data.error || '验证失败' }; } - // 验证签名 - if (!verifyLicenseSignature(data.license.data, data.license.signature)) { - return { valid: false, error: '许可证签名验证失败' }; - } - - // 更新本地缓存 - await updateLocalLicense(data.license); + // 重新加载许可证(因为后端已更新) + await loadLicenseFromLocal(); // 更新内存缓存 - cachedLicense = data.license.data; - lastVerified = new Date(); + if (cachedLicense) { + lastVerified = new Date(); + } return { valid: true }; } catch (error) { @@ -181,44 +246,49 @@ export async function verify(): Promise<{ valid: boolean; error?: string }> { /** * 发送心跳 + * + * ⚠️ 注意:此函数现在只用于客户端调用素材系统自己的API + * 实际的心跳逻辑已移至 app/api/license/heartbeat/route.ts(后端代理) + * + * 安全改进: + * - 客户端不再直接访问许可证服务器 + * - 硬件指纹在后端生成(更安全) + * - 许可证服务器地址完全隐藏 + * + * @returns 如果许可证被吊销返回 true */ -export async function heartbeat(): Promise { +export async function heartbeat(): Promise { try { const localLicense = await getLocalLicense(); - if (!localLicense || localLicense.licenseKey === 'FREE-TRIAL') { - return; + if (!localLicense) { + return false; } - const fingerprint = await generateFingerprint(); - - // 获取使用统计 - const [userCount, materialCount] = await Promise.all([ - prisma.user.count(), - prisma.material.count(), - ]); - - const response = await fetch(`${LICENSE_SERVER_URL}/api/license/heartbeat`, { + // ✅ 安全改进:调用素材系统自己的API(不再直接调用许可证服务器) + const response = await fetch('/api/license/heartbeat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - instanceId: fingerprint.instanceId, - fingerprint, - usage: { userCount, materialCount }, - timestamp: Date.now(), - }), }); - const data: HeartbeatResponse = await response.json(); + const data = await response.json(); - if (data.acknowledged && data.license) { - // 更新本地缓存 - await updateLocalLicense(data.license); - cachedLicense = data.license.data; + // 检查是否被吊销 + if (data.revoked) { + console.log('[License] 心跳检测到许可证已被吊销'); + cachedLicense = null; + return true; // 返回 true 表示被吊销 } - lastVerified = new Date(); + if (data.acknowledged) { + // 重新加载许可证(因为后端已更新) + await loadLicenseFromLocal(); + lastVerified = new Date(); + } + + return false; } catch (error) { console.error('心跳发送失败:', error); + return false; } } @@ -226,35 +296,39 @@ export async function heartbeat(): Promise { * 获取当前许可证配置 */ export function getLicense(): LicenseConfig | null { + if (isLicenseConfigExpired(cachedLicense)) { + cachedLicense = null; + return null; + } + return cachedLicense; } /** * 检查功能是否可用 */ -export function isFeatureEnabled(feature: FeatureKey): boolean { - if (!cachedLicense) { - return false; - } - return cachedLicense.features[feature]; +export async function isFeatureEnabled(feature: FeatureKey): Promise { + const currentLicense = await getCurrentLicenseConfig(); + return Boolean(currentLicense?.features[feature]); } /** * 检查是否达到限制 */ export async function checkLimit(type: 'users' | 'materials'): Promise<{ allowed: boolean; current: number; max: number }> { - if (!cachedLicense) { + const currentLicense = await getCurrentLicenseConfig(); + if (!currentLicense) { return { allowed: false, current: 0, max: 0 }; } - const max = type === 'users' ? cachedLicense.limits.maxUsers : cachedLicense.limits.maxMaterials; - + const max = type === 'users' ? currentLicense.limits.maxUsers : currentLicense.limits.maxMaterials; + // -1 表示无限制 if (max === -1) { return { allowed: true, current: 0, max: -1 }; } - const current = type === 'users' + const current = type === 'users' ? await prisma.user.count() : await prisma.material.count(); @@ -268,18 +342,16 @@ export async function checkLimit(type: 'users' | 'materials'): Promise<{ allowed /** * 获取许可证类型 */ -export function getLicenseType(): string { - return cachedLicense?.type || 'FREE'; +export async function getLicenseType(): Promise { + const currentLicense = await getCurrentLicenseConfig(); + return currentLicense?.type || 'FREE'; } /** * 检查许可证是否已过期 */ export function isExpired(): boolean { - if (!cachedLicense || !cachedLicense.expiresAt) { - return false; - } - return new Date(cachedLicense.expiresAt) < new Date(); + return isLicenseConfigExpired(cachedLicense); } /** @@ -314,15 +386,16 @@ export async function initLicenseService(): Promise { // ============ 内部辅助函数 ============ -async function saveLicenseLocally(licenseKey: string, license: SignedLicense): Promise { +export async function saveLicenseLocally(licenseKey: string, license: SignedLicense): Promise { const instanceId = await getInstanceId(); - + const currentConfig = extractLicenseConfig(license); + const localData: LocalLicenseData = { instanceId, licenseKey, licenseData: encryptLocal(JSON.stringify(license)), lastVerified: new Date().toISOString(), - cachedConfig: license.data, + cachedConfig: currentConfig, }; await prisma.systemConfig.upsert({ @@ -330,26 +403,48 @@ async function saveLicenseLocally(licenseKey: string, license: SignedLicense): P update: { value: JSON.stringify(localData) }, create: { key: 'license', value: JSON.stringify(localData) }, }); + + cachedLicense = isLicenseConfigExpired(currentConfig) ? null : currentConfig; + lastVerified = new Date(localData.lastVerified); } -async function updateLocalLicense(license: SignedLicense): Promise { +export async function updateLocalLicense(license: SignedLicense): Promise { const existing = await getLocalLicense(); if (!existing) return; + const currentConfig = extractLicenseConfig(license); + const localData: LocalLicenseData = { ...existing, licenseData: encryptLocal(JSON.stringify(license)), lastVerified: new Date().toISOString(), - cachedConfig: license.data, + cachedConfig: currentConfig, }; await prisma.systemConfig.update({ where: { key: 'license' }, data: { value: JSON.stringify(localData) }, }); + + cachedLicense = isLicenseConfigExpired(currentConfig) ? null : currentConfig; + lastVerified = new Date(localData.lastVerified); } -async function getLocalLicense(): Promise { +/** + * 从本地加载许可证并更新内存缓存 + */ +async function loadLicenseFromLocal(): Promise { + const localLicense = await getLocalLicense(); + if (localLicense) { + cachedLicense = localLicense.cachedConfig; + lastVerified = new Date(localLicense.lastVerified); + } else { + cachedLicense = null; + lastVerified = null; + } +} + +export async function getLocalLicense(): Promise { try { const config = await prisma.systemConfig.findUnique({ where: { key: 'license' }, @@ -357,12 +452,27 @@ async function getLocalLicense(): Promise { if (!config) return null; - return JSON.parse(config.value) as LocalLicenseData; + const parsed = JSON.parse(config.value) as LocalLicenseData; + return normalizeLocalLicenseData(parsed); } catch { return null; } } +/** + * 清除本地许可证缓存(许可证被吊销时调用) + */ +export async function clearLocalLicense(): Promise { + try { + await prisma.systemConfig.delete({ + where: { key: 'license' }, + }); + console.log('[License] 本地许可证缓存已清除'); + } catch (error) { + console.error('[License] 清除本地许可证缓存失败:', error); + } +} + function isWithinGracePeriod(lastVerifiedStr: string): boolean { const lastVerified = new Date(lastVerifiedStr); const gracePeriodEnd = new Date(lastVerified.getTime() + GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000); diff --git a/lib/upload-template-presets.ts b/lib/upload-template-presets.ts index 6e7922c..949d27a 100644 --- a/lib/upload-template-presets.ts +++ b/lib/upload-template-presets.ts @@ -144,7 +144,7 @@ export const STANDARD_FIELDS: Record = { }, audioScene: { key: 'audioScene', - label: '使用场景', + label: '音频使用场景', type: 'select', required: true, layoutWidth: 'full',