收口登录激活私有化入口

This commit is contained in:
WuYongJie 2026-05-28 18:18:03 +08:00
parent 91825ee591
commit bf80d3d801
5 changed files with 107 additions and 70 deletions

View File

@ -3,7 +3,7 @@
*/
/**
* 激活页面测试
* 测试许可证激活和免费试用功能
* 测试许可证激活和自助试用功能
*/
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import ActivatePage from '../page';
@ -60,6 +60,7 @@ describe('ActivatePage', () => {
beforeEach(() => {
jest.clearAllMocks();
(global.fetch as jest.Mock).mockReset();
delete process.env.NEXT_PUBLIC_ENABLE_FREE_TRIAL_ACTIVATION;
mockFetchRoutes({
'/api/license/check': createJsonResponse({ activated: false }),
'*': createJsonResponse({ success: true }),
@ -71,20 +72,20 @@ describe('ActivatePage', () => {
await renderPage();
expect(screen.getByText('FLNXI 素材系统')).toBeInTheDocument();
expect(screen.getByText('请激活您的许可证')).toBeInTheDocument();
expect(screen.getByText('请输入交付给您的许可证密钥')).toBeInTheDocument();
expect(screen.getByPlaceholderText('XXXX-XXXX-XXXX-XXXX')).toBeInTheDocument();
expect(screen.getByText('激活许可证')).toBeInTheDocument();
expect(screen.getByText('免费试用')).toBeInTheDocument();
expect(screen.queryByText('自助试用')).not.toBeInTheDocument();
});
it('应该显示免费版限制说明', async () => {
it('默认不显示自助试用说明', async () => {
await renderPage();
expect(screen.getByText('免费版限制:10 个用户、100 个素材')).toBeInTheDocument();
expect(screen.queryByText('自助试用会按当前授权服务规则生成受限许可证')).not.toBeInTheDocument();
});
it('应该显示联系信息', async () => {
await renderPage();
expect(screen.getByText('需要购买许可证?请联系我们')).toBeInTheDocument();
expect(screen.getByText('如需许可证,请联系系统交付人员或管理员')).toBeInTheDocument();
});
});
@ -285,24 +286,28 @@ describe('ActivatePage', () => {
});
});
describe('免费试用', () => {
it('点击免费试用按钮应该显示客户名称输入弹窗', async () => {
describe('自助试用(显式开关开启时)', () => {
beforeEach(() => {
process.env.NEXT_PUBLIC_ENABLE_FREE_TRIAL_ACTIVATION = 'true';
});
it('点击自助试用按钮应该显示交付单位输入弹窗', async () => {
await renderPage();
const button = screen.getByText('免费试用');
const button = screen.getByText('自助试用');
await act(async () => {
fireEvent.click(button);
});
expect(screen.getByText('申请免费试用')).toBeInTheDocument();
expect(screen.getByPlaceholderText('请输入您的公司或个人名称')).toBeInTheDocument();
expect(screen.getByText('申请自助试用')).toBeInTheDocument();
expect(screen.getByPlaceholderText('请输入单位或团队名称')).toBeInTheDocument();
expect(screen.getByText('确认申请')).toBeInTheDocument();
expect(screen.getByText('取消')).toBeInTheDocument();
});
it('未输入客户名称时确认申请按钮应该禁用', async () => {
await renderPage();
const freeTrialButton = screen.getByText('免费试用');
const freeTrialButton = screen.getByText('自助试用');
await act(async () => {
fireEvent.click(freeTrialButton);
@ -314,13 +319,13 @@ describe('ActivatePage', () => {
it('只输入客户名称未输入联系方式时确认申请按钮应该禁用', async () => {
await renderPage();
const freeTrialButton = screen.getByText('免费试用');
const freeTrialButton = screen.getByText('自助试用');
await act(async () => {
fireEvent.click(freeTrialButton);
});
const nameInput = screen.getByPlaceholderText('请输入您的公司或个人名称');
const nameInput = screen.getByPlaceholderText('请输入单位或团队名称');
fireEvent.change(nameInput, { target: { value: '测试公司' } });
const confirmButton = screen.getByText('确认申请');
@ -329,13 +334,13 @@ describe('ActivatePage', () => {
it('输入客户名称和联系方式后确认申请按钮应该启用', async () => {
await renderPage();
const freeTrialButton = screen.getByText('免费试用');
const freeTrialButton = screen.getByText('自助试用');
await act(async () => {
fireEvent.click(freeTrialButton);
});
const nameInput = screen.getByPlaceholderText('请输入您的公司或个人名称');
const nameInput = screen.getByPlaceholderText('请输入单位或团队名称');
const contactInput = screen.getByPlaceholderText('手机号/微信/邮箱');
fireEvent.change(nameInput, { target: { value: '测试公司' } });
fireEvent.change(contactInput, { target: { value: '13800138000' } });
@ -346,23 +351,23 @@ describe('ActivatePage', () => {
it('点击取消按钮应该关闭弹窗', async () => {
await renderPage();
const freeTrialButton = screen.getByText('免费试用');
const freeTrialButton = screen.getByText('自助试用');
await act(async () => {
fireEvent.click(freeTrialButton);
});
expect(screen.getByText('申请免费试用')).toBeInTheDocument();
expect(screen.getByText('申请自助试用')).toBeInTheDocument();
const cancelButton = screen.getByText('取消');
await act(async () => {
fireEvent.click(cancelButton);
});
expect(screen.queryByText('申请免费试用')).not.toBeInTheDocument();
expect(screen.queryByText('申请自助试用')).not.toBeInTheDocument();
});
it('免费试用成功后应该显示成功页面', async () => {
it('自助试用成功后应该显示成功页面', async () => {
mockFetchRoutes({
'/api/license/check': createJsonResponse({ activated: false }),
'/api/license/activate-free': createJsonResponse({
@ -378,14 +383,14 @@ describe('ActivatePage', () => {
await renderPage();
// 点击免费试用按钮打开弹窗
const freeTrialButton = screen.getByText('免费试用');
// 点击自助试用按钮打开弹窗
const freeTrialButton = screen.getByText('自助试用');
await act(async () => {
fireEvent.click(freeTrialButton);
});
// 输入客户名称和联系方式
const nameInput = screen.getByPlaceholderText('请输入您的公司或个人名称');
const nameInput = screen.getByPlaceholderText('请输入单位或团队名称');
const contactInput = screen.getByPlaceholderText('手机号/微信/邮箱');
fireEvent.change(nameInput, { target: { value: '测试公司' } });
fireEvent.change(contactInput, { target: { value: '13800138000' } });
@ -408,7 +413,7 @@ describe('ActivatePage', () => {
});
});
it('免费试用失败应该显示错误信息', async () => {
it('自助试用失败应该显示错误信息', async () => {
mockFetchRoutes({
'/api/license/check': createJsonResponse({ activated: false }),
'/api/license/activate-free': createJsonResponse({ success: false, error: '试用已过期' }),
@ -416,14 +421,14 @@ describe('ActivatePage', () => {
await renderPage();
// 点击免费试用按钮打开弹窗
const freeTrialButton = screen.getByText('免费试用');
// 点击自助试用按钮打开弹窗
const freeTrialButton = screen.getByText('自助试用');
await act(async () => {
fireEvent.click(freeTrialButton);
});
// 输入客户名称和联系方式
const nameInput = screen.getByPlaceholderText('请输入您的公司或个人名称');
const nameInput = screen.getByPlaceholderText('请输入单位或团队名称');
const contactInput = screen.getByPlaceholderText('手机号/微信/邮箱');
fireEvent.change(nameInput, { target: { value: '测试公司' } });
fireEvent.change(contactInput, { target: { value: '13800138000' } });
@ -447,14 +452,14 @@ describe('ActivatePage', () => {
await renderPage();
// 点击免费试用按钮打开弹窗
const freeTrialButton = screen.getByText('免费试用');
// 点击自助试用按钮打开弹窗
const freeTrialButton = screen.getByText('自助试用');
await act(async () => {
fireEvent.click(freeTrialButton);
});
// 输入客户名称和联系方式
const nameInput = screen.getByPlaceholderText('请输入您的公司或个人名称');
const nameInput = screen.getByPlaceholderText('请输入单位或团队名称');
const contactInput = screen.getByPlaceholderText('手机号/微信/邮箱');
fireEvent.change(nameInput, { target: { value: '测试公司' } });
fireEvent.change(contactInput, { target: { value: '13800138000' } });
@ -472,7 +477,7 @@ describe('ActivatePage', () => {
it('应该显示联系方式输入框', async () => {
await renderPage();
const freeTrialButton = screen.getByText('免费试用');
const freeTrialButton = screen.getByText('自助试用');
await act(async () => {
fireEvent.click(freeTrialButton);

View File

@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
export default function ActivatePage() {
const router = useRouter();
const freeTrialActivationEnabled = process.env.NEXT_PUBLIC_ENABLE_FREE_TRIAL_ACTIVATION === 'true';
const [initialAdmin, setInitialAdmin] = useState<{
email: string;
username: string;
@ -90,15 +91,25 @@ export default function ActivatePage() {
}
};
// 打开免费试用弹窗
// 打开自助试用弹窗
const openFreeTrialModal = () => {
if (!freeTrialActivationEnabled) {
setError('当前部署未开放自助试用,请使用许可证密钥或联系系统交付人员');
return;
}
setCustomerName('');
setContactInfo('');
setShowFreeTrialModal(true);
};
// 免费试用
// 自助试用
const handleFreeTrial = async () => {
if (!freeTrialActivationEnabled) {
setError('当前部署未开放自助试用,请使用许可证密钥或联系系统交付人员');
setShowFreeTrialModal(false);
return;
}
const trimmedName = customerName.trim();
const trimmedContact = contactInfo.trim();
@ -238,7 +249,7 @@ export default function ActivatePage() {
{/* Logo */}
<div className="text-center mb-8">
<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>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-2">请输入交付给您的许可证密钥</p>
</div>
{/* 许可证输入 */}
@ -273,36 +284,40 @@ export default function ActivatePage() {
{loading ? '激活中...' : '激活许可证'}
</button>
{/* 分隔线 */}
<div className="my-6 flex items-center">
<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>
{freeTrialActivationEnabled && (
<>
{/* 分隔线 */}
<div className="my-6 flex items-center">
<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={openFreeTrialModal}
disabled={loading}
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>
{/* 自助试用 */}
<button
onClick={openFreeTrialModal}
disabled={loading}
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-slate-400">
免费版限制:10 个用户、100 个素材
</p>
{/* 免费版说明 */}
<p className="mt-4 text-center text-xs text-slate-400">
自助试用会按当前授权服务规则生成受限许可证
</p>
</>
)}
</div>
{/* 联系信息 */}
<p className="mt-6 text-center text-sm text-slate-500">
需要购买许可证?请联系我们
如需许可证,请联系系统交付人员或管理员
</p>
</div>
{/* 免费试用弹窗 */}
{showFreeTrialModal && (
{/* 自助试用弹窗 */}
{freeTrialActivationEnabled && 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"
@ -311,16 +326,16 @@ export default function ActivatePage() {
handleFreeTrial();
}}
>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">申请免费试用</h3>
<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>
交付单位名称 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
placeholder="请输入您的公司或个人名称"
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
@ -338,17 +353,18 @@ export default function ActivatePage() {
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>
<p className="mt-1 text-xs text-slate-500">用于授权联系和交付核验</p>
</div>
<div className="flex gap-3">
<button
type="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}
type="submit"
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"
>

View File

@ -44,6 +44,7 @@ function getPublicSystemSettings(settings: SystemSettingsRow) {
createdAt: settings.createdAt,
headerLogoUrl: normalizeLogoUrl(settings.headerLogoUrl),
loginLogoUrl: normalizeLogoUrl(settings.loginLogoUrl),
enableRegistration: settings.enableRegistration,
}
}

View File

@ -16,6 +16,12 @@ type DingtalkPublicConfig = {
callbackUrl: string | null
}
type PublicSystemSettings = {
loginLogoUrl?: string | null
siteName?: string | null
enableRegistration?: boolean | null
}
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
@ -28,6 +34,7 @@ export default function LoginPage() {
// 系统设置
const [loginLogoUrl, setLoginLogoUrl] = useState(DEFAULT_LOGIN_LOGO)
const [siteName, setSiteName] = useState('FLNXI素材库')
const [registrationEnabled, setRegistrationEnabled] = useState(false)
const [dingtalkConfig, setDingtalkConfig] = useState<DingtalkPublicConfig>({
enabled: false,
clientId: null,
@ -72,9 +79,10 @@ export default function LoginPage() {
try {
const response = await fetch('/api/system-settings')
if (response.ok) {
const settings = await response.json()
const settings = await response.json() as PublicSystemSettings
setLoginLogoUrl(settings.loginLogoUrl || DEFAULT_LOGIN_LOGO)
setSiteName(settings.siteName || 'FLNXI素材库')
setRegistrationEnabled(settings.enableRegistration === true)
}
} catch (error) {
console.error('加载系统设置失败:', error)
@ -417,13 +425,19 @@ export default function LoginPage() {
<div className="mt-6 text-center">
<p className="text-sm text-slate-600 dark:text-slate-400">
还没有账号?{' '}
<Link
href="/register"
className="text-slate-900 dark:text-slate-100 hover:text-slate-700 dark:hover:text-slate-300 font-medium underline"
>
立即注册
</Link>
{registrationEnabled ? (
<>
还没有账号?{' '}
<Link
href="/register"
className="text-slate-900 dark:text-slate-100 hover:text-slate-700 dark:hover:text-slate-300 font-medium underline"
>
申请加入
</Link>
</>
) : (
'没有账号?请联系管理员开通'
)}
</p>
</div>
</div>

View File

@ -1137,19 +1137,20 @@ NAS 私有化系统可能在平板或窄屏浏览器里使用。模板编辑器
证据:
- `app/login/page.tsx:11`:默认登录 Logo 不再指向不存在的 `/logo/logo2.png`,没有配置时直接走文字占位。
- `app/api/system-settings/route.ts`、`app/login/page.tsx`:公共系统设置现在返回注册开关;登录页不再固定展示“立即注册”,注册开启时改为“申请加入”,关闭时提示联系管理员开通。
- `app/login/page.tsx:295`:登录页已改为浅灰背景和中性表单,不再使用浅蓝渐变背景。
- `app/register/page.tsx:21`:注册页已改为和登录页一致的浅灰背景。
- `app/register/page.tsx:88`:注册页已增加默认 Logo 兜底,没有配置图片时显示文字占位,不再显示破图。
- `app/complete-profile/page.tsx:25`:完善资料页已改为和登录页一致的浅灰背景。
- `app/complete-profile/page.tsx:205`:完善资料页已增加默认 Logo 兜底。
- `app/activate/page.tsx`:激活页已完成第一轮收口,激活表单、激活成功页和免费试用弹窗统一为浅灰背景、普通边框、`rounded-lg` 和轻阴影;许可证输入去掉大字距和偏展示页的大号字体,蓝色主按钮改为中性深色按钮。
- `app/activate/page.tsx`:激活页已完成第二轮收口,默认只展示许可证密钥激活;“免费试用/购买许可证”入口已改为显式环境开关下的“自助试用”,客户默认安装现场不再露出公网获客口径。
- `app/dingtalk-callback/page.tsx`:钉钉回调页已完成第一轮收口,蓝色渐变整屏背景改为浅灰背景,`rounded-2xl shadow-xl` 大卡片改为 `rounded-lg border shadow-sm` 普通状态卡片,加载/成功/失败状态复用同一个中性外壳。
- 代码复扫:`app/dingtalk-callback/page.tsx` 已无 `bg-gradient`、`rounded-2xl`、`shadow-xl`、大尺寸状态图标等命中。
- 验证限制:本轮尝试用 Codex 内置浏览器打开钉钉回调页时返回“没有可用活动面板”,因此该页暂未补截图;已完成 `npm run type-check` 和 `npm run lint` 验证。
建议动作:
- 登录、激活、回调、完善资料页统一成同一套私有化安装/访问风格;钉钉回调页和激活页已完成第一轮视觉收口。
- 登录、激活、回调、完善资料页统一成同一套私有化安装/访问风格;钉钉回调页和激活页已完成第一轮视觉收口,激活页公网试用/购买口径已进一步隐藏。
- 背景用纯色或轻灰,不用大面积渐变。
- 登录页可以保留品牌感,但减少泛 SaaS 视觉:小 Logo、清晰表单、明确客户系统名称即可。