mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
fix: 修复三个空白页面 + 许可证页面重构
- 修复发现页空白:后端 discover.go nil→[] Match,前端 discover.ts 加 ?? []
- 修复下载客户端页空白:后端 download_clients.go nil→[]model.DownloadClient,前端加 ?? []
- 修复通知渠道页空白:后端 notify_channels.go nil→[]struct{},前端加 ?? []
- 许可证页面重构:移除生成密钥功能,对接 LicenseServer 绑定/状态模式
- 所有变更通过 Go build + npm run build 零错误验证
This commit is contained in:
@@ -16,6 +16,9 @@ func trendingHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []service.Match{}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,6 +30,9 @@ func popularHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []service.Match{}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +18,9 @@ func listDownloadClientsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if rows == nil {
|
||||||
|
rows = []model.DownloadClient{}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, rows)
|
c.JSON(http.StatusOK, rows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ func listNotifyChannelsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if rows == nil {
|
||||||
|
c.JSON(http.StatusOK, []struct{}{})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, rows)
|
c.JSON(http.StatusOK, rows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export interface DiscoverItem extends Partial<Media> {
|
|||||||
|
|
||||||
export const discoverAPI = {
|
export const discoverAPI = {
|
||||||
trending: () =>
|
trending: () =>
|
||||||
api.get<{ items: DiscoverItem[] }>('/discover/trending').then((r) => r.data.items),
|
api.get<{ items: DiscoverItem[] }>('/discover/trending').then((r) => r.data.items ?? []),
|
||||||
popular: () =>
|
popular: () =>
|
||||||
api.get<{ items: DiscoverItem[] }>('/discover/popular').then((r) => r.data.items),
|
api.get<{ items: DiscoverItem[] }>('/discover/popular').then((r) => r.data.items ?? []),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export interface DownloadClientInput {
|
|||||||
|
|
||||||
export const downloadClientsAPI = {
|
export const downloadClientsAPI = {
|
||||||
list: () =>
|
list: () =>
|
||||||
api.get<DownloadClient[]>('/admin/download/clients').then((r) => r.data),
|
api.get<DownloadClient[]>('/admin/download/clients').then((r) => r.data ?? []),
|
||||||
|
|
||||||
create: (input: DownloadClientInput) =>
|
create: (input: DownloadClientInput) =>
|
||||||
api.post<DownloadClient>('/admin/download/clients', input).then((r) => r.data),
|
api.post<DownloadClient>('/admin/download/clients', input).then((r) => r.data),
|
||||||
|
|||||||
+37
-49
@@ -1,71 +1,59 @@
|
|||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
|
|
||||||
// LicenseKey + LicenseActivation mirror the Go model structs.
|
// ── License Server types (generic, ready for MediaStationLicenseServer integration) ──
|
||||||
export interface LicenseKey {
|
|
||||||
id: string
|
|
||||||
key: string
|
|
||||||
customer?: string
|
|
||||||
plan: string
|
|
||||||
max_activations: number
|
|
||||||
issued_at: string
|
|
||||||
expires_at?: string | null
|
|
||||||
revoked: boolean
|
|
||||||
notes?: string
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** Response after binding/activating a license. */
|
||||||
export interface LicenseActivation {
|
export interface LicenseActivation {
|
||||||
id: string
|
id: string
|
||||||
key_id: string
|
key_id: string
|
||||||
|
/** The license key string (e.g. MS-XXXX-XXXX-XXXX) */
|
||||||
|
key?: string
|
||||||
device_id: string
|
device_id: string
|
||||||
device_name?: string
|
device_name?: string
|
||||||
|
plan?: string
|
||||||
|
max_activations?: number
|
||||||
|
/** ISO8601 — null means perpetual */
|
||||||
|
expires_at?: string | null
|
||||||
|
valid: boolean
|
||||||
ip?: string
|
ip?: string
|
||||||
unbound_at?: string | null
|
|
||||||
heartbeat_at?: string | null
|
heartbeat_at?: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerateKeyInput {
|
/** Status of the currently bound license on this device. */
|
||||||
customer?: string
|
export interface LicenseStatus {
|
||||||
plan?: string
|
/** Whether a license is currently active */
|
||||||
max_activations?: number
|
active: boolean
|
||||||
expires_at?: string // RFC3339; "" or omit for perpetual
|
activation?: LicenseActivation
|
||||||
notes?: string
|
/** Error or status message */
|
||||||
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generate a short device fingerprint from browser/OS info. */
|
||||||
|
function deviceFingerprint(): string {
|
||||||
|
const nav = window.navigator
|
||||||
|
const parts = [nav.hardwareConcurrency, nav.language, screen.width, screen.height]
|
||||||
|
return btoa(parts.join('|')).slice(0, 32).replace(/[+/=]/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API methods ──
|
||||||
|
|
||||||
export const licenseAPI = {
|
export const licenseAPI = {
|
||||||
generate: (input: GenerateKeyInput) =>
|
/** Bind / activate a license key for this device. */
|
||||||
api.post<LicenseKey>('/admin/license/generate', input).then((r) => r.data),
|
bind: (key: string) =>
|
||||||
|
|
||||||
list: () => api.get<LicenseKey[]>('/admin/license/list').then((r) => r.data),
|
|
||||||
|
|
||||||
listActivations: (keyID: string) =>
|
|
||||||
api
|
|
||||||
.get<LicenseActivation[]>(`/admin/license/${keyID}/activations`)
|
|
||||||
.then((r) => r.data),
|
|
||||||
|
|
||||||
revoke: (keyID: string) =>
|
|
||||||
api.post(`/admin/license/${keyID}/revoke`).then((r) => r.data),
|
|
||||||
|
|
||||||
unbind: (activationID: string) =>
|
|
||||||
api.post(`/admin/license/activation/${activationID}/unbind`).then((r) => r.data),
|
|
||||||
|
|
||||||
// Self-service
|
|
||||||
activate: (key: string, deviceID: string, deviceName?: string) =>
|
|
||||||
api
|
api
|
||||||
.post<LicenseActivation>('/license/activate', {
|
.post<LicenseActivation>('/license/activate', {
|
||||||
key,
|
key: key.trim(),
|
||||||
device_id: deviceID,
|
device_id: deviceFingerprint(),
|
||||||
device_name: deviceName,
|
device_name: navigator.platform || 'Web Client',
|
||||||
})
|
})
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|
||||||
status: (keyID: string) =>
|
/** Get the status of the currently active license. */
|
||||||
api
|
status: () =>
|
||||||
.get<{ key: LicenseKey; active_activations: number; valid: boolean }>(
|
api.get<LicenseStatus>('/license/status').then((r) => r.data),
|
||||||
'/license/status',
|
|
||||||
{ params: { key_id: keyID } },
|
/** Refresh the heartbeat for the active license. */
|
||||||
)
|
heartbeat: () =>
|
||||||
.then((r) => r.data),
|
api.post('/license/heartbeat').then((r) => r.data),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface NotifyChannelInput {
|
|||||||
// notifyChannelsAPI wraps the admin /admin/notify/channels surface.
|
// notifyChannelsAPI wraps the admin /admin/notify/channels surface.
|
||||||
export const notifyChannelsAPI = {
|
export const notifyChannelsAPI = {
|
||||||
list: () =>
|
list: () =>
|
||||||
api.get<NotifyChannel[]>('/admin/notify/channels').then((r) => r.data),
|
api.get<NotifyChannel[]>('/admin/notify/channels').then((r) => r.data ?? []),
|
||||||
|
|
||||||
create: (input: NotifyChannelInput) =>
|
create: (input: NotifyChannelInput) =>
|
||||||
api.post<NotifyChannel>('/admin/notify/channels', input).then((r) => r.data),
|
api.post<NotifyChannel>('/admin/notify/channels', input).then((r) => r.data),
|
||||||
|
|||||||
+217
-288
@@ -1,319 +1,248 @@
|
|||||||
import { FormEvent, useEffect, useState } from 'react'
|
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
CheckCircle2,
|
||||||
ChevronUp,
|
|
||||||
KeySquare,
|
KeySquare,
|
||||||
|
Link2,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
RefreshCw,
|
||||||
ShieldOff,
|
XCircle,
|
||||||
Trash2,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
import {
|
import { licenseAPI, type LicenseStatus } from '../api/license'
|
||||||
licenseAPI,
|
|
||||||
type GenerateKeyInput,
|
// ── Helpers ──
|
||||||
type LicenseActivation,
|
|
||||||
type LicenseKey,
|
function fmtDate(iso: string | null | undefined): string {
|
||||||
} from '../api/license'
|
if (!iso) return '永久'
|
||||||
|
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ──
|
||||||
|
|
||||||
// LicensePage is the admin UI for issuing / revoking license keys and
|
|
||||||
// inspecting activations. Mirrors the Vue LicenseTab inside Settings.
|
|
||||||
export function LicensePage() {
|
export function LicensePage() {
|
||||||
const [keys, setKeys] = useState<LicenseKey[]>([])
|
const [status, setStatus] = useState<LicenseStatus | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loadingStatus, setLoadingStatus] = useState(true)
|
||||||
const [showGen, setShowGen] = useState(false)
|
const [bindKey, setBindKey] = useState('')
|
||||||
const [openKey, setOpenKey] = useState<string | null>(null)
|
const [binding, setBinding] = useState(false)
|
||||||
const [activations, setActivations] = useState<Record<string, LicenseActivation[]>>({})
|
|
||||||
|
|
||||||
const refresh = async () => {
|
const refreshStatus = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoadingStatus(true)
|
||||||
try {
|
try {
|
||||||
setKeys(await licenseAPI.list())
|
const s = await licenseAPI.status()
|
||||||
|
setStatus(s)
|
||||||
|
} catch {
|
||||||
|
setStatus({ active: false })
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoadingStatus(false)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh().catch(() => undefined)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const toggleOpen = async (k: LicenseKey) => {
|
useEffect(() => {
|
||||||
if (openKey === k.id) {
|
refreshStatus()
|
||||||
setOpenKey(null)
|
}, [refreshStatus])
|
||||||
|
|
||||||
|
const onBind = async (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const key = bindKey.trim()
|
||||||
|
if (!key) {
|
||||||
|
toast.error('请输入许可证密钥')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setOpenKey(k.id)
|
setBinding(true)
|
||||||
if (!activations[k.id]) {
|
|
||||||
try {
|
|
||||||
const acts = await licenseAPI.listActivations(k.id)
|
|
||||||
setActivations((a) => ({ ...a, [k.id]: acts }))
|
|
||||||
} catch {
|
|
||||||
toast.error('加载激活记录失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onRevoke = async (k: LicenseKey) => {
|
|
||||||
if (!confirm(`确定吊销 ${k.key.slice(0, 14)}…?`)) return
|
|
||||||
try {
|
try {
|
||||||
await licenseAPI.revoke(k.id)
|
const activation = await licenseAPI.bind(key)
|
||||||
toast.success('已吊销')
|
toast.success('许可证绑定成功!')
|
||||||
await refresh()
|
setBindKey('')
|
||||||
} catch (err: unknown) {
|
// Optimistically update status
|
||||||
const msg =
|
setStatus({
|
||||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
active: true,
|
||||||
'吊销失败'
|
activation,
|
||||||
toast.error(msg)
|
message: '已激活',
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onUnbind = async (a: LicenseActivation) => {
|
|
||||||
if (!confirm(`解绑设备 ${a.device_name || a.device_id}?`)) return
|
|
||||||
try {
|
|
||||||
await licenseAPI.unbind(a.id)
|
|
||||||
toast.success('已解绑')
|
|
||||||
const acts = await licenseAPI.listActivations(a.key_id)
|
|
||||||
setActivations((all) => ({ ...all, [a.key_id]: acts }))
|
|
||||||
} catch {
|
|
||||||
toast.error('解绑失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-fuchsia-400/10 text-fuchsia-300">
|
|
||||||
<KeySquare size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="font-display text-3xl font-bold text-white">许可证管理</h1>
|
|
||||||
<p className="text-sm text-slate-400">
|
|
||||||
生成密钥 · 绑定设备 · 心跳监控 · 吊销
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setShowGen(true)} className="neon-button">
|
|
||||||
<Plus size={16} /> 生成新密钥
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<div className="flex justify-center py-12 text-slate-400">
|
|
||||||
<Loader2 className="animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && keys.length === 0 && (
|
|
||||||
<div className="glass-panel py-12 text-center text-slate-400">暂无密钥</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && keys.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{keys.map((k) => (
|
|
||||||
<div key={k.id} className="glass-panel space-y-3">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="rounded bg-white/5 px-2 py-0.5 text-xs text-primary-400">
|
|
||||||
{k.key}
|
|
||||||
</code>
|
|
||||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
|
|
||||||
{k.plan}
|
|
||||||
</span>
|
|
||||||
{k.revoked && (
|
|
||||||
<span className="rounded bg-red-400/20 px-2 py-0.5 text-xs text-red-400">
|
|
||||||
已吊销
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-slate-400">
|
|
||||||
{k.customer && `客户: ${k.customer} · `}
|
|
||||||
最多 {k.max_activations} 设备 ·
|
|
||||||
{k.expires_at ? ` ${new Date(k.expires_at).toLocaleDateString()} 到期` : ' 永久'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleOpen(k)}
|
|
||||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
|
||||||
>
|
|
||||||
{openKey === k.id ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
|
||||||
{' '}激活记录
|
|
||||||
</button>
|
|
||||||
{!k.revoked && (
|
|
||||||
<button
|
|
||||||
onClick={() => onRevoke(k)}
|
|
||||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
|
||||||
>
|
|
||||||
<ShieldOff size={12} className="inline" /> 吊销
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{openKey === k.id && (
|
|
||||||
<div className="space-y-2 border-t border-white/5 pt-3">
|
|
||||||
{(activations[k.id] ?? []).length === 0 && (
|
|
||||||
<p className="text-sm text-slate-500">暂无激活记录</p>
|
|
||||||
)}
|
|
||||||
{(activations[k.id] ?? []).map((a) => (
|
|
||||||
<div
|
|
||||||
key={a.id}
|
|
||||||
className="flex items-center justify-between rounded border border-white/5 bg-white/5 px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-white">
|
|
||||||
{a.device_name || a.device_id}
|
|
||||||
{a.unbound_at && (
|
|
||||||
<span className="ml-2 text-xs text-slate-500">
|
|
||||||
(已解绑 {new Date(a.unbound_at).toLocaleDateString()})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-400">
|
|
||||||
{a.ip} · 心跳{' '}
|
|
||||||
{a.heartbeat_at
|
|
||||||
? new Date(a.heartbeat_at).toLocaleString()
|
|
||||||
: '未上报'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!a.unbound_at && (
|
|
||||||
<button
|
|
||||||
onClick={() => onUnbind(a)}
|
|
||||||
className="shrink-0 rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-red-400/40 hover:text-red-400"
|
|
||||||
>
|
|
||||||
<Trash2 size={12} className="inline" /> 解绑
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showGen && (
|
|
||||||
<GenerateModal
|
|
||||||
onClose={() => setShowGen(false)}
|
|
||||||
onCreated={async () => {
|
|
||||||
setShowGen(false)
|
|
||||||
await refresh()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function GenerateModal({
|
|
||||||
onClose,
|
|
||||||
onCreated,
|
|
||||||
}: {
|
|
||||||
onClose: () => void
|
|
||||||
onCreated: () => void | Promise<void>
|
|
||||||
}) {
|
|
||||||
const [form, setForm] = useState<GenerateKeyInput>({
|
|
||||||
customer: '',
|
|
||||||
plan: 'basic',
|
|
||||||
max_activations: 1,
|
|
||||||
expires_at: '',
|
|
||||||
notes: '',
|
|
||||||
})
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
const onSubmit = async (e: FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
const k = await licenseAPI.generate({
|
|
||||||
...form,
|
|
||||||
expires_at: form.expires_at ? new Date(form.expires_at).toISOString() : undefined,
|
|
||||||
})
|
})
|
||||||
toast.success(`已生成: ${k.key}`)
|
|
||||||
await onCreated()
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg =
|
const msg =
|
||||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||||
'生成失败'
|
'绑定失败,请检查密钥是否正确'
|
||||||
toast.error(msg)
|
toast.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setBinding(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onHeartbeat = async () => {
|
||||||
|
try {
|
||||||
|
await licenseAPI.heartbeat()
|
||||||
|
toast.success('心跳上报成功')
|
||||||
|
} catch {
|
||||||
|
toast.error('心跳上报失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Derive display values ──
|
||||||
|
const active = status?.active === true
|
||||||
|
const activation = status?.activation
|
||||||
|
const isExpired =
|
||||||
|
activation?.expires_at != null && new Date(activation.expires_at).getTime() < Date.now()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
<div className="mx-auto max-w-2xl space-y-8">
|
||||||
<div className="glass-panel w-full max-w-md">
|
{/* ── Header ── */}
|
||||||
<h2 className="mb-4 font-display text-xl font-semibold text-white">生成密钥</h2>
|
<div className="flex items-center gap-3">
|
||||||
<form onSubmit={onSubmit} className="space-y-3">
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-fuchsia-400/10 text-fuchsia-300">
|
||||||
<label className="block">
|
<KeySquare size={20} />
|
||||||
<span className="mb-1 block text-sm text-slate-300">客户</span>
|
</div>
|
||||||
<input
|
<div>
|
||||||
className="input-base"
|
<h1 className="font-display text-3xl font-bold text-white">许可证</h1>
|
||||||
value={form.customer ?? ''}
|
<p className="text-sm text-slate-400">绑定授权密钥以解锁全部功能</p>
|
||||||
onChange={(e) => setForm((f) => ({ ...f, customer: e.target.value }))}
|
</div>
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1 block text-sm text-slate-300">套餐</span>
|
|
||||||
<select
|
|
||||||
className="input-base"
|
|
||||||
value={form.plan ?? 'basic'}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, plan: e.target.value }))}
|
|
||||||
>
|
|
||||||
<option value="basic">basic</option>
|
|
||||||
<option value="pro">pro</option>
|
|
||||||
<option value="enterprise">enterprise</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1 block text-sm text-slate-300">最多设备数</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
className="input-base"
|
|
||||||
value={form.max_activations ?? 1}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm((f) => ({ ...f, max_activations: Number(e.target.value) }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1 block text-sm text-slate-300">到期日期 (空 = 永久)</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className="input-base"
|
|
||||||
value={form.expires_at ?? ''}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, expires_at: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1 block text-sm text-slate-300">备注</span>
|
|
||||||
<textarea
|
|
||||||
rows={2}
|
|
||||||
className="input-base"
|
|
||||||
value={form.notes ?? ''}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button type="submit" disabled={saving} className="neon-button">
|
|
||||||
{saving && <Loader2 size={16} className="animate-spin" />} 生成
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Bind Form ── */}
|
||||||
|
<div className="glass-panel space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link2 size={18} className="text-primary-400" />
|
||||||
|
<h2 className="font-display text-lg font-semibold text-white">绑定许可证</h2>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={onBind} className="flex gap-3">
|
||||||
|
<input
|
||||||
|
className="input-base flex-1 font-mono text-sm tracking-wider"
|
||||||
|
placeholder="MS-XXXX-XXXX-XXXX"
|
||||||
|
value={bindKey}
|
||||||
|
onChange={(e) => setBindKey(e.target.value)}
|
||||||
|
disabled={binding}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={binding} className="neon-button shrink-0">
|
||||||
|
{binding ? <Loader2 size={16} className="animate-spin" /> : <Link2 size={16} />}
|
||||||
|
绑定
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
输入从授权服务器获取的许可证密钥,激活后即可使用所有高级功能。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── License Status ── */}
|
||||||
|
{loadingStatus ? (
|
||||||
|
<div className="flex justify-center py-8 text-slate-400">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="glass-panel space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{active && !isExpired ? (
|
||||||
|
<CheckCircle2 size={18} className="text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<XCircle size={18} className="text-slate-500" />
|
||||||
|
)}
|
||||||
|
<h2 className="font-display text-lg font-semibold text-white">当前状态</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={refreshStatus}
|
||||||
|
className="rounded border border-white/10 p-2 text-slate-400 hover:bg-white/5 hover:text-white"
|
||||||
|
title="刷新状态"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
{active && (
|
||||||
|
<button
|
||||||
|
onClick={onHeartbeat}
|
||||||
|
className="rounded border border-primary-400/30 px-3 py-1.5 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||||
|
>
|
||||||
|
心跳上报
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!active && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-white/5 bg-white/[0.02] p-4 text-center">
|
||||||
|
<p className="text-slate-400">
|
||||||
|
尚未绑定许可证。请在上方输入密钥完成激活。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active && activation && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<StatusBadge label="密钥" value={activation.key ?? activation.key_id} mono />
|
||||||
|
<StatusBadge
|
||||||
|
label="套餐"
|
||||||
|
value={activation.plan ?? 'standard'}
|
||||||
|
className={
|
||||||
|
activation.plan === 'enterprise'
|
||||||
|
? 'text-amber-300'
|
||||||
|
: activation.plan === 'pro'
|
||||||
|
? 'text-primary-400'
|
||||||
|
: 'text-slate-300'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatusBadge label="设备名称" value={activation.device_name || 'Web Client'} />
|
||||||
|
<StatusBadge label="设备数" value={`${activation.max_activations ?? 1} 台`} />
|
||||||
|
<StatusBadge label="绑定时间" value={fmtDate(activation.created_at)} />
|
||||||
|
<StatusBadge
|
||||||
|
label="到期时间"
|
||||||
|
value={fmtDate(activation.expires_at)}
|
||||||
|
className={isExpired ? 'text-red-400' : undefined}
|
||||||
|
/>
|
||||||
|
<StatusBadge label="最近心跳" value={fmtDateTime(activation.heartbeat_at)} />
|
||||||
|
<StatusBadge label="客户端 IP" value={activation.ip ?? '—'} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isExpired && (
|
||||||
|
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-4 py-3 text-sm text-red-400">
|
||||||
|
此许可证已过期,部分功能可能受限。请获取新的许可证密钥。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Pro tip ── */}
|
||||||
|
{!active && !loadingStatus && (
|
||||||
|
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-5 text-center">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
需要获取许可证?请联系管理员获取 MediaStationGo 授权密钥。
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-600">
|
||||||
|
授权服务器地址可在系统设置中配置
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reusable status row ──
|
||||||
|
|
||||||
|
function StatusBadge({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
mono?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-white/5 bg-white/[0.02] p-3">
|
||||||
|
<p className="mb-0.5 text-xs text-slate-500">{label}</p>
|
||||||
|
<p className={`text-sm text-white ${mono ? 'font-mono' : ''} ${className ?? ''}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user