完善许可证受限入口与外观管理
This commit is contained in:
parent
ab29682ada
commit
6b4ba0f861
@ -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 () => {
|
||||
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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
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 () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
json: () => Promise.resolve({ success: false, error: '许可证无效' }),
|
||||
mockFetchRoutes({
|
||||
'/api/license/check': createJsonResponse({ activated: false }),
|
||||
'/api/license/activate': createJsonResponse({ success: false, error: '许可证无效' }),
|
||||
});
|
||||
|
||||
render(<ActivatePage />);
|
||||
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' } });
|
||||
|
||||
|
||||
@ -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 = '请填写完整的客户信息(客户名称和联系方式都是必填项)';
|
||||
}
|
||||
} catch {
|
||||
setError(errorMsg);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('激活请求失败:', error);
|
||||
setError('网络错误,请检查网络连接');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 检查中或已激活,显示加载状态
|
||||
if (checking) {
|
||||
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="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 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">
|
||||
<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-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">
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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,23 +1808,23 @@ 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
|
||||
@ -1837,12 +1837,10 @@ 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="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}
|
||||
@ -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">“{migrationSourceTemplate.name}”</span> 有{' '}
|
||||
<span className="font-bold text-blue-600 dark:text-blue-400">{migrationSourceTemplate.materialCount}</span>{' '}
|
||||
个素材正在使用。
|
||||
</p>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
{!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>
|
||||
)}
|
||||
@ -96,9 +130,6 @@ export default function CustomTemplateSection({
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
105
app/api/auth/verify-admin-password/__tests__/route.test.ts
Normal file
105
app/api/auth/verify-admin-password/__tests__/route.test.ts
Normal 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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
69
app/api/auth/verify-admin-password/route.ts
Normal file
69
app/api/auth/verify-admin-password/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
129
app/api/license/info/__tests__/route.test.ts
Normal file
129
app/api/license/info/__tests__/route.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
169
components/ui/ActivationModal.tsx
Normal file
169
components/ui/ActivationModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
components/ui/ProBadge.tsx
Normal file
47
components/ui/ProBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
components/ui/RestrictedToggle.tsx
Normal file
153
components/ui/RestrictedToggle.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
162
components/ui/StorageEditConfirmModal.tsx
Normal file
162
components/ui/StorageEditConfirmModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
components/ui/UpgradeModal.tsx
Normal file
128
components/ui/UpgradeModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
295
components/ui/__tests__/ActivationModal.test.tsx
Normal file
295
components/ui/__tests__/ActivationModal.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
184
components/ui/__tests__/ProBadge.test.tsx
Normal file
184
components/ui/__tests__/ProBadge.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
159
components/ui/__tests__/ProBadgeIntegration.test.tsx
Normal file
159
components/ui/__tests__/ProBadgeIntegration.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
254
components/ui/__tests__/RestrictedToggle.test.tsx
Normal file
254
components/ui/__tests__/RestrictedToggle.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
226
components/ui/__tests__/UpgradeModal.test.tsx
Normal file
226
components/ui/__tests__/UpgradeModal.test.tsx
Normal 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
109
hooks/useLicenseStatus.ts
Normal 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
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
86
lib/license/feature-descriptions.ts
Normal file
86
lib/license/feature-descriptions.ts
Normal 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: ['解锁更多功能', '获得技术支持', '享受更新服务'],
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
if (cachedLicense) {
|
||||
lastVerified = new Date();
|
||||
|
||||
// 启动心跳
|
||||
startHeartbeat();
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
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 表示被吊销
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@ -144,7 +144,7 @@ export const STANDARD_FIELDS: Record<string, StandardFieldConfig> = {
|
||||
},
|
||||
audioScene: {
|
||||
key: 'audioScene',
|
||||
label: '使用场景',
|
||||
label: '音频使用场景',
|
||||
type: 'select',
|
||||
required: true,
|
||||
layoutWidth: 'full',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user