diff --git a/internal/handler/discover.go b/internal/handler/discover.go index 071e23c..75e2810 100644 --- a/internal/handler/discover.go +++ b/internal/handler/discover.go @@ -16,6 +16,9 @@ func trendingHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if items == nil { + items = []service.Match{} + } 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()}) return } + if items == nil { + items = []service.Match{} + } c.JSON(http.StatusOK, gin.H{"items": items}) } } diff --git a/internal/handler/download_clients.go b/internal/handler/download_clients.go index db4b695..f659c27 100644 --- a/internal/handler/download_clients.go +++ b/internal/handler/download_clients.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/ShukeBta/MediaStationGo/internal/model" "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()}) return } + if rows == nil { + rows = []model.DownloadClient{} + } c.JSON(http.StatusOK, rows) } } diff --git a/internal/handler/notify_channels.go b/internal/handler/notify_channels.go index b44518f..bacbfb1 100644 --- a/internal/handler/notify_channels.go +++ b/internal/handler/notify_channels.go @@ -16,6 +16,10 @@ func listNotifyChannelsHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if rows == nil { + c.JSON(http.StatusOK, []struct{}{}) + return + } c.JSON(http.StatusOK, rows) } } diff --git a/web/src/api/discover.ts b/web/src/api/discover.ts index 8fc91ce..492079d 100644 --- a/web/src/api/discover.ts +++ b/web/src/api/discover.ts @@ -15,7 +15,7 @@ export interface DiscoverItem extends Partial { export const discoverAPI = { trending: () => - api.get<{ items: DiscoverItem[] }>('/discover/trending').then((r) => r.data.items), + api.get<{ items: DiscoverItem[] }>('/discover/trending').then((r) => r.data.items ?? []), popular: () => - api.get<{ items: DiscoverItem[] }>('/discover/popular').then((r) => r.data.items), + api.get<{ items: DiscoverItem[] }>('/discover/popular').then((r) => r.data.items ?? []), } diff --git a/web/src/api/download_clients.ts b/web/src/api/download_clients.ts index 80d9618..c5aaf22 100644 --- a/web/src/api/download_clients.ts +++ b/web/src/api/download_clients.ts @@ -28,7 +28,7 @@ export interface DownloadClientInput { export const downloadClientsAPI = { list: () => - api.get('/admin/download/clients').then((r) => r.data), + api.get('/admin/download/clients').then((r) => r.data ?? []), create: (input: DownloadClientInput) => api.post('/admin/download/clients', input).then((r) => r.data), diff --git a/web/src/api/license.ts b/web/src/api/license.ts index f490191..1e601bf 100644 --- a/web/src/api/license.ts +++ b/web/src/api/license.ts @@ -1,71 +1,59 @@ import { api } from './client' -// LicenseKey + LicenseActivation mirror the Go model structs. -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 -} +// ── License Server types (generic, ready for MediaStationLicenseServer integration) ── +/** Response after binding/activating a license. */ export interface LicenseActivation { id: string key_id: string + /** The license key string (e.g. MS-XXXX-XXXX-XXXX) */ + key?: string device_id: string device_name?: string + plan?: string + max_activations?: number + /** ISO8601 — null means perpetual */ + expires_at?: string | null + valid: boolean ip?: string - unbound_at?: string | null heartbeat_at?: string | null created_at: string } -export interface GenerateKeyInput { - customer?: string - plan?: string - max_activations?: number - expires_at?: string // RFC3339; "" or omit for perpetual - notes?: string +/** Status of the currently bound license on this device. */ +export interface LicenseStatus { + /** Whether a license is currently active */ + active: boolean + activation?: LicenseActivation + /** 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 = { - generate: (input: GenerateKeyInput) => - api.post('/admin/license/generate', input).then((r) => r.data), - - list: () => api.get('/admin/license/list').then((r) => r.data), - - listActivations: (keyID: string) => - api - .get(`/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) => + /** Bind / activate a license key for this device. */ + bind: (key: string) => api .post('/license/activate', { - key, - device_id: deviceID, - device_name: deviceName, + key: key.trim(), + device_id: deviceFingerprint(), + device_name: navigator.platform || 'Web Client', }) .then((r) => r.data), - status: (keyID: string) => - api - .get<{ key: LicenseKey; active_activations: number; valid: boolean }>( - '/license/status', - { params: { key_id: keyID } }, - ) - .then((r) => r.data), + /** Get the status of the currently active license. */ + status: () => + api.get('/license/status').then((r) => r.data), + + /** Refresh the heartbeat for the active license. */ + heartbeat: () => + api.post('/license/heartbeat').then((r) => r.data), } diff --git a/web/src/api/notify_channels.ts b/web/src/api/notify_channels.ts index 0d13e1c..503b2da 100644 --- a/web/src/api/notify_channels.ts +++ b/web/src/api/notify_channels.ts @@ -13,7 +13,7 @@ export interface NotifyChannelInput { // notifyChannelsAPI wraps the admin /admin/notify/channels surface. export const notifyChannelsAPI = { list: () => - api.get('/admin/notify/channels').then((r) => r.data), + api.get('/admin/notify/channels').then((r) => r.data ?? []), create: (input: NotifyChannelInput) => api.post('/admin/notify/channels', input).then((r) => r.data), diff --git a/web/src/pages/LicensePage.tsx b/web/src/pages/LicensePage.tsx index c0bc10d..a7aab87 100644 --- a/web/src/pages/LicensePage.tsx +++ b/web/src/pages/LicensePage.tsx @@ -1,319 +1,248 @@ -import { FormEvent, useEffect, useState } from 'react' +import { FormEvent, useCallback, useEffect, useState } from 'react' import { - ChevronDown, - ChevronUp, + CheckCircle2, KeySquare, + Link2, Loader2, - Plus, - ShieldOff, - Trash2, + RefreshCw, + XCircle, } from 'lucide-react' import toast from 'react-hot-toast' -import { - licenseAPI, - type GenerateKeyInput, - type LicenseActivation, - type LicenseKey, -} from '../api/license' +import { licenseAPI, type LicenseStatus } from '../api/license' + +// ── Helpers ── + +function fmtDate(iso: string | null | undefined): string { + 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() { - const [keys, setKeys] = useState([]) - const [loading, setLoading] = useState(true) - const [showGen, setShowGen] = useState(false) - const [openKey, setOpenKey] = useState(null) - const [activations, setActivations] = useState>({}) + const [status, setStatus] = useState(null) + const [loadingStatus, setLoadingStatus] = useState(true) + const [bindKey, setBindKey] = useState('') + const [binding, setBinding] = useState(false) - const refresh = async () => { - setLoading(true) + const refreshStatus = useCallback(async () => { + setLoadingStatus(true) try { - setKeys(await licenseAPI.list()) + const s = await licenseAPI.status() + setStatus(s) + } catch { + setStatus({ active: false }) } finally { - setLoading(false) + setLoadingStatus(false) } - } - - useEffect(() => { - refresh().catch(() => undefined) }, []) - const toggleOpen = async (k: LicenseKey) => { - if (openKey === k.id) { - setOpenKey(null) + useEffect(() => { + refreshStatus() + }, [refreshStatus]) + + const onBind = async (e: FormEvent) => { + e.preventDefault() + const key = bindKey.trim() + if (!key) { + toast.error('请输入许可证密钥') return } - setOpenKey(k.id) - 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 + setBinding(true) try { - await licenseAPI.revoke(k.id) - toast.success('已吊销') - await refresh() - } catch (err: unknown) { - const msg = - (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? - '吊销失败' - toast.error(msg) - } - } - - 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 ( -
-
-
-
- -
-
-

许可证管理

-

- 生成密钥 · 绑定设备 · 心跳监控 · 吊销 -

-
-
- -
- - {loading && ( -
- -
- )} - - {!loading && keys.length === 0 && ( -
暂无密钥
- )} - - {!loading && keys.length > 0 && ( -
- {keys.map((k) => ( -
-
-
-
- - {k.key} - - - {k.plan} - - {k.revoked && ( - - 已吊销 - - )} -
-
- {k.customer && `客户: ${k.customer} · `} - 最多 {k.max_activations} 设备 · - {k.expires_at ? ` ${new Date(k.expires_at).toLocaleDateString()} 到期` : ' 永久'} -
-
-
- - {!k.revoked && ( - - )} -
-
- {openKey === k.id && ( -
- {(activations[k.id] ?? []).length === 0 && ( -

暂无激活记录

- )} - {(activations[k.id] ?? []).map((a) => ( -
-
-
- {a.device_name || a.device_id} - {a.unbound_at && ( - - (已解绑 {new Date(a.unbound_at).toLocaleDateString()}) - - )} -
-
- {a.ip} · 心跳{' '} - {a.heartbeat_at - ? new Date(a.heartbeat_at).toLocaleString() - : '未上报'} -
-
- {!a.unbound_at && ( - - )} -
- ))} -
- )} -
- ))} -
- )} - - {showGen && ( - setShowGen(false)} - onCreated={async () => { - setShowGen(false) - await refresh() - }} - /> - )} -
- ) -} - -function GenerateModal({ - onClose, - onCreated, -}: { - onClose: () => void - onCreated: () => void | Promise -}) { - const [form, setForm] = useState({ - 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, + const activation = await licenseAPI.bind(key) + toast.success('许可证绑定成功!') + setBindKey('') + // Optimistically update status + setStatus({ + active: true, + activation, + message: '已激活', }) - toast.success(`已生成: ${k.key}`) - await onCreated() } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? - '生成失败' + '绑定失败,请检查密钥是否正确' toast.error(msg) } 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 ( -
-
-

生成密钥

-
- - - - -