feat(license): 退出授权文案精简与两态布局

未授权隐藏退出;Pro+管理员可见「退出授权」;成功只 reload 授权态不整页刷。
This commit is contained in:
opsx committed 2026-11-01 03:34:10 +08:00
1 parent cec7c6eeaf
commit 914eec1e4b
4 files changed
+45 -29

No files matched your search

+1 -1
View File
@@ -76,7 +76,7 @@ curl -sS 'https://licence.rlwj.cn/api/v1/products/code/lokistack'
## 退出授权(本机)
- UI:设置 → 授权,Pro 态管理员可见「退出授权 / 释放本实例」,二次确认。
- UI:设置 → 授权,Pro 态(含宽限仍 pro)管理员可见「退出授权」,二次确认;免费/未激活不渲染。成功后只重拉授权态,不整页刷新。
- 后端:`POST /api/v1/system/license/deactivate`(Admin)。
- 在线 `LIC-`:调用 `POST {LICENSE_API_BASE}/v1/license/deactivate`,body 含 `license_key`、`device_id`(=instance_id)、`activation_token`(若有);**成功才清本地**,失败保留现状并报错。
- 离线 `LICOFF-`:只清本地,响应 `offlineLocalOnly=true`,提示后台名额可能仍占。
+3 -2
View File
@@ -21,10 +21,11 @@ export default {
activating: 'Activating…',
activated: 'Pro activated — all features unlocked',
activateFailed: 'Activation failed',
deactivate: 'Deactivate / release this instance',
deactivate: 'Deactivate license',
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).',
deactivateConfirmDesc: 'This machine returns to Free. Online keys notify the server first; on failure local state is kept.',
deactivateConfirm: 'Confirm deactivate',
graceBadge: 'Grace {{days}}d',
deactivating: 'Deactivating…',
deactivated: 'License deactivated — this machine is Free again',
deactivateFailed: 'Deactivation failed',
+3 -2
View File
@@ -21,10 +21,11 @@ export default {
activating: '激活中…',
activated: 'Pro 已激活,全部功能已解锁',
activateFailed: '激活失败',
deactivate: '退出授权 / 释放本实例',
deactivate: '退出授权',
deactivateConfirmTitle: '退出授权?',
deactivateConfirmDesc: '将释放本实例占用的授权名额,本机回到免费版。在线授权会先通知授权服务器;失败则保留现状,不会静默清码。',
deactivateConfirmDesc: '确认后本机回到免费版。在线授权会先通知服务器;失败则保留现状,不会清码。',
deactivateConfirm: '确认退出',
graceBadge: '宽限 {{days}} 天',
deactivating: '退出中…',
deactivated: '已退出授权,本机已回到免费版',
deactivateFailed: '退出授权失败',
@@ -13,11 +13,12 @@ import { licenseApi } from '@/api/license'
import { useLicense } from '@/hooks/useLicense'
import { getUser } from '@/lib/auth-storage'
/** 授权区块:当前版本状态 + 许可码激活 / 退出授权(仅管理员可操作) */
/** 授权区块:免费态激活 / Pro 态退出(仅管理员;未授权不渲染退出) */
export const LicenseSection = memo(function LicenseSection() {
const { t } = useTranslation('license')
const { pro, info, reload } = useLicense()
const isAdmin = getUser()?.role === 'admin'
const canDeactivate = Boolean(pro) && isAdmin
const [key, setKey] = useState('')
const [activating, setActivating] = useState(false)
const [confirmOpen, setConfirmOpen] = useState(false)
@@ -36,7 +37,7 @@ export const LicenseSection = memo(function LicenseSection() {
await licenseApi.activate(key.trim())
toast.success(t('activated'))
setKey('')
reload()
await Promise.resolve(reload())
} catch (err) {
toast.error(err instanceof Error ? err.message : t('activateFailed'))
} finally {
@@ -54,8 +55,10 @@ export const LicenseSection = memo(function LicenseSection() {
toast.success(res?.message || t('deactivated'))
}
setConfirmOpen(false)
reload()
// 只重拉授权态,禁止整页 window.location.reload
await Promise.resolve(reload())
} catch (err) {
// 失败:弹层保留、不清码、toast
toast.error(err instanceof Error ? err.message : t('deactivateFailed'))
} finally {
setDeactivating(false)
@@ -69,6 +72,7 @@ export const LicenseSection = memo(function LicenseSection() {
: null
const instanceLimit = info?.maxDevices || info?.instances
const inGrace = Boolean(info?.inGrace)
return (
<Card>
@@ -79,10 +83,15 @@ export const LicenseSection = memo(function LicenseSection() {
<Badge variant={pro ? 'default' : 'secondary'} className={pro ? 'bg-success text-success-foreground' : ''}>
{pro ? 'Pro' : t('freeBadge')}
</Badge>
{pro && inGrace && (
<Badge variant="outline" className="border-warning/50 text-warning-foreground">
{t('graceBadge', { days: info?.graceDaysLeft ?? 0 })}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<LicenseStatusBanners info={info} pro={pro} onAction={isAdmin ? focusKey : undefined} />
<LicenseStatusBanners info={info} pro={pro} onAction={isAdmin && !pro ? focusKey : undefined} />
<InstanceIdRow instanceId={info?.instanceId} />
<div className="text-sm">
{pro ? (
@@ -98,7 +107,8 @@ export const LicenseSection = memo(function LicenseSection() {
<p className="text-muted-foreground">{t('freeDesc')}</p>
)}
</div>
{isAdmin && (
{/* 未激活:管理员可激活;已激活:仅管理员退出(对齐 Yaak — 无 Deactivate 于未授权态) */}
{isAdmin && !pro && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{t('activateHint')}</p>
<div className="flex flex-wrap gap-2">
@@ -112,29 +122,33 @@ export const LicenseSection = memo(function LicenseSection() {
<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>
)}
{canDeactivate && (
<div>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10"
onClick={() => setConfirmOpen(true)}
disabled={deactivating}
>
{t('deactivate')}
</Button>
</div>
)}
</CardContent>
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t('deactivateConfirmTitle')}
description={t('deactivateConfirmDesc')}
confirmText={t('deactivateConfirm')}
loading={deactivating}
onConfirm={() => void deactivate()}
/>
{canDeactivate && (
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t('deactivateConfirmTitle')}
description={t('deactivateConfirmDesc')}
confirmText={t('deactivateConfirm')}
loading={deactivating}
onConfirm={() => void deactivate()}
/>
)}
</Card>
)
})