453 lines
18 KiB
TypeScript
453 lines
18 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import Image from 'next/image';
|
||
|
||
const DEFAULT_LOGIN_LOGO = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '';
|
||
|
||
type PublicSystemSettings = {
|
||
loginLogoUrl?: string | null;
|
||
siteName?: string | null;
|
||
};
|
||
|
||
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;
|
||
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('');
|
||
const [loginLogoUrl, setLoginLogoUrl] = useState(DEFAULT_LOGIN_LOGO);
|
||
const [siteName, setSiteName] = useState('FLNXI素材库');
|
||
const [logoError, setLogoError] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const loadSettings = async () => {
|
||
try {
|
||
const response = await fetch('/api/system-settings');
|
||
if (response.ok) {
|
||
const settings = await response.json() as PublicSystemSettings;
|
||
setLoginLogoUrl(settings.loginLogoUrl || DEFAULT_LOGIN_LOGO);
|
||
setSiteName(settings.siteName || 'FLNXI素材库');
|
||
setLogoError(false);
|
||
}
|
||
} catch (error) {
|
||
console.error('加载系统设置失败:', error);
|
||
}
|
||
};
|
||
|
||
loadSettings();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!loginLogoUrl) return;
|
||
|
||
if (loginLogoUrl.startsWith('/')) {
|
||
setLogoError(false);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const parsed = new URL(loginLogoUrl);
|
||
const host = parsed.hostname.toLowerCase();
|
||
const isLocal = host === 'localhost' || host === '127.0.0.1';
|
||
if (isLocal && typeof window !== 'undefined') {
|
||
const currentHost = window.location.hostname.toLowerCase();
|
||
if (currentHost !== host) {
|
||
console.warn(
|
||
`[Activate] Logo 地址 ${loginLogoUrl} 指向本地服务,但当前访问域名为 ${currentHost},自动降级为占位符`
|
||
);
|
||
setLogoError(true);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn('[Activate] Logo 地址解析失败,自动降级为占位符:', loginLogoUrl, error);
|
||
setLogoError(true);
|
||
}
|
||
}, [loginLogoUrl]);
|
||
|
||
// 页面加载时检查数据库中是否已有许可证
|
||
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) => {
|
||
const cleaned = value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||
const parts = [];
|
||
for (let i = 0; i < cleaned.length && i < 16; i += 4) {
|
||
parts.push(cleaned.slice(i, i + 4));
|
||
}
|
||
return parts.join('-');
|
||
};
|
||
|
||
const handleKeyChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
setLicenseKey(formatLicenseKey(e.target.value));
|
||
setError('');
|
||
};
|
||
|
||
// 激活许可证
|
||
const handleActivate = async () => {
|
||
if (!licenseKey || licenseKey.length !== 19) {
|
||
setError('请输入完整的许可证密钥');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
setError('');
|
||
|
||
try {
|
||
const res = await fetch('/api/license/activate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ licenseKey }),
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
setInitialAdmin(data.initialAdmin || null);
|
||
setActivated(true);
|
||
} else {
|
||
setError(data.error || '激活失败,请检查许可证密钥');
|
||
}
|
||
} catch {
|
||
setError('网络错误,请检查网络连接');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 打开临时授权申请弹窗
|
||
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();
|
||
|
||
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
|
||
}),
|
||
});
|
||
|
||
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) {
|
||
setInitialAdmin(data.initialAdmin || null);
|
||
setActivated(true);
|
||
} else {
|
||
// 处理各种错误消息格式
|
||
let errorMsg = data.error || '激活失败,请检查输入信息';
|
||
if (errorMsg === 'Required' || errorMsg.includes('required')) {
|
||
errorMsg = '请填写完整的客户信息(客户名称和联系方式都是必填项)';
|
||
}
|
||
setError(errorMsg);
|
||
}
|
||
} catch (error) {
|
||
console.error('激活请求失败:', error);
|
||
setError('网络错误,请检查网络连接');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 检查中或已激活,显示加载状态
|
||
if (checking) {
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex items-center justify-center p-4">
|
||
<div className="text-center">
|
||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-slate-700 dark:border-slate-300 mb-4"></div>
|
||
<p className="text-slate-600 dark:text-slate-400">正在检查许可证状态...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 激活成功页面
|
||
if (activated) {
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex items-center justify-center p-4">
|
||
<div className="w-full max-w-md">
|
||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 shadow-sm p-6">
|
||
<div className="text-center mb-6">
|
||
<div className="w-10 h-10 bg-emerald-50 dark:bg-emerald-900/20 rounded-full flex items-center justify-center mx-auto mb-4 border border-emerald-100 dark:border-emerald-900/40">
|
||
<svg className="w-5 h-5 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
</div>
|
||
<h1 className="text-xl font-semibold text-slate-900 dark:text-white">激活成功</h1>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-2">系统已准备就绪</p>
|
||
</div>
|
||
|
||
{initialAdmin ? (
|
||
<>
|
||
<div className="bg-slate-50 dark:bg-slate-800/60 border border-slate-200 dark:border-slate-700 rounded-lg p-4 mb-6">
|
||
<h3 className="text-sm font-medium text-slate-800 dark:text-slate-200 mb-3">初始管理员账号</h3>
|
||
<div className="space-y-2 text-sm">
|
||
<div className="flex justify-between gap-3">
|
||
<span className="text-slate-600 dark:text-slate-400">邮箱:</span>
|
||
<span className="font-mono font-medium text-slate-900 dark:text-white break-all text-right">{initialAdmin.email}</span>
|
||
</div>
|
||
{initialAdmin.password ? (
|
||
<div className="flex justify-between gap-3">
|
||
<span className="text-slate-600 dark:text-slate-400">密码:</span>
|
||
<span className="font-mono font-medium text-slate-900 dark:text-white break-all text-right">{initialAdmin.password}</span>
|
||
</div>
|
||
) : initialAdmin.passwordSource === 'environment' ? (
|
||
<p className="text-slate-700 dark:text-slate-300">初始密码已由部署配置提供,系统不会在页面或日志中回显明文。</p>
|
||
) : (
|
||
<p className="text-slate-700 dark:text-slate-300">管理员账号已存在,请使用现有管理员密码登录。</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-100 dark:border-amber-800 rounded-lg p-3 mb-6">
|
||
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||
{initialAdmin.password
|
||
? '初始密码只在当前页面展示一次,首次登录后将强制修改密码。'
|
||
: '首次登录后将强制修改密码。'}
|
||
</p>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="bg-slate-50 dark:bg-slate-800/60 border border-slate-200 dark:border-slate-700 rounded-lg p-4 mb-6">
|
||
<p className="text-sm text-slate-700 dark:text-slate-300">系统已激活,请使用现有管理员账号登录。</p>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
onClick={() => router.push('/login')}
|
||
className="w-full py-3 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 rounded-lg font-medium hover:bg-slate-800 dark:hover:bg-white transition-colors"
|
||
>
|
||
前往登录
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-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">
|
||
<div className="flex justify-center mb-4">
|
||
{loginLogoUrl && !logoError ? (
|
||
<Image
|
||
src={loginLogoUrl}
|
||
alt={siteName}
|
||
width={80}
|
||
height={80}
|
||
unoptimized
|
||
className="rounded-lg border border-slate-200 dark:border-slate-700 shadow-sm"
|
||
onError={() => setLogoError(true)}
|
||
/>
|
||
) : (
|
||
<div className="w-20 h-20 rounded-lg border border-slate-200 dark:border-slate-700 shadow-sm bg-slate-900 dark:bg-slate-700 flex items-center justify-center">
|
||
<span className="text-3xl font-bold text-white">{siteName.charAt(0) || 'F'}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<h1 className="text-xl font-semibold text-slate-900 dark:text-white">{siteName}</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-slate-700 dark:text-slate-300 mb-2">
|
||
许可证密钥
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={licenseKey}
|
||
onChange={handleKeyChange}
|
||
placeholder="XXXX-XXXX-XXXX-XXXX"
|
||
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}
|
||
/>
|
||
</div>
|
||
|
||
{/* 错误提示 */}
|
||
{error && (
|
||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* 激活按钮 */}
|
||
<button
|
||
onClick={handleActivate}
|
||
disabled={loading || licenseKey.length !== 19}
|
||
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>
|
||
|
||
{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>
|
||
|
||
{/* 免费版说明 */}
|
||
<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>
|
||
|
||
{/* 临时授权弹窗 */}
|
||
{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"
|
||
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
|
||
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
|
||
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"
|
||
>
|
||
确认申请
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|