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