完善许可证受限入口与外观管理

This commit is contained in:
WuYongJie 2026-05-25 14:52:24 +08:00
parent ab29682ada
commit 6b4ba0f861
34 changed files with 3817 additions and 742 deletions

View File

@ -19,15 +19,56 @@ 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<string, any>) => {
(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(<ActivatePage />);
});
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(<ActivatePage />);
it('应该正确渲染页面标题和表单', async () => {
await renderPage();
expect(screen.getByText('FLNXI 素材系统')).toBeInTheDocument();
expect(screen.getByText('请激活您的许可证')).toBeInTheDocument();
@ -36,44 +77,44 @@ describe('ActivatePage', () => {
expect(screen.getByText('免费试用')).toBeInTheDocument();
});
it('应该显示免费版限制说明', () => {
render(<ActivatePage />);
it('应该显示免费版限制说明', async () => {
await renderPage();
expect(screen.getByText('免费版限制:10 个用户、100 个素材')).toBeInTheDocument();
});
it('应该显示联系信息', () => {
render(<ActivatePage />);
it('应该显示联系信息', async () => {
await renderPage();
expect(screen.getByText('需要购买许可证?请联系我们')).toBeInTheDocument();
});
});
describe('许可证密钥格式化', () => {
it('应该自动将输入转换为大写', () => {
render(<ActivatePage />);
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(<ActivatePage />);
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(<ActivatePage />);
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(<ActivatePage />);
it('应该限制最大长度为19个字符(含横杠)', async () => {
await renderPage();
const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678EXTRA' } });
@ -82,14 +123,14 @@ describe('ActivatePage', () => {
});
describe('激活按钮状态', () => {
it('密钥不完整时激活按钮应该禁用', () => {
render(<ActivatePage />);
it('密钥不完整时激活按钮应该禁用', async () => {
await renderPage();
const button = screen.getByText('激活许可证');
expect(button).toBeDisabled();
});
it('密钥完整时激活按钮应该启用', () => {
render(<ActivatePage />);
it('密钥完整时激活按钮应该启用', async () => {
await renderPage();
const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX');
fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } });
@ -100,12 +141,21 @@ describe('ActivatePage', () => {
});
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(<ActivatePage />);
await renderPage();
const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX');
fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } });
@ -115,16 +165,57 @@ describe('ActivatePage', () => {
});
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(<ActivatePage />);
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' } });
@ -139,9 +230,12 @@ 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(<ActivatePage />);
await renderPage();
const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX');
fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } });
@ -157,11 +251,15 @@ 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(<ActivatePage />);
await renderPage();
const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX');
fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } });
@ -174,56 +272,233 @@ describe('ActivatePage', () => {
// 清理
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(<ActivatePage />);
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(<ActivatePage />);
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(<ActivatePage />);
await renderPage();
const input = screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX');
fireEvent.change(input, { target: { value: 'ABCD1234EFGH5678' } });

View File

@ -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 (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex items-center justify-center p-4">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-slate-700 dark:border-slate-300 mb-4"></div>
<p className="text-slate-600 dark:text-slate-400">正在检查许可证状态...</p>
</div>
</div>
);
}
// 激活成功页面
if (activated) {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 shadow-sm p-6">
<div className="text-center mb-6">
<div className="w-10 h-10 bg-emerald-50 dark:bg-emerald-900/20 rounded-full flex items-center justify-center mx-auto mb-4 border border-emerald-100 dark:border-emerald-900/40">
<svg className="w-5 h-5 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-xl font-semibold text-slate-900 dark:text-white">激活成功</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-2">系统已准备就绪</p>
</div>
{initialAdmin ? (
<>
<div className="bg-slate-50 dark:bg-slate-800/60 border border-slate-200 dark:border-slate-700 rounded-lg p-4 mb-6">
<h3 className="text-sm font-medium text-slate-800 dark:text-slate-200 mb-3">初始管理员账号</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between gap-3">
<span className="text-slate-600 dark:text-slate-400">邮箱:</span>
<span className="font-mono font-medium text-slate-900 dark:text-white break-all text-right">{initialAdmin.email}</span>
</div>
{initialAdmin.password ? (
<div className="flex justify-between gap-3">
<span className="text-slate-600 dark:text-slate-400">密码:</span>
<span className="font-mono font-medium text-slate-900 dark:text-white break-all text-right">{initialAdmin.password}</span>
</div>
) : initialAdmin.passwordSource === 'environment' ? (
<p className="text-slate-700 dark:text-slate-300">初始密码已由部署配置提供,系统不会在页面或日志中回显明文。</p>
) : (
<p className="text-slate-700 dark:text-slate-300">管理员账号已存在,请使用现有管理员密码登录。</p>
)}
</div>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-100 dark:border-amber-800 rounded-lg p-3 mb-6">
<p className="text-xs text-amber-700 dark:text-amber-400">
{initialAdmin.password
? '初始密码只在当前页面展示一次,首次登录后将强制修改密码。'
: '首次登录后将强制修改密码。'}
</p>
</div>
</>
) : (
<div className="bg-slate-50 dark:bg-slate-800/60 border border-slate-200 dark:border-slate-700 rounded-lg p-4 mb-6">
<p className="text-sm text-slate-700 dark:text-slate-300">系统已激活,请使用现有管理员账号登录。</p>
</div>
)}
<button
onClick={() => router.push('/login')}
className="w-full py-3 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 rounded-lg font-medium hover:bg-slate-800 dark:hover:bg-white transition-colors"
>
前往登录
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-lg shadow-lg p-8">
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 shadow-sm p-6">
{/* Logo */}
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">FLNXI 素材系统</h1>
<p className="text-gray-500 mt-2">请激活您的许可证</p>
<h1 className="text-xl font-semibold text-slate-900 dark:text-white">FLNXI 素材系统</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-2">请激活您的许可证</p>
</div>
{/* 许可证输入 */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
许可证密钥
</label>
<input
@ -99,7 +251,7 @@ export default function ActivatePage() {
value={licenseKey}
onChange={handleKeyChange}
placeholder="XXXX-XXXX-XXXX-XXXX"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-center text-lg tracking-wider font-mono"
className="w-full px-4 py-3 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 text-center text-base font-mono bg-white dark:bg-slate-800 text-slate-900 dark:text-white"
maxLength={19}
disabled={loading}
/>
@ -116,38 +268,96 @@ export default function ActivatePage() {
<button
onClick={handleActivate}
disabled={loading || licenseKey.length !== 19}
className="w-full py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
className="w-full py-3 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 rounded-lg font-medium hover:bg-slate-800 dark:hover:bg-white disabled:bg-slate-300 dark:disabled:bg-slate-700 disabled:cursor-not-allowed transition-colors"
>
{loading ? '激活中...' : '激活许可证'}
</button>
{/* 分隔线 */}
<div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-200"></div>
<span className="px-4 text-sm text-gray-400">或</span>
<div className="flex-1 border-t border-gray-200"></div>
<div className="flex-1 border-t border-slate-200 dark:border-slate-700"></div>
<span className="px-4 text-sm text-slate-400">或</span>
<div className="flex-1 border-t border-slate-200 dark:border-slate-700"></div>
</div>
{/* 免费试用 */}
<button
onClick={handleFreeTrial}
onClick={openFreeTrialModal}
disabled={loading}
className="w-full py-3 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 disabled:cursor-not-allowed transition-colors"
className="w-full py-3 border border-slate-300 dark:border-slate-700 text-slate-700 dark:text-slate-300 rounded-lg font-medium hover:bg-slate-50 dark:hover:bg-slate-800 disabled:cursor-not-allowed transition-colors"
>
免费试用
</button>
{/* 免费版说明 */}
<p className="mt-4 text-center text-xs text-gray-400">
<p className="mt-4 text-center text-xs text-slate-400">
免费版限制:10 个用户、100 个素材
</p>
</div>
{/* 联系信息 */}
<p className="mt-6 text-center text-sm text-gray-500">
<p className="mt-6 text-center text-sm text-slate-500">
需要购买许可证?请联系我们
</p>
</div>
{/* 免费试用弹窗 */}
{showFreeTrialModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<form
className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 shadow-sm p-6 w-full max-w-sm mx-4"
onSubmit={(e) => {
e.preventDefault();
handleFreeTrial();
}}
>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">申请免费试用</h3>
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
客户名称 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={customerName}
onChange={(e) => 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
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
联系方式 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={contactInfo}
onChange={(e) => 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
/>
<p className="mt-1 text-xs text-slate-500">用于许可证管理和售后服务</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowFreeTrialModal(false)}
className="flex-1 py-2 border border-slate-300 dark:border-slate-700 text-slate-700 dark:text-slate-300 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
>
取消
</button>
<button
onClick={handleFreeTrial}
disabled={!customerName.trim() || !contactInfo.trim()}
className="flex-1 py-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 rounded-lg hover:bg-slate-800 dark:hover:bg-white disabled:bg-slate-300 dark:disabled:bg-slate-700 disabled:cursor-not-allowed transition-colors"
>
确认申请
</button>
</div>
</form>
</div>
)}
</div>
);
}

View File

@ -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<ToastState | null>(null)
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
const [heroGlowPos, setHeroGlowPos] = useState({ x: 50, y: 50 })
const [activeNav, setActiveNav] = useState<SectionKey>('brand')
const [visibleSections, setVisibleSections] = useState<Record<SectionKey, boolean>>({
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<HTMLDivElement | null>(null)
const logoRef = useRef<HTMLDivElement | null>(null)
const toggleRef = useRef<HTMLDivElement | null>(null)
const storageRef = useRef<HTMLDivElement | null>(null)
const navCardRef = useRef<HTMLDivElement | null>(null)
const [navCardHeight, setNavCardHeight] = useState(0)
const sectionRefs: Record<(typeof sectionConfig)[number]['key'], RefObject<HTMLDivElement>> = 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 {
@ -297,49 +235,13 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
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<HTMLDivElement>) => {
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,23 +249,6 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
)
// 更精致的光晕效果 - 使用更相近的颜色,减少数量
const floatingOrbs = (
<>
{/* 精致光球 - 右上角 - 使用相近的蓝色系 */}
<div className={cn('absolute -top-8 -right-8 h-24 w-24 rounded-full blur-3xl pointer-events-none animate-pulse-slow', theme === 'dark' ? 'bg-indigo-500/15' : 'bg-indigo-400/20')} />
{/* 精致光球 - 左下角 - 使用相近的紫色系 */}
<div className={cn('absolute -bottom-8 -left-8 h-20 w-20 rounded-full blur-3xl pointer-events-none animate-[pulse_8s_ease-in-out_infinite]', theme === 'dark' ? 'bg-purple-500/15' : 'bg-purple-400/20')} style={{ animationDelay: '2s' }} />
</>
)
const staggerDelays: Record<SectionKey, string> = {
brand: 'delay-[120ms]',
logos: 'delay-[220ms]',
toggles: 'delay-[320ms]',
storage: 'delay-[420ms]',
}
if (!sessionUser || sessionUser.role !== 'ADMIN') {
return (
<div className={cn(
@ -390,24 +275,20 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
<div className="space-y-8">
<section
className={cn(
'relative overflow-hidden rounded-3xl border',
'rounded-2xl border p-5',
theme === 'dark'
? 'border-slate-600/60 text-white shadow-[0_28px_60px_rgba(139,92,246,0.2)] hover:shadow-[0_35px_65px_rgba(139,92,246,0.3)]'
: 'border-slate-200/70 text-slate-900 shadow-[0_28px_60px_rgba(15,23,42,0.28)] hover:shadow-[0_35px_65px_rgba(15,23,42,0.35)]',
'transition-all duration-500 p-6',
glowEffects.glowHoverRainbow
? 'border-slate-700 bg-slate-900 text-white'
: 'border-slate-200 bg-white text-slate-900',
)}
onMouseMove={handleHeroMouseMove}
onMouseLeave={handleHeroMouseLeave}
style={heroDynamicBackground}
>
{floatingOrbs}
<div className="relative z-10 flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3 max-w-2xl">
<p className={cn('text-sm font-semibold tracking-[0.2em] uppercase', textAnimations.gradientText)}>Appearance Studio</p>
<h2 className={cn('text-2xl font-semibold', textAnimations.gradientTextRainbow)}>外观设置工作台</h2>
<p className={cn('text-xs font-medium text-slate-500', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
外观
</p>
<h2 className={cn('text-xl font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>品牌、标识与系统开关</h2>
<p className={cn('text-sm leading-relaxed', theme === 'dark' ? 'text-slate-100/90' : 'text-slate-700/90')}>
沿用模板管理的分区式结构,这里集中处理品牌主题、LOGO、功能开关与对象存储。每次保存都会即时同步到后台界面与素材上传入口。
这里集中处理品牌主题、LOGO、功能开关与对象存储。保存后会同步到后台界面与素材上传入口。
</p>
{lastSavedAt && (
<p className={cn('text-xs', theme === 'dark' ? 'text-slate-300' : 'text-slate-600')}>
@ -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
</section>
<div className="grid gap-6 xl:grid-cols-[320px,1fr]">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 xl:sticky xl:top-24 xl:self-start xl:max-h-[calc(100vh-6rem)] xl:overflow-y-auto xl:[&::-webkit-scrollbar]:hidden xl:[-ms-overflow-style:none] xl:[scrollbar-width:none]">
<div
id="nav-card"
ref={navCardRef}
className={cn(styles.cardStyles.base, 'p-5 backdrop-blur-sm flex flex-col sticky top-24 max-h-[calc(100vh-8rem)]')}>
<p className={cn('text-xs font-semibold tracking-[0.3em] uppercase mb-4 flex-shrink-0', theme === 'dark' ? 'text-slate-200' : 'text-slate-500')}>
className={cn(styles.cardStyles.base, 'p-5 flex flex-col')}>
<p className={cn('text-xs font-medium text-slate-500 mb-4 flex-shrink-0', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
导航
</p>
<div className="space-y-3 flex-1 min-h-0 overflow-y-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<div className="space-y-3">
{sectionConfig.map((section) => (
<button
key={section.key}
@ -505,28 +384,21 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
id="status-card"
className={cn(
styles.cardStyles.base,
'p-5 space-y-4 backdrop-blur-sm flex flex-col',
'sticky',
theme === 'dark'
? 'bg-gradient-to-br from-slate-900 to-slate-800'
: 'bg-gradient-to-br from-white to-slate-50'
'p-5 space-y-4 flex flex-col'
)}
style={{
top: navCardHeight > 0 ? `calc(6rem + ${navCardHeight}px + 1.5rem)` : 'calc(6rem + 1.5rem)'
}}
>
<div>
<p className="text-xs font-semibold text-slate-500 tracking-[0.3em] uppercase">状态</p>
<p className={cn('text-lg font-semibold mt-1', theme === 'dark' ? 'text-white' : 'text-slate-900')}>当前主题色</p>
<p className="text-xs font-medium text-slate-500">状态概览</p>
<p className={cn('text-base font-semibold mt-1', theme === 'dark' ? 'text-white' : 'text-slate-900')}>当前主题色</p>
</div>
<div className={cn(
'w-full h-24 rounded-2xl border shadow-inner',
theme === 'dark' ? 'border-slate-700/60' : 'border-slate-200/50'
'w-full h-24 rounded-xl border shadow-inner',
theme === 'dark' ? 'border-slate-700' : 'border-slate-200'
)} style={{ backgroundColor: headerBgColor }}>
<div className="flex items-center justify-between h-full px-4 text-white/90">
<div>
<p className="text-sm font-semibold">{siteName}</p>
<p className="text-xs opacity-80 tracking-widest uppercase">{headerBgColor}</p>
<p className="text-xs opacity-80 font-mono">{headerBgColor}</p>
</div>
<span className={cn(
'text-xs font-semibold px-3 py-1 rounded-full',
@ -550,13 +422,11 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
className={cn(
styles.cardStyles.large,
'space-y-6',
sectionRevealClass('brand'),
staggerDelays.brand
)}
>
<header className="space-y-1">
<p className="text-xs font-semibold text-indigo-500 tracking-[0.4em] uppercase">Section 01</p>
<h3 className={cn('text-xl font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>品牌与主题</h3>
<p className="text-xs font-medium text-slate-500">品牌</p>
<h3 className={cn('text-lg font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>品牌与主题</h3>
<p className={cn('text-sm', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>同步修改站点名称以及顶部导航栏主色。</p>
</header>
<div className="grid gap-6 lg:grid-cols-2">
@ -576,7 +446,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
<div className="flex items-center gap-3">
<label
className={cn(
'relative w-16 h-16 border rounded-none shadow-inner cursor-pointer',
'relative w-16 h-16 border rounded-xl shadow-inner cursor-pointer',
theme === 'dark' ? 'border-slate-600 shadow-black/60' : 'border-slate-200 shadow-black/10'
)}
style={{ backgroundColor: headerBgColor }}
@ -602,7 +472,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
key={color}
onClick={() => setHeaderBgColor(color)}
type="button"
className={`w-10 h-10 rounded-none border ${headerBgColor === color ? 'border-white/80 ring-2 ring-indigo-400 shadow-lg shadow-indigo-500/40' : 'border-white/20'} transition-all`}
className={`w-10 h-10 rounded-lg border ${headerBgColor === color ? 'border-white/80 ring-2 ring-slate-300 shadow-sm' : 'border-white/20'} transition-colors`}
style={{ backgroundColor: color }}
/>
))}
@ -617,14 +487,12 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
className={cn(
styles.cardStyles.large,
'space-y-6',
sectionRevealClass('logos'),
staggerDelays.logos
)}
>
<header className="space-y-1">
<p className="text-xs font-semibold text-indigo-500 tracking-[0.4em] uppercase">Section 02</p>
<h3 className={cn('text-xl font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>LOGO 管理</h3>
<p className={cn('text-sm', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>为深色导航与浅色登录界面分别上传 LOGO。</p>
<p className="text-xs font-medium text-slate-500">标识</p>
<h3 className={cn('text-lg font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>标识管理</h3>
<p className={cn('text-sm', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>为导航栏和登录页分别上传标识。</p>
</header>
<div className="grid gap-6 lg:grid-cols-2">
{[
@ -717,13 +585,11 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
className={cn(
styles.cardStyles.large,
'space-y-6',
sectionRevealClass('toggles'),
staggerDelays.toggles
)}
>
<header className="space-y-1">
<p className="text-xs font-semibold text-indigo-500 tracking-[0.4em] uppercase">Section 03</p>
<h3 className={cn('text-xl font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>功能开关</h3>
<p className="text-xs font-medium text-slate-500">开关</p>
<h3 className={cn('text-lg font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>功能开关</h3>
<p className={cn('text-sm', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>敏感功能仅限超级管理员调整,其他开关即时生效。</p>
</header>
<div className="space-y-4">
@ -750,32 +616,26 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
disabled: false,
},
{
title: 'AI 改图能力(预留)',
desc: '控制素材详情页的 AI 改图入口(功能预留)。',
title: '图片编辑能力(预留)',
desc: '控制素材详情页的图片编辑入口(功能预留)。',
value: enableAiImageEdit,
onChange: setEnableAiImageEdit,
disabled: !sessionUser?.isSuperAdmin,
},
{
title: '付费内网穿透',
desc: '接入第三方服务以支持公网访问(功能预留)。',
value: enableTunnelService,
onChange: setEnableTunnelService,
disabled: !sessionUser?.isSuperAdmin,
},
].map((item) => (
<div key={item.title} className={cn(
'flex items-center justify-between gap-4 rounded-2xl border px-4 py-3',
theme === 'dark' ? 'border-slate-700' : 'border-slate-200'
theme === 'dark' ? 'border-slate-700' : 'border-slate-200',
item.disabled && 'opacity-60'
)}>
<div>
<div className={cn(item.disabled && 'opacity-80')}>
<p className={cn('text-sm font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>{item.title}</p>
<p className={cn('text-xs mt-1', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>{item.desc}</p>
{item.disabled && (
<p className="text-[10px] text-amber-500/90 mt-1">仅超级管理员可修改</p>
)}
</div>
<label className={`relative inline-flex items-center ${item.disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
<label className={`relative inline-flex items-center ${item.disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
<input
type="checkbox"
className="sr-only peer"
@ -784,10 +644,13 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
disabled={item.disabled}
/>
<div className={cn(
'w-11 h-6 peer-focus:outline-none peer-focus:ring-4 rounded-full peer peer-checked:after:translate-x-full after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600',
'w-11 h-6 peer-focus:outline-none peer-focus:ring-4 rounded-full peer peer-checked:after:translate-x-full after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:rounded-full after:h-5 after:w-5 after:transition-all',
item.disabled
? 'peer-checked:bg-slate-400 dark:peer-checked:bg-slate-600 bg-slate-300 dark:bg-slate-700'
: 'peer-checked:bg-indigo-600',
theme === 'dark'
? 'bg-slate-700 peer-focus:ring-indigo-800'
: 'bg-slate-200 peer-focus:ring-indigo-200'
? !item.disabled && 'bg-slate-700 peer-focus:ring-indigo-800'
: !item.disabled && 'bg-slate-200 peer-focus:ring-indigo-200'
)}></div>
</label>
</div>
@ -832,6 +695,21 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
)}
</div>
</div>
{/* 付费功能开关 - 使用 RestrictedToggle */}
<div className={cn(
'rounded-2xl border px-4',
theme === 'dark' ? 'border-slate-700' : 'border-slate-200'
)}>
<RestrictedToggle
label="付费内网穿透"
featureKey="intranetPenetration"
value={enableTunnelService}
onChange={setEnableTunnelService}
description="接入第三方服务以支持公网访问"
isRestricted={isFree || !isFeatureEnabled('intranetPenetration')}
/>
</div>
</div>
</div>
@ -841,15 +719,68 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
className={cn(
styles.cardStyles.large,
'space-y-6',
sectionRevealClass('storage'),
staggerDelays.storage
)}
>
<header className="space-y-1">
<p className="text-xs font-semibold text-indigo-500 tracking-[0.4em] uppercase">Section 04</p>
<h3 className={cn('text-xl font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>对象存储配置</h3>
<p className={cn('text-sm', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>用于托管上传素材与 LOGO,可对接 S3 / MinIO。</p>
<header className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-xs font-medium text-slate-500">存储</p>
<h3 className={cn('text-lg font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>对象存储配置</h3>
<p className={cn('text-sm', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>用于托管上传素材与 LOGO,可对接 S3 / MinIO。</p>
</div>
{/* 修改配置按钮 */}
{sessionUser?.isSuperAdmin && !storageEditEnabled && (
<button
type="button"
onClick={() => setShowStorageConfirmModal(true)}
className={cn(
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400',
'hover:bg-amber-200 dark:hover:bg-amber-900/50',
'border border-amber-200 dark:border-amber-800'
)}
>
修改配置
</button>
)}
{storageEditEnabled && (
<button
type="button"
onClick={() => setStorageEditEnabled(false)}
className={cn(
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300',
'hover:bg-slate-200 dark:hover:bg-slate-600'
)}
>
锁定配置
</button>
)}
</header>
{/* 锁定状态提示 */}
{!storageEditEnabled && (
<div className={cn(
'rounded-2xl border p-4',
theme === 'dark'
? 'border-amber-900/60 bg-amber-900/20'
: 'border-amber-100 bg-amber-50/80'
)}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<p className={cn('text-sm font-semibold', theme === 'dark' ? 'text-amber-300' : 'text-amber-700')}>配置已锁定</p>
<p className={cn('text-xs mt-0.5', theme === 'dark' ? 'text-amber-200/80' : 'text-amber-600/80')}>
对象存储是系统核心配置,修改需要超级管理员密码验证
</p>
</div>
</div>
</div>
)}
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<label className={cn('text-sm font-medium', theme === 'dark' ? 'text-slate-200' : 'text-slate-700')}>S3 端点</label>
@ -858,7 +789,8 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
value={s3Endpoint}
onChange={(e) => setS3Endpoint(e.target.value)}
placeholder="s3.example.com"
className={cn(styles.inputStyles.base, 'text-sm')}
disabled={!storageEditEnabled}
className={cn(styles.inputStyles.base, 'text-sm', !storageEditEnabled && 'opacity-60 cursor-not-allowed')}
/>
</div>
<div className="space-y-2">
@ -868,7 +800,8 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
value={s3PublicUrl}
onChange={(e) => setS3PublicUrl(e.target.value)}
placeholder="https://cdn.example.com"
className={cn(styles.inputStyles.base, 'text-sm')}
disabled={!storageEditEnabled}
className={cn(styles.inputStyles.base, 'text-sm', !storageEditEnabled && 'opacity-60 cursor-not-allowed')}
/>
</div>
</div>
@ -880,7 +813,8 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
value={s3AccessKey}
onChange={(e) => setS3AccessKey(e.target.value)}
placeholder="访问密钥"
className={cn(styles.inputStyles.base, 'text-sm font-mono')}
disabled={!storageEditEnabled}
className={cn(styles.inputStyles.base, 'text-sm font-mono', !storageEditEnabled && 'opacity-60 cursor-not-allowed')}
/>
</div>
<div className="space-y-2">
@ -890,7 +824,8 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
value={s3SecretKey}
onChange={(e) => setS3SecretKey(e.target.value)}
placeholder="安全密钥"
className={cn(styles.inputStyles.base, 'text-sm font-mono')}
disabled={!storageEditEnabled}
className={cn(styles.inputStyles.base, 'text-sm font-mono', !storageEditEnabled && 'opacity-60 cursor-not-allowed')}
/>
</div>
</div>
@ -902,7 +837,8 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
value={s3Bucket}
onChange={(e) => setS3Bucket(e.target.value)}
placeholder="uploads-bucket"
className={cn(styles.inputStyles.base, 'text-sm')}
disabled={!storageEditEnabled}
className={cn(styles.inputStyles.base, 'text-sm', !storageEditEnabled && 'opacity-60 cursor-not-allowed')}
/>
</div>
<div className="space-y-2">
@ -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')}
/>
</div>
</div>
@ -934,8 +871,13 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
</div>
</div>
{/* 对象存储修改确认弹窗 */}
<StorageEditConfirmModal
isOpen={showStorageConfirmModal}
onClose={() => setShowStorageConfirmModal(false)}
onConfirm={() => setStorageEditEnabled(true)}
/>
</div>
)
}

View File

@ -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<Set<string>>(new Set())
const [renamingDepartmentId, setRenamingDepartmentId] = useState<string | null>(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 (
<div key={node.fullName} className="space-y-1.5">
<div
className={cn(
`group flex items-center justify-between px-5 py-3 border-l-[3px] rounded-xl ${shadowDark} transition-all duration-300 ease-out hover:shadow-md hover:scale-[1.01]`,
'group flex items-center justify-between px-4 py-3 border-l-2 rounded-lg transition-colors',
depthContainerClasses,
)}
style={{ marginLeft: depth * 12 }}
@ -173,7 +169,7 @@ export default function DepartmentsTab({
{hasChildren ? (
<button
type="button"
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-indigo-200/80 dark:hover:bg-indigo-700/80 transition-all duration-200 hover:scale-110"
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
title={isCollapsed ? '展开' : '折叠'}
onClick={(e) => {
e.stopPropagation()
@ -182,7 +178,7 @@ export default function DepartmentsTab({
>
<svg
className={cn(
'w-4 h-4 transform transition-transform duration-300 ease-out text-indigo-600 dark:text-indigo-300',
'w-4 h-4 transform transition-transform duration-300 ease-out text-slate-500 dark:text-slate-300',
!isCollapsed && 'rotate-90',
)}
fill="none"
@ -255,15 +251,21 @@ export default function DepartmentsTab({
type="button"
onClick={(e) => {
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 && <ProBadge size="small" className="absolute -top-1.5 -right-1.5" />}
</button>
<button
type="button"
@ -310,19 +312,19 @@ export default function DepartmentsTab({
<div className="space-y-6">
<section
className={cn(
'relative overflow-hidden rounded-3xl border mb-6 flex-shrink-0',
'relative overflow-hidden rounded-2xl border mb-6 flex-shrink-0 p-5',
theme === 'dark'
? 'border-slate-600/60 text-white shadow-[0_28px_60px_rgba(139,92,246,0.2)] hover:shadow-[0_35px_65px_rgba(139,92,246,0.3)]'
: 'border-slate-200/70 text-slate-900 shadow-[0_28px_60px_rgba(15,23,42,0.28)] hover:shadow-[0_35px_65px_rgba(15,23,42,0.35)]',
'transition-all duration-500 p-6',
? 'border-slate-700 bg-slate-900 text-white'
: 'border-slate-200 bg-white text-slate-900',
'transition-colors',
)}
>
<div className="relative z-10 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-3">
<p className={cn('text-sm font-semibold tracking-[0.2em] uppercase', theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600')}>
Architecture Management
<p className={cn('text-xs font-medium', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
部门管理
</p>
<h2 className={cn('text-2xl font-semibold', textTitle)}>组织脉络</h2>
<h2 className={cn('text-xl font-semibold', textTitle)}>组织结构</h2>
<p className={cn('text-sm leading-relaxed', textDesc)}>
树形结构展示上下级,配合搜索框即可迅速过滤任意分支或定位负责人。
</p>
@ -369,17 +371,17 @@ export default function DepartmentsTab({
</div>
<div className="mt-4 space-y-2">
<div className="space-y-2">
<div className="group flex items-center justify-between px-4 py-2.5 bg-gradient-to-r from-indigo-50/90 to-purple-50/90 dark:from-indigo-900/50 dark:to-purple-900/50 border-l-4 border-indigo-500 dark:border-indigo-400 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 ease-out hover:scale-[1.01]">
<div className="group flex items-center justify-between px-4 py-2.5 border-l-4 border-slate-300 dark:border-slate-600 rounded-lg bg-slate-50 dark:bg-slate-900/60 transition-colors">
<div className="flex items-center gap-3">
<button
type="button"
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-indigo-200/80 dark:hover:bg-indigo-700/80 transition-all duration-200 hover:scale-110"
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
title={collapsedDepartments.has('ROOT') ? '展开' : '折叠'}
onClick={() => toggleDepartmentCollapse('ROOT')}
>
<svg
className={cn(
'w-4 h-4 transform transition-transform duration-300 ease-out text-indigo-600 dark:text-indigo-300',
'w-4 h-4 transform transition-transform duration-300 ease-out text-slate-500 dark:text-slate-300',
!collapsedDepartments.has('ROOT') && 'rotate-90',
)}
fill="none"
@ -399,11 +401,23 @@ export default function DepartmentsTab({
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleRootAdd()}
onClick={() => {
if (!canAddSubDepartment) {
setShowUpgradeModal(true)
return
}
void handleRootAdd()
}}
disabled={addingDepartment}
className={cn(styles.buttonStyles.primary, styles.buttonModifiers.compact)}
className={cn(
styles.buttonStyles.primary,
styles.buttonModifiers.compact,
'relative',
!canAddSubDepartment && 'opacity-80'
)}
>
{addingDepartment ? '添加中...' : '添加子分支'}
{!canAddSubDepartment && <ProBadge size="small" className="absolute -top-1.5 -right-1.5" />}
</button>
</div>
</div>
@ -427,7 +441,13 @@ export default function DepartmentsTab({
</div>
</div>
</div>
{/* 升级提示弹窗 */}
<UpgradeModal
isOpen={showUpgradeModal}
onClose={() => setShowUpgradeModal(false)}
featureKey="departmentIsolation"
/>
</div>
)
}

View File

@ -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<string, FieldForm> = {
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<typeof createBuiltinTemplate>['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 <T,>(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<UploadTemplateDto[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -501,9 +473,11 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
const [previewTemplate, setPreviewTemplate] = useState<UploadTemplateDto | null>(null)
const [templateSearchTerm, setTemplateSearchTerm] = useState('')
const [enabledBuiltinTemplates, setEnabledBuiltinTemplates] = useState<Set<string>>(new Set())
const [showUpgradeModal, setShowUpgradeModal] = useState(false)
// 分类字段集合同步自后台配置,默认使用常量兜底
const [categoryFieldKeys, setCategoryFieldKeys] = useState<string[]>(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 (
<div
key={key}
className="w-full border-2 border-dashed border-blue-500 dark:border-blue-400 bg-blue-100/60 dark:bg-blue-900/30 rounded-lg py-8 flex items-center justify-center text-sm font-semibold text-blue-700 dark:text-blue-200 shadow-lg shadow-blue-200/50 dark:shadow-blue-900/30 animate-pulse"
className="w-full border border-dashed border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800/40 rounded-lg py-8 flex items-center justify-center text-sm font-semibold text-slate-600 dark:text-slate-300"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
@ -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,26 +1808,26 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
closeOnEscape={false}
>
<div
className={`bg-white dark:bg-slate-900 rounded-2xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col transition-all duration-500 ease-in-out ${
className={`bg-white dark:bg-slate-900 rounded-xl shadow-lg max-h-[92vh] overflow-hidden flex flex-col ${
isPreviewMode
? 'w-full max-w-2xl animate-in zoom-in-95'
: 'w-full max-w-7xl animate-in zoom-in-95'
? 'w-full max-w-4xl'
: 'w-full max-w-6xl'
}`}
>
<div className="px-6 py-5 border-b border-slate-200/60 dark:border-slate-800/60 flex items-center justify-between bg-slate-50/50 dark:bg-slate-800/30">
<div>
<h3 className="text-xl font-semibold text-slate-900 dark:text-white tracking-tight">
{isPreviewMode ? '预览模板' : form.id ? '编辑模板' : '新建模板'}
<h3 className="text-xl font-semibold text-slate-900 dark:text-white">
{isPreviewMode ? '模板预览' : form.id ? '编辑模板' : '新建模板'}
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
{isPreviewMode
? previewTemplate
? '查看自定义模板的真实上传界面(预览模式不可编辑)'
: '预览预制模板的实际效果(预览模式不可编辑)'
: '左侧配置字段,右侧实时预览上传面板效果'}
? '查看自定义模板的上传效果'
: '查看预制模板的上传效果'
: '左侧配置字段,右侧查看预览'}
</p>
</div>
<button
</div>
<button
onClick={closeEditor}
className="p-2 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all duration-200"
>
@ -1837,13 +1837,11 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
</button>
</div>
<div className={`flex flex-1 overflow-hidden transition-all duration-500 ease-in-out ${
isPreviewMode ? 'justify-center' : ''
}`}>
<div className={`flex flex-1 overflow-hidden ${isPreviewMode ? 'justify-center' : 'flex-col lg:flex-row'}`}>
{/* 左侧:字段配置面板 */}
{!isPreviewMode && (
<div className="w-1/2 border-r border-slate-200/60 dark:border-slate-800/60 overflow-y-auto transition-all duration-500 ease-in-out animate-in slide-in-from-left">
<div className="p-6 space-y-6">
<div className="w-full lg:w-1/2 border-b lg:border-b-0 lg:border-r border-slate-200/60 dark:border-slate-800/60 overflow-y-auto">
<div className="p-6 space-y-6">
<TemplateBasicsForm
form={form}
statusOptions={STATUS_OPTIONS}
@ -1891,9 +1889,9 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
onAddField={addField}
renderDropPlaceholder={renderEditorDropPlaceholder}
onUpdateField={updateField}
/>
</div>
</div>
/>
</div>
</div>
)}
<TemplatePreviewPanel
@ -1922,6 +1920,14 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
</Modal>
)}
<UpgradeModal
isOpen={showUpgradeModal}
onClose={() => setShowUpgradeModal(false)}
featureKey="customTemplates"
featureName="自定义模板"
featureDescription="创建、复制、编辑和迁移自定义模板需要升级后才能使用。"
/>
{/* 素材迁移弹窗 */}
{migrationDialogOpen && migrationSourceTemplate && (
<Modal
@ -1932,7 +1938,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
closeOnOverlayClick={!migrating}
closeOnEscape={!migrating}
>
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-2xl p-6 max-w-lg w-full">
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg p-6 max-w-lg w-full">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -1951,7 +1957,7 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) {
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-lg p-4 mb-4">
<p className="text-sm text-slate-700 dark:text-slate-300">
模板 <span className="font-medium text-slate-900 dark:text-white">"{migrationSourceTemplate.name}"</span> 有{' '}
模板 <span className="font-medium text-slate-900 dark:text-white">&ldquo;{migrationSourceTemplate.name}&rdquo;</span> 有{' '}
<span className="font-bold text-blue-600 dark:text-blue-400">{migrationSourceTemplate.materialCount}</span>{' '}
个素材正在使用。
</p>

View File

@ -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<string>
@ -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({
</div>
{hasDisabledTemplates && onEnableAll && (
<button
type="button"
onClick={() => onEnableAll()}
className={cn(
styles.buttonStyles.secondary,
@ -58,14 +63,13 @@ export default function BuiltInTemplateGrid({
className={cn(styles.cardStyles.hover, 'group relative overflow-hidden cursor-pointer')}
onClick={() => onPreview(preset.key)}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-blue-500/70 via-purple-500/60 to-blue-500/70 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-white tracking-tight">{preset.name}</p>
<p className="text-sm font-semibold text-slate-900 dark:text-white">{preset.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 leading-snug">{preset.description}</p>
</div>
<span className="px-2.5 py-0.5 rounded-full text-[11px] font-medium text-blue-600 dark:text-blue-300 bg-blue-50/70 dark:bg-blue-900/30 border border-blue-100/70 dark:border-blue-800/60">
推荐
<span className="px-2.5 py-0.5 rounded-full text-[11px] font-medium text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
预制
</span>
</div>
<div className="mt-4 flex items-center justify-between gap-3">
@ -100,20 +104,25 @@ export default function BuiltInTemplateGrid({
type="button"
onClick={(e) => {
e.stopPropagation()
if (!canQuickCreate) {
onRequestUpgrade()
return
}
onQuickCreate(preset.key)
}}
className={cn(
styles.buttonStyles.success,
styles.buttonStyles.primary,
styles.buttonModifiers.compactXs,
styles.buttonModifiers.pill,
'inline-flex items-center gap-1.5 shadow-none',
'bg-emerald-600 hover:bg-emerald-700 text-white' // 显式添加背景色和文字色
!canQuickCreate && 'opacity-75'
)}
title={canQuickCreate ? '快速创建副本' : '需要升级后才能创建自定义模板'}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 3.5a.75.75 0 01.75.75v5h5a.75.75 0 010 1.5h-5v5a.75.75 0 01-1.5 0v-5h-5a.75.75 0 010-1.5h5v-5A.75.75 0 0110 3.5z" />
</svg>
快速创建副本
{canQuickCreate ? '快速创建副本' : '升级后创建'}
</button>
</div>
</div>

View File

@ -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 (
<div className="px-6 pt-5 pb-4">
<div className="flex items-center justify-between gap-4 mb-3">
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">自定义模板</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">这里列出你自己创建的模板,可继续编辑、复制或删除</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500">这些模板才会被真实上传面板使用,确保状态为“启用”后用户才能看到。</p>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">自定义模板</h3>
{!canManageCustomTemplates && <ProBadge size="small" />}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
{canManageCustomTemplates
? '这里列出你自己创建的模板,可继续编辑、复制或删除'
: '当前许可证未开放自定义模板功能,升级后可新建、复制、编辑和迁移模板'}
</p>
</div>
</div>
@ -51,17 +71,32 @@ export default function CustomTemplateSection({
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<button
type="button"
onClick={onCreate}
className={cn(styles.cardStyles.flat, 'border-dashed border-2 border-slate-300 dark:border-slate-600 flex items-center justify-center gap-3')}
onClick={() => {
if (!canManageCustomTemplates) {
onRequestUpgrade()
return
}
onCreate()
}}
className={cn(
styles.cardStyles.flat,
'border-dashed border-2 border-slate-300 dark:border-slate-600 flex items-center justify-center gap-3'
)}
>
<div className="w-14 h-14 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0 group-hover:bg-slate-200 dark:group-hover:bg-slate-700 transition-colors">
<svg className="w-8 h-8 text-slate-400 group-hover:text-slate-600 dark:group-hover:text-slate-200 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="w-14 h-14 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0 transition-colors">
<svg className="w-8 h-8 text-slate-400 dark:text-slate-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="text-left">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-200">新建自定义模板</p>
<p className="text-[11px] text-slate-500 dark:text-slate-400 mt-1 leading-snug">从空白模板起步,完全按照业务需求自由配置字段</p>
<p className="text-sm font-semibold text-slate-800 dark:text-slate-200">
{canManageCustomTemplates ? '新建自定义模板' : '升级后新建模板'}
</p>
<p className="text-[11px] text-slate-500 dark:text-slate-400 mt-1 leading-snug">
{canManageCustomTemplates
? '从空白模板起步,按照业务需求配置字段'
: '升级后可新建、复制和迁移自定义模板'}
</p>
</div>
</button>
@ -76,10 +111,9 @@ export default function CustomTemplateSection({
onClick={() => onPreview(tpl)}
className={cn(styles.cardStyles.hover, 'group relative overflow-hidden cursor-pointer')}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-purple-500/70 via-blue-500/60 to-purple-500/70 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-white tracking-tight">{tpl.name}</p>
<p className="text-sm font-semibold text-slate-900 dark:text-white">{tpl.name}</p>
{tpl.description && (
<p className="text-xs text-slate-500 dark:text-slate-400 leading-snug line-clamp-2">{tpl.description}</p>
)}
@ -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}
</span>
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-blue-500/80 dark:text-blue-300/80 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
预览
</span>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-slate-200/70 dark:border-slate-700/70 pt-3">
@ -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'
)}
>
取消启用
</button>
@ -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'
)}
>
启用
</button>
@ -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'
)}
>
编辑
</button>
@ -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({
</div>
)
}

View File

@ -19,21 +19,17 @@ export default function TemplateManagerHeader({
return (
<section
className={cn(
'relative overflow-hidden rounded-3xl border mb-6 flex-shrink-0',
'mb-4 rounded-2xl border px-4 py-4',
theme === 'dark'
? 'border-slate-600/60 text-white shadow-[0_28px_60px_rgba(139,92,246,0.2)] hover:shadow-[0_35px_65px_rgba(139,92,246,0.3)]'
: 'border-slate-200/70 text-slate-900 shadow-[0_28px_60px_rgba(15,23,42,0.28)] hover:shadow-[0_35px_65px_rgba(15,23,42,0.35)]',
'transition-all duration-500 p-6',
? 'border-slate-700 bg-slate-900/85 text-white'
: 'border-slate-200 bg-white text-slate-900'
)}
>
<div className="relative z-10 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-3">
<p className={cn('text-sm font-semibold tracking-[0.2em] uppercase', theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600')}>
Template Management
</p>
<h2 className={cn('text-2xl font-semibold', textTitle)}>模板工作台</h2>
<p className={cn('text-sm leading-relaxed', textDesc)}>
预制模板仅供参考和复制,自定义模板启用后才会对上传面板生效。
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-1">
<h2 className={cn('text-lg font-semibold', textTitle)}>模板管理</h2>
<p className={cn('text-sm', textDesc)}>
预制模板可预览和启用,自定义模板按许可证开放。
</p>
</div>
<div className="relative w-full md:w-[320px] lg:w-[360px]">
@ -44,7 +40,7 @@ export default function TemplateManagerHeader({
placeholder="搜索模板名称..."
className={cn(styles.inputStyles.base, 'pl-11 pr-10')}
/>
<svg className="absolute left-4 top-3 w-5 h-5 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className={cn('absolute left-4 top-3 w-5 h-5', theme === 'dark' ? 'text-slate-500' : 'text-slate-400')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{searchTerm && (
@ -68,5 +64,3 @@ export default function TemplateManagerHeader({
</section>
)
}

View File

@ -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<string | false | null | undefined>) => 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(
<CustomTemplateSection
loading={false}
error={null}
templates={[customTemplate]}
filteredTemplates={[customTemplate]}
deletingId={null}
canManageCustomTemplates={canManageCustomTemplates}
{...callbacks}
/>
)
}
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()
})
})

View File

@ -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 (
<div className="space-y-5">
<div>
@ -74,7 +81,7 @@ export default function TemplateBasicsForm({
</label>
<select
value={form.materialType}
onChange={(e) => onChange({ materialType: e.target.value })}
onChange={(e) => onChange({ materialType: e.target.value, materialKind: inferMaterialKind(e.target.value) })}
className={cn(inputStyles.base)}
>
{materialTypeOptions.map((option) => (
@ -93,6 +100,7 @@ export default function TemplateBasicsForm({
<div className="flex flex-wrap gap-2">
{[
{ label: '图片/文档', value: 'IMAGE', hint: '普通素材,走图片/文件流程' },
{ label: '音频', value: 'AUDIO', hint: '走音频上传与解析流程' },
{ label: '视频', value: 'VIDEO', hint: '走视频上传与转码流程' },
{ label: '其他', value: 'OTHER', hint: '暂不归类,可后续扩展' },
].map((option) => {
@ -114,7 +122,7 @@ export default function TemplateBasicsForm({
)
})}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1.5">用于驱动上传/处理链路,视频模板请选择“视频”。</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1.5">用于驱动上传/处理链路,音频模板请选择“音频”,视频模板请选择“视频”。</p>
</div>
</div>

View File

@ -206,7 +206,7 @@ export default function TemplateFieldsEditor({
: 'border-slate-200/60 dark:border-slate-700/60 bg-slate-50/50 dark:bg-slate-800/40 hover:border-slate-300 dark:hover:border-slate-600 hover:bg-white dark:hover:bg-slate-800 hover:shadow-sm'
} ${
isDragging
? 'opacity-40 scale-[0.96] cursor-grabbing border-blue-500 dark:border-blue-400 shadow-lg shadow-blue-200/50 dark:shadow-blue-900/50 transition-all duration-200'
? 'opacity-60 cursor-grabbing border-slate-400 dark:border-slate-500 shadow-sm transition-colors duration-150'
: 'cursor-default'
}`}
draggable={true}
@ -589,12 +589,12 @@ export default function TemplateFieldsEditor({
{/* 删除确认对话框 */}
{deleteConfirmIndex !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-md w-full mx-4 animate-in zoom-in-95 duration-200">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 max-w-md w-full mx-4">
<div className="p-6">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<svg className="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-900/40 flex items-center justify-center">
<svg className="w-5 h-5 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
@ -611,7 +611,7 @@ export default function TemplateFieldsEditor({
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 bg-slate-50 dark:bg-slate-900/50 rounded-b-2xl border-t border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-end gap-3 px-6 py-4 bg-slate-50 dark:bg-slate-900/50 rounded-b-lg border-t border-slate-200 dark:border-slate-700">
<button
onClick={cancelDelete}
className="px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"

View File

@ -328,52 +328,52 @@ export default function TemplatePreviewPanel({
return (
<div
className={`overflow-y-auto bg-slate-50/30 dark:bg-slate-900/50 transition-all duration-500 ease-in-out ${
isPreviewMode ? 'w-[520px] mx-auto animate-in zoom-in-95' : 'w-1/2 animate-in slide-in-from-right'
className={`overflow-y-auto bg-slate-50/30 dark:bg-slate-900/50 ${
isPreviewMode ? 'w-full max-w-[520px] mx-auto' : 'w-full lg:w-1/2'
}`}
>
<div className={isPreviewMode ? 'p-4' : 'p-6'}>
{!isPreviewMode && (
<div className="mb-5 pb-5 border-b border-slate-200/40 dark:border-slate-800/40">
<h4 className="text-xl font-semibold text-slate-900 dark:text-white mb-1.5 tracking-tight">实时预览</h4>
<p className="text-sm text-slate-500 dark:text-slate-400 font-light">预览上传面板的实际效果</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500">右侧仅供预览,不可编辑,点击字段可定位到左侧配置。</p>
<h4 className="text-lg font-semibold text-slate-900 dark:text-white mb-1.5">预览区</h4>
<p className="text-sm text-slate-500 dark:text-slate-400">查看上传面板的实际效果</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500">右侧只读,点击字段可定位到左侧配置。</p>
</div>
)}
{isPreviewMode && (
<div className="mb-4 text-center">
<h4 className="text-lg font-semibold text-slate-900 dark:text-white mb-1">模板预览</h4>
<p className="text-xs text-slate-500 dark:text-slate-400">完整的上传面板效果</p>
<p className="text-xs text-slate-500 dark:text-slate-400">查看模板在上传页中的效果</p>
</div>
)}
<div
className={
isPreviewMode
? 'border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-3xl p-4 bg-slate-50/30 dark:bg-slate-800/30'
? 'border border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-4 bg-slate-50/30 dark:bg-slate-800/30'
: ''
}
>
<div
className={`bg-white/95 dark:bg-slate-800/95 backdrop-blur-xl rounded-2xl shadow-lg shadow-slate-200/30 dark:shadow-slate-900/30 border border-slate-200/40 dark:border-slate-700/40 space-y-5 ${
isPreviewMode ? 'p-6 border-2 border-slate-300 dark:border-slate-600 ring-2 ring-slate-200/50 dark:ring-slate-700/50' : 'p-5'
className={`bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 space-y-5 ${
isPreviewMode ? 'p-5 border border-slate-300 dark:border-slate-600' : 'p-5'
}`}
>
<div className="relative">
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
预览图片 <span className="text-red-500">*</span>
</label>
<div className="border-2 border-dashed border-slate-300/60 dark:border-slate-600/60 rounded-2xl p-4 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-slate-400/80 dark:hover:border-slate-500/80 hover:bg-slate-50/50 dark:hover:bg-slate-700/30 hover:shadow-md">
<div className="border border-dashed border-slate-300/60 dark:border-slate-600/60 rounded-lg p-4 transition-colors hover:border-slate-400 dark:hover:border-slate-500 hover:bg-slate-50/50 dark:hover:bg-slate-700/30">
<div className="flex items-center justify-center space-x-3">
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-slate-100 to-slate-50 dark:from-slate-700 dark:to-slate-800 flex items-center justify-center flex-shrink-0 shadow-inner">
<div className="w-16 h-16 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0 border border-slate-200 dark:border-slate-700">
<svg className="w-8 h-8 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div className="text-left flex-1 min-w-0">
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">点击或拖拽上传预览图</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">支持 JPG、PNG、WebP 等格式</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">支持 JPG、PNG、WebP</p>
</div>
</div>
</div>
@ -383,7 +383,7 @@ export default function TemplatePreviewPanel({
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
素材文件 <span className="text-red-500">*</span>
</label>
<div className="border-2 border-dashed border-slate-300/60 dark:border-slate-600/60 rounded-2xl p-4 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-slate-400/80 dark:hover:border-slate-500/80 hover:bg-slate-50/50 dark:hover:bg-slate-700/30 hover:shadow-md">
<div className="border-2 border-dashed border-slate-300/60 dark:border-slate-600/60 rounded-lg p-4 transition-colors hover:border-slate-400 dark:hover:border-slate-500 hover:bg-slate-50/50 dark:hover:bg-slate-700/30">
<div className="flex items-center justify-center space-x-3">
<svg className="w-8 h-8 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
@ -406,7 +406,7 @@ export default function TemplatePreviewPanel({
>
<SortableContext items={previewOrder} strategy={rectSortingStrategy}>
{orderedPreviewItems.length === 0 ? (
<div className="w-full rounded-2xl border-2 border-dashed border-slate-200/60 dark:border-slate-700/60 bg-gradient-to-br from-slate-50/50 to-transparent dark:from-slate-800/30 dark:to-transparent backdrop-blur-sm py-8 text-center">
<div className="w-full rounded-lg border-2 border-dashed border-slate-200 dark:border-slate-700 bg-slate-50/70 dark:bg-slate-800/40 py-8 text-center">
<div className="flex flex-col items-center gap-2">
<svg className="w-8 h-8 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
@ -445,8 +445,8 @@ export default function TemplatePreviewPanel({
createPortal(
<DragOverlay
dropAnimation={{
duration: 500,
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
duration: 180,
easing: 'ease-out',
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
@ -502,7 +502,7 @@ function PreviewSortableCard({
transform: isDragging ? undefined : CSS.Transform.toString(transform),
transition: isDragging
? undefined
: transition ?? 'transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
: transition ?? 'transform 0.2s ease-out, opacity 0.2s ease-out',
zIndex: isDragging ? 30 : undefined,
opacity: isDragging ? 0.4 : 1,
}
@ -512,8 +512,8 @@ function PreviewSortableCard({
const containerPointerEvents = isDragging ? 'none' : 'auto'
const cardStateClass = isEditing
? 'transform scale-[1.02] shadow-xl shadow-slate-200/50 dark:shadow-slate-900/50 border-2 border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-800 rounded-2xl backdrop-blur-sm'
: 'hover:scale-[1.01] hover:shadow-lg hover:shadow-slate-200/50 dark:hover:shadow-slate-900/50 border border-slate-200/40 dark:border-slate-700/40 bg-white/95 dark:bg-slate-800/95 rounded-2xl backdrop-blur-sm'
? 'shadow-sm border border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-800 rounded-lg'
: 'hover:shadow-sm border border-slate-200/40 dark:border-slate-700/40 bg-white dark:bg-slate-800 rounded-lg'
return (
<div
@ -524,17 +524,17 @@ function PreviewSortableCard({
position: 'relative',
pointerEvents: containerPointerEvents,
}}
className={`group relative transition-all duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)] ${gridClassName ?? 'col-span-12'}`}
className={`group relative transition-transform duration-200 ease-out ${gridClassName ?? 'col-span-12'}`}
onMouseEnter={onHover}
onMouseLeave={onHoverEnd}
onClick={onClick}
>
<div className={`relative transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] ${cardStateClass}`}>
<div className={`relative transition-all duration-200 ease-out ${cardStateClass}`}>
{!isPreviewMode && (
<div className="absolute top-2 right-2 z-20 flex flex-col items-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<button
type="button"
className="w-8 h-8 rounded-full bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-200/70 dark:border-slate-700/70 shadow-lg shadow-slate-200/50 dark:shadow-slate-900/50 flex items-center justify-center cursor-grab active:cursor-grabbing hover:border-blue-400/80 dark:hover:border-blue-300/80 hover:bg-white dark:hover:bg-slate-900 hover:shadow-xl hover:scale-110 active:scale-95 transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]"
className="w-8 h-8 rounded-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 flex items-center justify-center cursor-grab active:cursor-grabbing hover:border-slate-400 dark:hover:border-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
title="拖拽调整顺序"
aria-label="拖拽调整顺序"
{...attributes}
@ -549,7 +549,7 @@ function PreviewSortableCard({
e.stopPropagation()
onRemove()
}}
className="w-7 h-7 bg-gradient-to-br from-red-500 to-red-600 text-white rounded-full flex items-center justify-center hover:from-red-600 hover:to-red-700 transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] hover:scale-110 active:scale-95 shadow-md shadow-red-500/30 hover:shadow-lg hover:shadow-red-500/40"
className="w-7 h-7 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition-colors"
title="删除此字段"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -566,9 +566,9 @@ function PreviewSortableCard({
function PreviewFieldContent({ field }: { field: FieldForm }) {
const baseInputClass =
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60'
'w-full px-4 py-2.5 text-sm rounded-lg outline-none transition-colors border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 focus-visible:ring-2 focus-visible:ring-slate-300 dark:focus-visible:ring-slate-600 focus-visible:border-slate-400 dark:focus-visible:border-slate-500'
const requiredInputClass = field.required
? ' bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
? ' bg-slate-50 dark:bg-slate-800 border-slate-300 dark:border-slate-600'
: ' bg-white/90 dark:bg-slate-800/80'
const optionList = (() => {
@ -594,7 +594,7 @@ function PreviewFieldContent({ field }: { field: FieldForm }) {
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-semibold text-slate-800 dark:text-slate-200 tracking-tight">{field.label || field.key || '未命名字段'}</span>
{field.required && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-[0.18em] text-white bg-gradient-to-r from-purple-500 to-indigo-600 shadow-[0_8px_16px_rgba(79,70,229,0.35)]">
<span className="inline-flex items-center px-2 py-0.5 rounded-md border border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 text-[10px] font-medium text-slate-600 dark:text-slate-300">
必填
</span>
)}
@ -660,7 +660,7 @@ function PreviewDragOverlayCard({
transform: 'none',
}}
>
<div className="rounded-2xl border-2 border-blue-500/60 dark:border-blue-400/60 bg-white/98 dark:bg-slate-800/98 backdrop-blur-xl shadow-2xl shadow-blue-500/30 dark:shadow-blue-900/50 px-4 py-3 transform rotate-[1.5deg] scale-[1.08] transition-all duration-200 ease-out">
<div className="rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-sm px-4 py-3">
<PreviewFieldContent field={item.field} />
</div>
</div>
@ -670,12 +670,12 @@ function PreviewDragOverlayCard({
function PreviewPlaceholderCard({ gridClassName }: { gridClassName: string }) {
return (
<div className={gridClassName}>
<div className="h-full min-h-[88px] rounded-2xl border-2 border-dashed border-blue-500 dark:border-blue-400 bg-gradient-to-br from-blue-50/90 to-blue-100/70 dark:from-blue-900/40 dark:to-blue-800/30 flex items-center justify-center text-blue-600 dark:text-blue-300 text-xs font-semibold tracking-wide pointer-events-none shadow-xl shadow-blue-200/60 dark:shadow-blue-900/60 animate-pulse">
<div className="flex flex-col items-center gap-2 animate-bounce">
<svg className="w-6 h-6 text-blue-500 dark:text-blue-400 animate-pulse" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<div className="h-full min-h-[88px] rounded-lg border border-dashed border-slate-300 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/60 flex items-center justify-center text-slate-600 dark:text-slate-300 text-xs font-medium pointer-events-none">
<div className="flex flex-col items-center gap-2">
<svg className="w-6 h-6 text-slate-400 dark:text-slate-500" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M12 5l7 7-7 7" />
</svg>
<span className="opacity-80 animate-pulse">松开即可放置</span>
<span>松开后放置</span>
</div>
</div>
</div>

View File

@ -27,13 +27,6 @@ import { useDataFetch } from './_hooks/useDataFetch'
import { useUserOperations } from './_hooks/useUserOperations'
import { useDepartmentOperations } from './_hooks/useDepartmentOperations'
// 强制设置全局标记,确保新代码已加载(在页面加载时立即执行)
if (typeof window !== 'undefined') {
;(window as any).__ADMIN_PAGE_LOADED__ = true
;(window as any).__ADMIN_PAGE_VERSION__ = '2025-11-23-v4'
console.log('[AdminPage] 🚀 页面已加载,版本:', (window as any).__ADMIN_PAGE_VERSION__)
}
// 类型断言函数:安全地获取 SessionUser
function getSessionUser(session: { user?: unknown } | null): SessionUser | null {
if (!session?.user) return null
@ -207,7 +200,7 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
<main className="container mx-auto px-4 py-8 max-w-7xl">
{/* Tabs - Apple风格动画(使用统一导航样式) */}
{/* 管理导航使用统一导航样式 */}
<nav role="tablist" aria-label="管理功能导航" className={cn('flex space-x-1 mb-8 overflow-x-auto')}>
{/* 监控仪表板已移除,统一通过头像下拉菜单访问 */}
<button
@ -353,28 +346,28 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
</button>
</nav>
{/* Template Management */}
{/* 模板管理 */}
{activeTab === 'templates' && (
<TabErrorBoundary tabKey="templates" onRetry={fetchData}>
<TemplateManager showToast={showToast} />
</TabErrorBoundary>
)}
{/* Materials Management */}
{/* 素材管理 */}
{activeTab === 'materials' && (
<TabErrorBoundary tabKey="materials">
<MaterialsTab showToast={showToast} />
</TabErrorBoundary>
)}
{/* Categories Management */}
{/* 分类管理 */}
{activeTab === 'categories' && (
<TabErrorBoundary tabKey="categories" onRetry={fetchData}>
<CategoriesTab showToast={showToast} onDataChange={fetchData} />
</TabErrorBoundary>
)}
{/* Invitation Management */}
{/* 邀请管理 */}
{activeTab === 'invitations' && (
<TabErrorBoundary tabKey="invitations">
<InvitationsTab showToast={showToast} />
@ -431,4 +424,3 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
</div>
)
}

View File

@ -0,0 +1,105 @@
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/admin-permissions', () => ({
isCurrentSuperAdmin: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
},
},
}))
jest.mock('bcryptjs', () => ({
compare: jest.fn(),
}))
import { NextRequest } from 'next/server'
import { POST } from '../route'
const { getServerSession } = require('next-auth')
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
const { prisma } = require('@/lib/prisma')
const bcrypt = require('bcryptjs')
function createRequest(password?: string) {
return new NextRequest('http://localhost/api/auth/verify-admin-password', {
method: 'POST',
body: JSON.stringify({ password }),
headers: { 'Content-Type': 'application/json' },
})
}
describe('POST /api/auth/verify-admin-password', () => {
beforeEach(() => {
jest.resetAllMocks()
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: true },
})
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.user.findUnique.mockResolvedValue({ password: 'hashed-password' })
bcrypt.compare.mockResolvedValue(true)
})
it('未登录时拒绝验证', async () => {
getServerSession.mockResolvedValue(null)
const response = await POST(createRequest('password'))
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('未登录')
expect(isCurrentSuperAdmin).not.toHaveBeenCalled()
})
it('不是最高权限账号时拒绝验证', async () => {
isCurrentSuperAdmin.mockResolvedValue(false)
const response = await POST(createRequest('password'))
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('需要超级管理员权限')
expect(prisma.user.findUnique).not.toHaveBeenCalled()
})
it('密码为空时返回输入错误', async () => {
const response = await POST(createRequest(''))
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('请输入密码')
expect(prisma.user.findUnique).not.toHaveBeenCalled()
})
it('密码不匹配时拒绝解锁', async () => {
bcrypt.compare.mockResolvedValue(false)
const response = await POST(createRequest('wrong-password'))
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('密码错误')
expect(bcrypt.compare).toHaveBeenCalledWith('wrong-password', 'hashed-password')
})
it('最高权限账号密码正确时允许解锁', async () => {
const response = await POST(createRequest('right-password'))
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: 'admin-1' },
select: { password: true },
})
})
})

View File

@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { isCurrentSuperAdmin } from '@/lib/admin-permissions';
import bcrypt from 'bcryptjs';
export async function POST(request: NextRequest) {
try {
// 1. 验证用户已登录且是超级管理员
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
const user = session.user as any;
if (!(await isCurrentSuperAdmin(session))) {
return NextResponse.json(
{ success: false, error: '需要超级管理员权限' },
{ status: 403 }
);
}
// 2. 获取密码
const { password } = await request.json();
if (!password) {
return NextResponse.json(
{ success: false, error: '请输入密码' },
{ status: 400 }
);
}
// 3. 从数据库获取用户信息验证密码
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { password: true },
});
if (!dbUser?.password) {
return NextResponse.json(
{ success: false, error: '用户信息异常' },
{ status: 400 }
);
}
// 4. 验证密码
const isValid = await bcrypt.compare(password, dbUser.password);
if (!isValid) {
return NextResponse.json(
{ success: false, error: '密码错误' },
{ status: 400 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('密码验证失败:', error);
return NextResponse.json(
{ success: false, error: '服务器错误' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,129 @@
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/admin-permissions', () => ({
isCurrentSuperAdmin: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
count: jest.fn(),
},
material: {
count: jest.fn(),
},
systemConfig: {
findUnique: jest.fn(),
},
},
}))
jest.mock('@/lib/license', () => ({
getLicense: jest.fn(),
}))
import { GET } from '../route'
const { getServerSession } = require('next-auth')
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
const { prisma } = require('@/lib/prisma')
const { getLicense } = require('@/lib/license')
const activeProLicense = {
type: 'PRO',
expiresAt: null,
limits: {
maxUsers: 100,
maxMaterials: 1000,
},
features: {
customTemplates: true,
systemSettings: true,
departmentIsolation: true,
apiAccess: true,
intranetPenetration: true,
},
}
describe('GET /api/license/info', () => {
beforeEach(() => {
jest.resetAllMocks()
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },
})
isCurrentSuperAdmin.mockResolvedValue(false)
prisma.user.count.mockResolvedValue(12)
prisma.material.count.mockResolvedValue(345)
prisma.systemConfig.findUnique.mockResolvedValue({
value: JSON.stringify({
licenseKey: 'FLNXI-PRO-SECRET-0001',
lastVerified: '2026-05-24T00:00:00.000Z',
}),
})
getLicense.mockReturnValue(activeProLicense)
})
it('未登录不能读取许可证详情和使用规模', async () => {
getServerSession.mockResolvedValue(null)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('未登录')
expect(prisma.user.count).not.toHaveBeenCalled()
expect(prisma.material.count).not.toHaveBeenCalled()
})
it('普通用户不能读取许可证详情', async () => {
getServerSession.mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
const response = await GET()
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('权限不足')
expect(prisma.user.count).not.toHaveBeenCalled()
})
it('普通管理员只能看到脱敏激活码', async () => {
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data.licenseKey).toBe('FLNX****0001')
expect(data.usage.users.current).toBe(12)
expect(data.usage.materials.current).toBe(345)
})
it('最高权限账号可以看到完整激活码', async () => {
isCurrentSuperAdmin.mockResolvedValue(true)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data.licenseKey).toBe('FLNXI-PRO-SECRET-0001')
})
it('管理员在未激活时可读取未激活状态', async () => {
getLicense.mockReturnValue(null)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data.activated).toBe(false)
expect(data.licenseKey).toBeNull()
expect(data.usage.users.current).toBe(12)
})
})

View File

@ -1,34 +1,79 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { getLicense, getLicenseType } from '@/lib/license';
import { isCurrentSuperAdmin } from '@/lib/admin-permissions';
import { getLicense } from '@/lib/license';
import type { LocalLicenseData } from '@/lib/license/types';
function maskLicenseKey(licenseKey: string | null): string | null {
if (!licenseKey) return null;
if (licenseKey.length <= 8) return '****';
return `${licenseKey.slice(0, 4)}****${licenseKey.slice(-4)}`;
}
export async function GET() {
try {
const license = getLicense();
if (!license) {
return NextResponse.json(
{ error: '未激活' },
{ status: 401 }
);
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
// 获取使用统计
if ((session.user as any)?.role !== 'ADMIN') {
return NextResponse.json({ error: '权限不足' }, { status: 403 });
}
const canReadFullLicenseKey = await isCurrentSuperAdmin(session);
// 获取使用统计(无论是否有许可证都需要)
const [userCount, materialCount] = await Promise.all([
prisma.user.count(),
prisma.material.count(),
]);
const license = getLicense();
// 如果没有许可证,返回未激活状态(而不是401错误)
if (!license) {
return NextResponse.json({
type: 'FREE' as const,
status: 'active' as const,
expiresAt: null,
licenseKey: null,
usage: {
users: {
current: userCount,
max: 10, // 免费版默认限制
},
materials: {
current: materialCount,
max: 100, // 免费版默认限制
},
},
features: {
customTemplates: false,
systemSettings: false,
departmentIsolation: false,
apiAccess: false,
intranetPenetration: false,
},
lastVerified: new Date().toISOString(),
activated: false, // 标记为未激活
});
}
// 获取本地许可证数据
const localLicense = await prisma.systemConfig.findUnique({
where: { key: 'license' },
});
let lastVerified = new Date().toISOString();
let licenseKey: string | null = null;
if (localLicense) {
try {
const data = JSON.parse(localLicense.value);
const data = JSON.parse(localLicense.value) as LocalLicenseData;
lastVerified = data.lastVerified || lastVerified;
licenseKey = data.licenseKey || null;
} catch {
// ignore
}
@ -56,6 +101,8 @@ export async function GET() {
},
features: license.features,
lastVerified,
activated: true, // 标记为已激活
licenseKey: canReadFullLicenseKey ? licenseKey : maskLicenseKey(licenseKey),
});
} catch (error) {
console.error('获取许可证信息失败:', error);

View File

@ -1,11 +1,13 @@
'use client';
import { useEffect, useState } from 'react';
import ActivationModal from './ui/ActivationModal';
interface LicenseData {
type: 'FREE' | 'STANDARD' | 'PRO';
status: 'active' | 'expired' | 'grace';
expiresAt: string | null;
licenseKey: string | null;
usage: {
users: { current: number; max: number };
materials: { current: number; max: number };
@ -33,6 +35,7 @@ const STATUS_NAMES = {
export default function LicenseInfo() {
const [license, setLicense] = useState<LicenseData | null>(null);
const [loading, setLoading] = useState(true);
const [showActivationModal, setShowActivationModal] = useState(false);
useEffect(() => {
fetchLicenseInfo();
@ -85,6 +88,14 @@ export default function LicenseInfo() {
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">
最后验证: {new Date(license.lastVerified).toLocaleString()}
</p>
{license.licenseKey && (
<div className="mt-2">
<p className="text-xs text-gray-500 dark:text-slate-400 mb-1">激活码</p>
<p className="text-sm font-mono font-medium text-gray-900 dark:text-slate-100 bg-gray-50 dark:bg-slate-700 px-3 py-1.5 rounded border dark:border-slate-600 inline-block">
{license.licenseKey}
</p>
</div>
)}
</div>
<div className="flex items-center gap-2">
<span
@ -186,14 +197,30 @@ export default function LicenseInfo() {
</div>
</div>
{/* 升级按钮 */}
{/* 升级/激活按钮 */}
{license.type !== 'PRO' && (
<div className="px-6 pb-6">
<button className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
升级许可证
<div className="px-6 pb-6 space-y-3">
<button
onClick={() => setShowActivationModal(true)}
className="w-full py-2.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium"
>
输入激活码
</button>
<button
onClick={() => window.open('https://www.flnxi.com/pricing', '_blank')}
className="w-full py-2.5 bg-white dark:bg-slate-700 text-indigo-600 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-slate-600 transition-colors font-medium"
>
购买授权 →
</button>
</div>
)}
{/* 激活码输入弹窗 */}
<ActivationModal
isOpen={showActivationModal}
onClose={() => setShowActivationModal(false)}
onSuccess={fetchLicenseInfo}
/>
</div>
);
}

View File

@ -0,0 +1,169 @@
'use client';
import { useState } from 'react';
import { cn } from '@/lib/ui-styles';
interface ActivationModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export default function ActivationModal({ isOpen, onClose, onSuccess }: ActivationModalProps) {
const [activationCode, setActivationCode] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const code = activationCode.trim().toUpperCase();
if (!code) {
setError('请输入激活码');
return;
}
// 简单格式验证
if (!code.startsWith('FLNXI-')) {
setError('激活码格式不正确');
return;
}
setLoading(true);
try {
const response = await fetch('/api/license/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ activationCode: code }),
});
const data = await response.json();
if (data.success) {
onSuccess?.();
onClose();
// 刷新页面以更新许可证状态
window.location.reload();
} else {
setError(data.error || '激活失败,请检查激活码是否正确');
}
} catch (err) {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);
}
};
const handlePurchase = () => {
// 跳转到官网购买页面
window.open('https://www.flnxi.com/pricing', '_blank');
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 背景遮罩 */}
<div
className="absolute inset-0 bg-black/45"
onClick={onClose}
/>
{/* 弹窗内容 */}
<div className="relative bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 w-full max-w-md mx-4 overflow-hidden">
{/* 头部 */}
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
激活许可证
</h3>
<button
onClick={onClose}
className="p-1 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
<svg className="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* 内容 */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
激活码
</label>
<input
type="text"
value={activationCode}
onChange={(e) => 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
/>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
激活码格式:FLNXI-PRO-XXXX-XXXX-XXXX-XXXX
</p>
</div>
{error && (
<div className="px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className="flex gap-3">
<button
type="submit"
disabled={loading}
className={cn(
'flex-1 px-4 py-3 rounded-xl font-medium text-sm transition-all',
'bg-indigo-600 hover:bg-indigo-700 text-white',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
激活中...
</span>
) : (
'立即激活'
)}
</button>
</div>
</form>
{/* 底部 */}
<div className="px-6 py-4 bg-slate-50 dark:bg-slate-900/50 border-t border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500 dark:text-slate-400">
还没有激活码?
</p>
<button
type="button"
onClick={handlePurchase}
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 transition-colors"
>
前往购买 →
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -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<ProBadgeSize, string> = {
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 (
<span
className={cn(
'inline-flex items-center justify-center font-semibold rounded-full',
'border border-slate-300 bg-slate-100 text-slate-700',
theme === 'dark' && 'border-slate-700 bg-slate-800 text-slate-200',
'shadow-sm',
sizeStyles[size],
className
)}
data-testid="pro-badge"
data-size={size}
>
PRO
</span>
)
}

View File

@ -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 (
<>
<div
className={cn(
'flex items-center justify-between py-3',
className
)}
data-testid="restricted-toggle"
data-restricted={isRestricted}
>
{/* 标签和描述 */}
<div className="flex-1 min-w-0 pr-4">
<div className="flex items-center gap-2">
<span
className={cn(
'text-sm font-medium',
theme === 'dark' ? 'text-slate-200' : 'text-slate-700',
isRestricted && 'opacity-60'
)}
>
{label}
</span>
{isRestricted && <ProBadge size="small" />}
</div>
{description && (
<p
className={cn(
'text-xs mt-1',
theme === 'dark' ? 'text-slate-400' : 'text-slate-500',
isRestricted && 'opacity-60'
)}
>
{description}
</p>
)}
{isRestricted && (
<p
className={cn(
'text-xs mt-1',
'text-amber-600 dark:text-amber-400'
)}
data-testid="restricted-hint"
>
升级到付费版解锁此功能
</p>
)}
</div>
{/* 开关 */}
<button
type="button"
role="switch"
aria-checked={value}
aria-disabled={isRestricted}
onClick={handleClick}
className={cn(
'relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent',
'transition-colors duration-200 ease-in-out',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
isRestricted
? cn(
// 受限状态样式
'cursor-not-allowed opacity-50',
value
? 'bg-slate-400 dark:bg-slate-600'
: 'bg-slate-300 dark:bg-slate-700',
'focus:ring-amber-500'
)
: cn(
// 正常状态样式
'cursor-pointer',
value
? 'bg-indigo-600 dark:bg-indigo-500'
: 'bg-slate-200 dark:bg-slate-700',
'focus:ring-indigo-500'
)
)}
data-testid="toggle-button"
>
<span
className={cn(
'pointer-events-none inline-block h-5 w-5 transform rounded-full',
'bg-white shadow ring-0 transition duration-200 ease-in-out',
value ? 'translate-x-5' : 'translate-x-0'
)}
/>
</button>
</div>
{/* 升级弹窗 */}
<UpgradeModal
isOpen={showUpgradeModal}
onClose={() => setShowUpgradeModal(false)}
featureKey={featureKey}
/>
</>
)
}

View File

@ -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<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 背景遮罩 */}
<div
className="absolute inset-0 bg-black/45"
onClick={handleClose}
/>
{/* 弹窗内容 */}
<div className="relative bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 w-full max-w-md mx-4 overflow-hidden">
{/* 头部 */}
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
安全验证
</h3>
</div>
<button
onClick={handleClose}
className="p-1 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
<svg className="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* 警告信息 */}
<div className="px-6 py-4 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-100 dark:border-amber-800">
<p className="text-sm text-amber-700 dark:text-amber-400">
对象存储配置是系统核心参数,修改不当可能导致素材无法上传或访问。请确认您了解修改的影响。
</p>
</div>
{/* 内容 */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
超级管理员密码
</label>
<input
type="password"
value={password}
onChange={(e) => 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
/>
</div>
{error && (
<div className="px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={handleClose}
className={cn(
'flex-1 px-4 py-3 rounded-xl font-medium text-sm transition-all',
'bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600',
'text-slate-700 dark:text-slate-300'
)}
>
取消
</button>
<button
type="submit"
disabled={loading}
className={cn(
'flex-1 px-4 py-3 rounded-xl font-medium text-sm transition-all',
'bg-amber-600 hover:bg-amber-700 text-white',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{loading ? '验证中...' : '确认修改'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -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 (
<Modal
isOpen={isOpen}
onClose={onClose}
size="small"
showCloseButton={true}
>
<div className="p-6 space-y-6" data-testid="upgrade-modal">
{/* 图标和标题 */}
<div className="text-center space-y-3">
<div className={cn(
'mx-auto w-16 h-16 rounded-full flex items-center justify-center',
'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-100',
'border border-slate-200 dark:border-slate-700'
)}>
<svg
className="w-8 h-8 text-slate-700 dark:text-slate-100"
stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h3
className="text-xl font-semibold text-slate-900 dark:text-white"
data-testid="upgrade-modal-feature-name"
>
{displayName}
</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">
当前许可证未包含此功能,请联系管理员处理授权
</p>
</div>
{/* 功能描述 */}
<div className={cn(
'p-4 rounded-xl',
'bg-slate-50 dark:bg-slate-800/50',
'border border-slate-200 dark:border-slate-700'
)}>
<p className="text-sm text-slate-600 dark:text-slate-400">
{displayDescription}
</p>
</div>
{/* 按钮 */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className={cn(
styles.buttonStyles.secondary,
'flex-1 text-sm font-medium transition-all'
)}
>
关闭
</button>
<button
type="button"
onClick={handleUpgradeClick}
className={cn(
'flex-1 px-4 py-2 rounded-xl text-sm font-medium transition-all',
'bg-slate-900 text-white hover:bg-slate-800',
'dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white'
)}
>
联系管理员
</button>
</div>
</div>
</Modal>
)
}

View File

@ -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(
<ActivationModal
isOpen={true}
onClose={mockOnClose}
/>
)
expect(screen.getByText('激活许可证')).toBeInTheDocument()
})
it('当 isOpen 为 false 时不应该渲染弹窗', () => {
render(
<ActivationModal
isOpen={false}
onClose={mockOnClose}
/>
)
expect(screen.queryByText('激活许可证')).not.toBeInTheDocument()
})
it('应该显示激活码输入框', () => {
render(
<ActivationModal
isOpen={true}
onClose={mockOnClose}
/>
)
expect(screen.getByPlaceholderText('FLNXI-PRO-XXXX-XXXX-XXXX-XXXX')).toBeInTheDocument()
})
it('应该显示格式提示', () => {
render(
<ActivationModal
isOpen={true}
onClose={mockOnClose}
/>
)
expect(screen.getByText('激活码格式:FLNXI-PRO-XXXX-XXXX-XXXX-XXXX')).toBeInTheDocument()
})
it('应该显示立即激活按钮', () => {
render(
<ActivationModal
isOpen={true}
onClose={mockOnClose}
/>
)
expect(screen.getByText('立即激活')).toBeInTheDocument()
})
it('应该显示购买链接', () => {
render(
<ActivationModal
isOpen={true}
onClose={mockOnClose}
/>
)
expect(screen.getByText('还没有激活码?')).toBeInTheDocument()
expect(screen.getByText('前往购买 →')).toBeInTheDocument()
})
})
describe('输入交互', () => {
it('输入应该自动转换为大写', () => {
render(
<ActivationModal
isOpen={true}
onClose={mockOnClose}
/>
)
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(
<ActivationModal
isOpen={true}
onClose={mockOnClose}
/>
)
fireEvent.click(screen.getByText('立即激活'))
expect(await screen.findByText('请输入激活码')).toBeInTheDocument()
})
it('格式不正确的激活码应该显示错误', async () => {
render(
<ActivationModal
isOpen={true}
onClose={mockOnClose}
/>
)
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(
<ActivationModal
isOpen={true}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
)
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(
<ActivationModal
isOpen={true}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
)
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(
<ActivationModal
isOpen={true}
onClose={mockOnClose}
/>
)
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(
<ActivationModal
isOpen={true}
onClose={mockOnClose}
/>
)
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(
<ActivationModal
isOpen={true}
onClose={mockOnClose}
/>
)
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(
<ActivationModal
isOpen={true}
onClose={mockOnClose}
/>
)
// 找到关闭按钮(SVG 的父元素)
const closeButton = screen.getByText('激活许可证').parentElement?.querySelector('button')
if (closeButton) {
fireEvent.click(closeButton)
expect(mockOnClose).toHaveBeenCalled()
}
})
it('点击背景遮罩应该调用 onClose', () => {
render(
<ActivationModal
isOpen={true}
onClose={mockOnClose}
/>
)
// 点击背景遮罩
const overlay = document.querySelector('.backdrop-blur-sm')
if (overlay) {
fireEvent.click(overlay)
expect(mockOnClose).toHaveBeenCalled()
}
})
})
describe('购买链接', () => {
it('点击购买链接应该打开新窗口', () => {
render(
<ActivationModal
isOpen={true}
onClose={mockOnClose}
/>
)
fireEvent.click(screen.getByText('前往购买 →'))
expect(mockOpen).toHaveBeenCalledWith('https://www.flnxi.com/pricing', '_blank')
})
})
})

View File

@ -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(<ProBadge />)
expect(screen.getByText('PRO')).toBeInTheDocument()
})
it('应该有正确的 data-testid', () => {
render(<ProBadge />)
expect(screen.getByTestId('pro-badge')).toBeInTheDocument()
})
it('应该使用默认的 small 尺寸', () => {
render(<ProBadge />)
const badge = screen.getByTestId('pro-badge')
expect(badge).toHaveAttribute('data-size', 'small')
})
it('应该应用自定义 className', () => {
render(<ProBadge className="custom-class" />)
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<ProBadgeSize, { textClass: string; paddingClass: string }> = {
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(<ProBadge size={size} />)
const badge = screen.getByTestId('pro-badge')
expect(badge).toHaveAttribute('data-size', size)
})
it(`${size} 尺寸应该有正确的文字大小类`, () => {
render(<ProBadge size={size} />)
const badge = screen.getByTestId('pro-badge')
expect(badge.className).toContain(sizeExpectations[size].textClass)
})
it(`${size} 尺寸应该有正确的内边距类`, () => {
render(<ProBadge size={size} />)
const badge = screen.getByTestId('pro-badge')
expect(badge.className).toContain(sizeExpectations[size].paddingClass)
})
})
it('不同尺寸应该产生不同的样式', () => {
const { rerender } = render(<ProBadge size="small" />)
const smallBadge = screen.getByTestId('pro-badge')
const smallClassName = smallBadge.className
rerender(<ProBadge size="large" />)
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(<ProBadge />)
const badge = screen.getByTestId('pro-badge')
expect(badge.className).toContain('bg-slate-100')
})
it('深色主题应该有正确的阴影样式', () => {
mockUseTheme.mockReturnValue('dark')
render(<ProBadge />)
const badge = screen.getByTestId('pro-badge')
expect(badge.className).toContain('bg-slate-800')
})
it('两种主题都应该有中性色背景', () => {
mockUseTheme.mockReturnValue('light')
const { rerender } = render(<ProBadge />)
let badge = screen.getByTestId('pro-badge')
expect(badge.className).toContain('bg-slate-100')
expect(badge.className).toContain('text-slate-700')
mockUseTheme.mockReturnValue('dark')
rerender(<ProBadge />)
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(<ProBadge />)
let badge = screen.getByTestId('pro-badge')
expect(badge.className).toContain('text-slate-700')
mockUseTheme.mockReturnValue('dark')
rerender(<ProBadge />)
badge = screen.getByTestId('pro-badge')
expect(badge.className).toContain('text-slate-200')
})
})
describe('视觉样式', () => {
it('应该有圆角样式', () => {
render(<ProBadge />)
const badge = screen.getByTestId('pro-badge')
expect(badge.className).toContain('rounded-full')
})
it('应该有中性色背景', () => {
render(<ProBadge />)
const badge = screen.getByTestId('pro-badge')
expect(badge.className).toContain('bg-slate-100')
})
it('应该有阴影效果', () => {
render(<ProBadge />)
const badge = screen.getByTestId('pro-badge')
expect(badge.className).toContain('shadow-sm')
})
it('应该是 inline-flex 以便内联使用', () => {
render(<ProBadge />)
const badge = screen.getByTestId('pro-badge')
expect(badge.className).toContain('inline-flex')
})
it('应该有加粗字体', () => {
render(<ProBadge />)
const badge = screen.getByTestId('pro-badge')
expect(badge.className).toContain('font-semibold')
})
})
})

View File

@ -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(
<RestrictedToggle
label={label}
featureKey={key}
value={false}
onChange={() => {}}
isRestricted={true}
/>
)
// 应该显示 PRO 徽章
expect(screen.getByTestId('pro-badge')).toBeInTheDocument()
expect(screen.getByText('PRO')).toBeInTheDocument()
})
it(`不受限的 ${label} 功能不应该显示 PRO 徽章`, () => {
render(
<RestrictedToggle
label={label}
featureKey={key}
value={false}
onChange={() => {}}
isRestricted={false}
/>
)
// 不应该显示 PRO 徽章
expect(screen.queryByTestId('pro-badge')).not.toBeInTheDocument()
})
})
it('PRO 徽章应该使用一致的视觉样式', () => {
const { rerender } = render(
<RestrictedToggle
label="功能1"
featureKey="customTemplates"
value={false}
onChange={() => {}}
isRestricted={true}
/>
)
const badge1 = screen.getByTestId('pro-badge')
const badge1ClassName = badge1.className
rerender(
<RestrictedToggle
label="功能2"
featureKey="departmentIsolation"
value={false}
onChange={() => {}}
isRestricted={true}
/>
)
const badge2 = screen.getByTestId('pro-badge')
const badge2ClassName = badge2.className
// 两个徽章应该有相同的样式类
expect(badge1ClassName).toBe(badge2ClassName)
})
it('PRO 徽章应该使用中性工作台样式', () => {
render(
<RestrictedToggle
label="测试功能"
featureKey="customTemplates"
value={false}
onChange={() => {}}
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(
<RestrictedToggle
label="测试功能"
featureKey="customTemplates"
value={false}
onChange={() => {}}
isRestricted={true}
/>
)
expect(screen.getByText('PRO')).toBeInTheDocument()
})
})

View File

@ -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(<RestrictedToggle {...defaultProps} />)
expect(screen.getByText('测试功能')).toBeInTheDocument()
})
it('应该正确渲染描述', () => {
render(<RestrictedToggle {...defaultProps} description="这是功能描述" />)
expect(screen.getByText('这是功能描述')).toBeInTheDocument()
})
it('应该有正确的 data-testid', () => {
render(<RestrictedToggle {...defaultProps} />)
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(<RestrictedToggle {...defaultProps} isRestricted={true} />)
const toggleButton = screen.getByTestId('toggle-button')
fireEvent.click(toggleButton)
// 应该显示升级弹窗
expect(screen.getByTestId('upgrade-modal')).toBeInTheDocument()
})
it('受限时点击不应该调用 onChange', () => {
render(<RestrictedToggle {...defaultProps} isRestricted={true} />)
const toggleButton = screen.getByTestId('toggle-button')
fireEvent.click(toggleButton)
// 不应该调用 onChange
expect(mockOnChange).not.toHaveBeenCalled()
})
it('受限时开关值不应该改变', () => {
const { rerender } = render(
<RestrictedToggle {...defaultProps} isRestricted={true} value={false} />
)
const toggleButton = screen.getByTestId('toggle-button')
fireEvent.click(toggleButton)
// 值应该保持不变
expect(mockOnChange).not.toHaveBeenCalled()
// 重新渲染确认值没变
rerender(<RestrictedToggle {...defaultProps} isRestricted={true} value={false} />)
expect(toggleButton).toHaveAttribute('aria-checked', 'false')
})
it('不受限时点击应该调用 onChange', () => {
render(<RestrictedToggle {...defaultProps} isRestricted={false} />)
const toggleButton = screen.getByTestId('toggle-button')
fireEvent.click(toggleButton)
// 应该调用 onChange
expect(mockOnChange).toHaveBeenCalledWith(true)
})
it('不受限时点击不应该显示升级弹窗', () => {
render(<RestrictedToggle {...defaultProps} isRestricted={false} />)
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(<RestrictedToggle {...defaultProps} isRestricted={true} />)
expect(screen.getByTestId('pro-badge')).toBeInTheDocument()
})
it('不受限时不应该显示 PRO 徽章', () => {
render(<RestrictedToggle {...defaultProps} isRestricted={false} />)
expect(screen.queryByTestId('pro-badge')).not.toBeInTheDocument()
})
it('受限时开关应该有禁用样式', () => {
render(<RestrictedToggle {...defaultProps} isRestricted={true} />)
const toggleButton = screen.getByTestId('toggle-button')
expect(toggleButton.className).toContain('opacity-50')
expect(toggleButton.className).toContain('cursor-not-allowed')
})
it('不受限时开关应该有正常样式', () => {
render(<RestrictedToggle {...defaultProps} isRestricted={false} />)
const toggleButton = screen.getByTestId('toggle-button')
expect(toggleButton.className).toContain('cursor-pointer')
expect(toggleButton.className).not.toContain('opacity-50')
})
it('受限时应该显示升级提示文字', () => {
render(<RestrictedToggle {...defaultProps} isRestricted={true} />)
expect(screen.getByTestId('restricted-hint')).toBeInTheDocument()
expect(screen.getByText('升级到付费版解锁此功能')).toBeInTheDocument()
})
it('不受限时不应该显示升级提示文字', () => {
render(<RestrictedToggle {...defaultProps} isRestricted={false} />)
expect(screen.queryByTestId('restricted-hint')).not.toBeInTheDocument()
})
it('受限时标签应该有降低的不透明度', () => {
render(<RestrictedToggle {...defaultProps} isRestricted={true} />)
const label = screen.getByText('测试功能')
expect(label.className).toContain('opacity-60')
})
it('受限时 data-restricted 属性应该为 true', () => {
render(<RestrictedToggle {...defaultProps} isRestricted={true} />)
const toggle = screen.getByTestId('restricted-toggle')
expect(toggle).toHaveAttribute('data-restricted', 'true')
})
it('不受限时 data-restricted 属性应该为 false', () => {
render(<RestrictedToggle {...defaultProps} isRestricted={false} />)
const toggle = screen.getByTestId('restricted-toggle')
expect(toggle).toHaveAttribute('data-restricted', 'false')
})
})
describe('开关状态', () => {
it('value 为 true 时开关应该在右侧', () => {
render(<RestrictedToggle {...defaultProps} value={true} />)
const toggleButton = screen.getByTestId('toggle-button')
expect(toggleButton).toHaveAttribute('aria-checked', 'true')
})
it('value 为 false 时开关应该在左侧', () => {
render(<RestrictedToggle {...defaultProps} value={false} />)
const toggleButton = screen.getByTestId('toggle-button')
expect(toggleButton).toHaveAttribute('aria-checked', 'false')
})
it('受限时 aria-disabled 应该为 true', () => {
render(<RestrictedToggle {...defaultProps} isRestricted={true} />)
const toggleButton = screen.getByTestId('toggle-button')
expect(toggleButton).toHaveAttribute('aria-disabled', 'true')
})
it('不受限时 aria-disabled 应该为 false', () => {
render(<RestrictedToggle {...defaultProps} isRestricted={false} />)
const toggleButton = screen.getByTestId('toggle-button')
expect(toggleButton).toHaveAttribute('aria-disabled', 'false')
})
})
describe('升级弹窗交互', () => {
it('关闭升级弹窗后应该隐藏弹窗', () => {
render(<RestrictedToggle {...defaultProps} isRestricted={true} />)
// 打开弹窗
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()
})
})
})

View File

@ -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(
<UpgradeModal
isOpen={true}
onClose={mockOnClose}
featureKey="customTemplates"
/>
)
expect(screen.getByTestId('upgrade-modal')).toBeInTheDocument()
})
it('当 isOpen 为 false 时不应该渲染弹窗', () => {
render(
<UpgradeModal
isOpen={false}
onClose={mockOnClose}
featureKey="customTemplates"
/>
)
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(
<UpgradeModal
isOpen={true}
onClose={mockOnClose}
featureKey={featureKey}
/>
)
const expectedName = FEATURE_DESCRIPTIONS[featureKey].name
expect(screen.getByTestId('upgrade-modal-feature-name')).toHaveTextContent(expectedName)
})
})
it('应该允许自定义功能名称覆盖默认值', () => {
const customName = '自定义功能名称'
render(
<UpgradeModal
isOpen={true}
onClose={mockOnClose}
featureKey="customTemplates"
featureName={customName}
/>
)
expect(screen.getByTestId('upgrade-modal-feature-name')).toHaveTextContent(customName)
})
it('应该显示功能描述', () => {
render(
<UpgradeModal
isOpen={true}
onClose={mockOnClose}
featureKey="customTemplates"
/>
)
const expectedDescription = FEATURE_DESCRIPTIONS.customTemplates.description
expect(screen.getByText(expectedDescription)).toBeInTheDocument()
})
it('应该允许自定义功能描述覆盖默认值', () => {
const customDescription = '自定义功能描述'
render(
<UpgradeModal
isOpen={true}
onClose={mockOnClose}
featureKey="customTemplates"
featureDescription={customDescription}
/>
)
expect(screen.getByText(customDescription)).toBeInTheDocument()
})
})
describe('限制提示显示', () => {
it('应该显示当前许可证限制提示', () => {
render(
<UpgradeModal
isOpen={true}
onClose={mockOnClose}
featureKey="customTemplates"
/>
)
expect(screen.getByText('当前许可证未包含此功能,请联系管理员处理授权')).toBeInTheDocument()
})
})
describe('按钮交互', () => {
it('点击"关闭"按钮应该调用 onClose', () => {
render(
<UpgradeModal
isOpen={true}
onClose={mockOnClose}
featureKey="customTemplates"
/>
)
fireEvent.click(screen.getByText('关闭'))
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('点击"联系管理员"按钮应该调用 onClose', () => {
render(
<UpgradeModal
isOpen={true}
onClose={mockOnClose}
featureKey="customTemplates"
/>
)
fireEvent.click(screen.getByText('联系管理员'))
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('应该显示两个按钮', () => {
render(
<UpgradeModal
isOpen={true}
onClose={mockOnClose}
featureKey="customTemplates"
/>
)
expect(screen.getByText('关闭')).toBeInTheDocument()
expect(screen.getByText('联系管理员')).toBeInTheDocument()
})
})
describe('视觉元素', () => {
it('应该显示锁图标', () => {
render(
<UpgradeModal
isOpen={true}
onClose={mockOnClose}
featureKey="customTemplates"
/>
)
// 检查 SVG 图标存在
const modal = screen.getByTestId('upgrade-modal')
const svgElements = modal.querySelectorAll('svg')
expect(svgElements.length).toBeGreaterThan(0)
})
it('应该显示限制说明文字', () => {
render(
<UpgradeModal
isOpen={true}
onClose={mockOnClose}
featureKey="customTemplates"
/>
)
expect(screen.getByText('当前许可证未包含此功能,请联系管理员处理授权')).toBeInTheDocument()
})
})
})

109
hooks/useLicenseStatus.ts Normal file
View File

@ -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<FeatureKey, boolean>
lastVerified: string
}
interface LicenseStatus {
/** 许可证类型 */
type: LicenseType
/** 是否正在加载 */
loading: boolean
/** 错误信息 */
error: string | null
/** 完整的许可证数据 */
data: LicenseData | null
/** 检查功能是否可用 */
isFeatureEnabled: (feature: FeatureKey) => boolean
/** 是否为免费版 */
isFree: boolean
/** 刷新许可证状态 */
refresh: () => Promise<void>
}
/**
* 获取许可证状态的 Hook
*/
export function useLicenseStatus(): LicenseStatus {
const [data, setData] = useState<LicenseData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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

View File

@ -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);
});
});
});

View File

@ -19,7 +19,7 @@ export async function canCreateUser(): Promise<LimitCheckResult> {
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<LimitCheckResult> {
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<LimitCheckResult> {
/**
* 检查自定义模板功能是否可用
*/
export function canUseCustomTemplates(): boolean {
return isFeatureEnabled('customTemplates');
export async function canUseCustomTemplates(): Promise<boolean> {
return await isFeatureEnabled('customTemplates');
}
/**
* 检查部门隔离功能是否可用
*/
export function canUseDepartmentIsolation(): boolean {
return isFeatureEnabled('departmentIsolation');
export async function canUseDepartmentIsolation(): Promise<boolean> {
return await isFeatureEnabled('departmentIsolation');
}
/**
* 检查内网穿透功能是否可用
*/
export function canUseIntranetPenetration(): boolean {
return isFeatureEnabled('intranetPenetration');
export async function canUseIntranetPenetration(): Promise<boolean> {
return await isFeatureEnabled('intranetPenetration');
}
/**
* 检查系统设置功能是否可用
*/
export function canUseSystemSettings(): boolean {
return isFeatureEnabled('systemSettings');
export async function canUseSystemSettings(): Promise<boolean> {
return await isFeatureEnabled('systemSettings');
}
/**
* 检查 API 访问功能是否可用
*/
export function canUseApiAccess(): boolean {
return isFeatureEnabled('apiAccess');
export async function canUseApiAccess(): Promise<boolean> {
return await isFeatureEnabled('apiAccess');
}

View File

@ -0,0 +1,86 @@
/**
* 功能描述映射
*
* 定义每个付费功能的名称、描述和升级好处
* 用于 UpgradeModal 和其他需要展示功能信息的地方
*/
import type { FeatureKey } from './types';
export interface FeatureDescription {
/** 功能名称 */
name: string;
/** 功能描述 */
description: string;
/** 升级好处列表 */
benefits: string[];
}
/**
* 功能描述映射表
*/
export const FEATURE_DESCRIPTIONS: Record<FeatureKey, FeatureDescription> = {
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: ['解锁更多功能', '获得技术支持', '享受更新服务'],
};
}

View File

@ -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<LicenseConfig | null> {
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,47 +125,70 @@ 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();
@ -122,29 +197,23 @@ export async function verify(): Promise<{ valid: boolean; error?: string }> {
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<void> {
export async function heartbeat(): Promise<boolean> {
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,28 +296,32 @@ export async function heartbeat(): Promise<void> {
* 获取当前许可证配置
*/
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<boolean> {
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) {
@ -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<string> {
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<void> {
// ============ 内部辅助函数 ============
async function saveLicenseLocally(licenseKey: string, license: SignedLicense): Promise<void> {
export async function saveLicenseLocally(licenseKey: string, license: SignedLicense): Promise<void> {
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<void> {
export async function updateLocalLicense(license: SignedLicense): Promise<void> {
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<LocalLicenseData | null> {
/**
* 从本地加载许可证并更新内存缓存
*/
async function loadLicenseFromLocal(): Promise<void> {
const localLicense = await getLocalLicense();
if (localLicense) {
cachedLicense = localLicense.cachedConfig;
lastVerified = new Date(localLicense.lastVerified);
} else {
cachedLicense = null;
lastVerified = null;
}
}
export async function getLocalLicense(): Promise<LocalLicenseData | null> {
try {
const config = await prisma.systemConfig.findUnique({
where: { key: 'license' },
@ -357,12 +452,27 @@ async function getLocalLicense(): Promise<LocalLicenseData | null> {
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<void> {
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);

View File

@ -144,7 +144,7 @@ export const STANDARD_FIELDS: Record<string, StandardFieldConfig> = {
},
audioScene: {
key: 'audioScene',
label: '使用场景',
label: '音频使用场景',
type: 'select',
required: true,
layoutWidth: 'full',