feat(license): 退出授权 / 释放本实例(确认后 deactivate)

设置→授权 Pro 态管理员可退出:在线 LIC- 先调 opsx deactivate(含 activation_token),成功才清本地;失败保留现状。LICOFF- 仅解本机并提示后台名额可能仍占。禁静默清码。
This commit is contained in:
opsx committed 2026-11-01 03:16:05 +08:00
1 parent f3d27ceb04
commit cec7c6eeaf
10 files changed
+338 -3

No files matched your search

+2
View File
@@ -21,4 +21,6 @@ export interface LicenseInfo {
export const licenseApi = {
status: () => http.get<{ license: LicenseInfo; pro: boolean; features: string[] }>('/system/license'),
activate: (key: string) => http.post<LicenseInfo>('/system/license/activate', { key }),
deactivate: () =>
http.post<{ offlineLocalOnly?: boolean; message?: string }>('/system/license/deactivate'),
}
+8
View File
@@ -21,6 +21,14 @@ export default {
activating: 'Activating…',
activated: 'Pro activated — all features unlocked',
activateFailed: 'Activation failed',
deactivate: 'Deactivate / release this instance',
deactivateConfirmTitle: 'Deactivate license?',
deactivateConfirmDesc: 'This releases the seat for this instance and returns the machine to Free. Online keys notify the license server first; on failure local state is kept (never silently cleared).',
deactivateConfirm: 'Confirm deactivate',
deactivating: 'Deactivating…',
deactivated: 'License deactivated — this machine is Free again',
deactivateFailed: 'Deactivation failed',
deactivateOfflineNote: 'Local offline license unbound. The server seat may still be occupied — ask ops to clear it if needed.',
legacyBanner: 'Legacy HMAC keys are in a migration window. Please switch to a LIC- / LICOFF- key. Old keys stay valid until the window ends — never silently invalidated.',
graceBanner: 'Offline grace: {{days}} day(s) left. Reconnect to re-verify.',
status: {
+8
View File
@@ -21,6 +21,14 @@ export default {
activating: '激活中…',
activated: 'Pro 已激活,全部功能已解锁',
activateFailed: '激活失败',
deactivate: '退出授权 / 释放本实例',
deactivateConfirmTitle: '退出授权?',
deactivateConfirmDesc: '将释放本实例占用的授权名额,本机回到免费版。在线授权会先通知授权服务器;失败则保留现状,不会静默清码。',
deactivateConfirm: '确认退出',
deactivating: '退出中…',
deactivated: '已退出授权,本机已回到免费版',
deactivateFailed: '退出授权失败',
deactivateOfflineNote: '已解除本机离线授权。后台名额可能仍占用,如需释放请联系运营处理。',
legacyBanner: '旧版 HMAC 许可码迁移窗口:请尽快换发 LIC- / LICOFF- 授权码,旧码在窗口内仍有效,不会静默失效。',
graceBanner: '离线宽限中:剩余 {{days}} 天,恢复网络后续验。',
status: {
@@ -6,19 +6,22 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { KeyRound } from 'lucide-react'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { InstanceIdRow } from '@/components/InstanceIdRow'
import { LicenseStatusBanners } from '@/components/LicenseStatusBanners'
import { licenseApi } from '@/api/license'
import { useLicense } from '@/hooks/useLicense'
import { getUser } from '@/lib/auth-storage'
/** 授权区块:当前版本状态 + 许可码激活(仅管理员可操作) */
/** 授权区块:当前版本状态 + 许可码激活 / 退出授权(仅管理员可操作) */
export const LicenseSection = memo(function LicenseSection() {
const { t } = useTranslation('license')
const { pro, info, reload } = useLicense()
const isAdmin = getUser()?.role === 'admin'
const [key, setKey] = useState('')
const [activating, setActivating] = useState(false)
const [confirmOpen, setConfirmOpen] = useState(false)
const [deactivating, setDeactivating] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const focusKey = () => {
@@ -41,6 +44,24 @@ export const LicenseSection = memo(function LicenseSection() {
}
}
const deactivate = async () => {
setDeactivating(true)
try {
const res = await licenseApi.deactivate()
if (res?.offlineLocalOnly) {
toast.success(res.message || t('deactivateOfflineNote'))
} else {
toast.success(res?.message || t('deactivated'))
}
setConfirmOpen(false)
reload()
} catch (err) {
toast.error(err instanceof Error ? err.message : t('deactivateFailed'))
} finally {
setDeactivating(false)
}
}
const expiry = info?.expiresAt
? info.expiresAt > 0
? new Date(info.expiresAt * 1000).toLocaleDateString()
@@ -80,21 +101,40 @@ export const LicenseSection = memo(function LicenseSection() {
{isAdmin && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{t('activateHint')}</p>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Input
ref={inputRef}
value={key}
onChange={e => setKey(e.target.value)}
placeholder={t('keyPlaceholder')}
className="font-mono text-sm"
className="min-w-[12rem] flex-1 font-mono text-sm"
/>
<Button onClick={() => void activate()} disabled={activating || !key.trim()}>
{activating ? t('activating') : t('activate')}
</Button>
{pro && (
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10"
onClick={() => setConfirmOpen(true)}
disabled={deactivating}
>
{t('deactivate')}
</Button>
)}
</div>
</div>
)}
</CardContent>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t('deactivateConfirmTitle')}
description={t('deactivateConfirmDesc')}
confirmText={t('deactivateConfirm')}
loading={deactivating}
onConfirm={() => void deactivate()}
/>
</Card>
)
})