mirror of
https://github.com/ZeroCatDev/ClassworksKVAdmin.git
synced 2025-12-08 19:23:10 +00:00
542 lines
20 KiB
Vue
542 lines
20 KiB
Vue
<script setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { apiClient } from '@/lib/api'
|
||
import { deviceStore } from '@/lib/deviceStore'
|
||
import { toast } from 'vue-sonner'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Checkbox } from '@/components/ui/checkbox'
|
||
import { Separator } from '@/components/ui/separator'
|
||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||
import { Table, TableBody, TableCaption, TableCell, TableEmpty, TableFooter, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||
import { Download, Upload, Trash2, Plus, Loader2, Search, RefreshCw, Copy, Edit, Check, X, Key, ShieldCheck, Database } from 'lucide-vue-next'
|
||
|
||
// Token 与自动授权
|
||
const token = ref(localStorage.getItem('kv_token') || '')
|
||
const isTokenSet = computed(() => !!token.value)
|
||
const autoAuth = ref({
|
||
namespace: localStorage.getItem('kv_namespace') || '',
|
||
password: '',
|
||
appId: 'c0147d26d087d70113a21de967c452c0',
|
||
})
|
||
const autoAuthLoading = ref(false)
|
||
const deviceUuid = ref('')
|
||
|
||
// 列表与筛选(仅本地过滤)
|
||
const searchText = ref('') // 关键字过滤(本地)
|
||
const loading = ref(false)
|
||
const error = ref('')
|
||
const keys = ref([])
|
||
const values = ref({})
|
||
const loadingValues = ref({})
|
||
const specificKey = ref('') // 加载特定项
|
||
|
||
// 选择与分页
|
||
const selected = ref(new Set())
|
||
const page = ref(1)
|
||
const pageSize = ref(10)
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredKeys.value.length / pageSize.value)))
|
||
const pagedKeys = computed(() => {
|
||
const start = (page.value - 1) * pageSize.value
|
||
return filteredKeys.value.slice(start, start + pageSize.value)
|
||
})
|
||
|
||
// 过滤后的 keys
|
||
const filteredKeys = computed(() => {
|
||
if (!searchText.value.trim()) return keys.value
|
||
const kw = searchText.value.toLowerCase()
|
||
return keys.value.filter(k => k.toLowerCase().includes(kw))
|
||
})
|
||
|
||
// 对话框:新增/编辑
|
||
const editOpen = ref(false)
|
||
const isEditing = ref(false)
|
||
const formKey = ref('')
|
||
const formValue = ref('')
|
||
const formError = ref('')
|
||
|
||
// 预填 namespace:尝试从设备信息中获取
|
||
onMounted(async () => {
|
||
deviceUuid.value = deviceStore.getDeviceUuid() || ''
|
||
if (deviceUuid.value) {
|
||
try {
|
||
const info = await apiClient.getDeviceInfo(deviceUuid.value)
|
||
const ns = info.device?.namespace || info.namespace
|
||
if (ns && !autoAuth.value.namespace) {
|
||
autoAuth.value.namespace = ns
|
||
localStorage.setItem('kv_namespace', ns)
|
||
}
|
||
} catch {
|
||
// 忽略
|
||
}
|
||
}
|
||
if (isTokenSet.value) {
|
||
await loadKeys()
|
||
}
|
||
})
|
||
|
||
// 自动授权获取 Token
|
||
const acquireToken = async () => {
|
||
if (!autoAuth.value.namespace) {
|
||
toast.error('请输入命名空间')
|
||
return
|
||
}
|
||
autoAuthLoading.value = true
|
||
try {
|
||
const res = await apiClient.getTokenByNamespace(
|
||
autoAuth.value.namespace,
|
||
autoAuth.value.password || undefined,
|
||
autoAuth.value.appId
|
||
)
|
||
if (res?.token) {
|
||
token.value = res.token
|
||
localStorage.setItem('kv_token', token.value)
|
||
if (autoAuth.value.namespace) localStorage.setItem('kv_namespace', autoAuth.value.namespace)
|
||
if (autoAuth.value.appId) localStorage.setItem('kv_appId', autoAuth.value.appId)
|
||
toast.success('已获取 Token')
|
||
await loadKeys()
|
||
} else {
|
||
toast.error('未返回 Token')
|
||
}
|
||
} catch (e) {
|
||
toast.error('获取 Token 失败:' + e.message)
|
||
} finally {
|
||
autoAuthLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 手动设置/清除 Token
|
||
const clearToken = () => {
|
||
token.value = ''
|
||
localStorage.removeItem('kv_token')
|
||
keys.value = []
|
||
values.value = {}
|
||
}
|
||
|
||
// 加载全部 keys 列表(服务器不支持 pattern,本地过滤)
|
||
const loadKeys = async () => {
|
||
if (!token.value) {
|
||
toast.error('请先获取或填写 Token')
|
||
return
|
||
}
|
||
loading.value = true
|
||
error.value = ''
|
||
try {
|
||
const res = await apiClient.getKVKeys(token.value)
|
||
keys.value = Array.isArray(res) ? res : (res?.keys || [])
|
||
page.value = 1
|
||
values.value = {}
|
||
} catch (e) {
|
||
error.value = e.message || '加载键名失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 加载指定 key(将其加入列表并加载值)
|
||
const loadSpecificKey = async () => {
|
||
const key = (specificKey.value || '').trim()
|
||
if (!key) {
|
||
toast.error('请输入要加载的键名')
|
||
return
|
||
}
|
||
if (!token.value) {
|
||
toast.error('请先获取或填写 Token')
|
||
return
|
||
}
|
||
try {
|
||
const res = await apiClient.getKVItem(token.value, key)
|
||
const val = (res && Object.prototype.hasOwnProperty.call(res, 'value')) ? res.value : res
|
||
if (!keys.value.includes(key)) {
|
||
keys.value.unshift(key)
|
||
page.value = 1
|
||
}
|
||
values.value[key] = val
|
||
toast.success('已加载指定键')
|
||
// 直接打开该项的编辑对话框
|
||
await openEdit(key)
|
||
} catch (e) {
|
||
toast.error('加载指定键失败:' + e.message)
|
||
}
|
||
}
|
||
// 打开新增/编辑对话框
|
||
const openCreate = () => {
|
||
isEditing.value = false
|
||
formKey.value = ''
|
||
formValue.value = ''
|
||
formError.value = ''
|
||
editOpen.value = true
|
||
}
|
||
const openEdit = async (key) => {
|
||
isEditing.value = true
|
||
formKey.value = key
|
||
formError.value = ''
|
||
editOpen.value = true
|
||
// 预加载当前值
|
||
try {
|
||
const res = await apiClient.getKVItem(token.value, key)
|
||
const v = (res && Object.prototype.hasOwnProperty.call(res, 'value')) ? res.value : res
|
||
formValue.value = typeof v === 'string' ? v : JSON.stringify(v, null, 2)
|
||
} catch (e) {
|
||
formValue.value = ''
|
||
toast.error('加载值失败:' + e.message)
|
||
}
|
||
}
|
||
|
||
// 保存键值
|
||
const saving = ref(false)
|
||
const saveKeyValue = async () => {
|
||
formError.value = ''
|
||
const key = (formKey.value || '').trim()
|
||
const raw = (formValue.value || '').trim()
|
||
if (!key) return (formError.value = '请输入键名')
|
||
if (!raw) return (formError.value = '请输入值(可为JSON或字符串)')
|
||
|
||
let value
|
||
try {
|
||
value = JSON.parse(raw)
|
||
} catch {
|
||
value = raw // 允许纯字符串
|
||
}
|
||
|
||
|
||
saving.value = true
|
||
try {
|
||
await apiClient.setKVItem(token.value, key, value)
|
||
toast.success(isEditing.value ? '已保存修改' : '已创建键值')
|
||
editOpen.value = false
|
||
await loadKeys()
|
||
} catch (e) {
|
||
formError.value = e.message || '保存失败'
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
// 删除单个
|
||
const deleteKey = async (key) => {
|
||
if (!confirm(`确定删除键 “${key}” 吗?`)) return
|
||
try {
|
||
await apiClient.deleteKVItem(token.value, key)
|
||
toast.success('已删除')
|
||
await loadKeys()
|
||
} catch (e) {
|
||
toast.error('删除失败:' + e.message)
|
||
}
|
||
}
|
||
|
||
// 批量删除
|
||
const bulkDeleting = ref(false)
|
||
const bulkDelete = async () => {
|
||
if (selected.value.size === 0) return
|
||
if (!confirm(`确定删除选中的 ${selected.value.size} 个键吗?`)) return
|
||
bulkDeleting.value = true
|
||
try {
|
||
const toDelete = Array.from(selected.value)
|
||
for (const k of toDelete) {
|
||
await apiClient.deleteKVItem(token.value, k)
|
||
}
|
||
toast.success('批量删除完成')
|
||
selected.value.clear()
|
||
await loadKeys()
|
||
} catch (e) {
|
||
toast.error('批量删除失败:' + e.message)
|
||
} finally {
|
||
bulkDeleting.value = false
|
||
}
|
||
}
|
||
|
||
// 导出与导入
|
||
const exportAll = async () => {
|
||
if (!isTokenSet.value) return
|
||
// 尝试加载所有值
|
||
const data = {}
|
||
for (const k of keys.value) {
|
||
try {
|
||
const res = await apiClient.getKVItem(token.value, k)
|
||
const val = (res && Object.prototype.hasOwnProperty.call(res, 'value')) ? res.value : res
|
||
data[k] = val
|
||
} catch {
|
||
// 忽略单项失败
|
||
}
|
||
}
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `kv-export-${Date.now()}.json`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
const importOpen = ref(false)
|
||
const importJson = ref('')
|
||
const importError = ref('')
|
||
const importing = ref(false)
|
||
const startImport = () => {
|
||
importJson.value = ''
|
||
importError.value = ''
|
||
importOpen.value = true
|
||
}
|
||
const doImport = async () => {
|
||
importError.value = ''
|
||
let parsed
|
||
try {
|
||
parsed = JSON.parse(importJson.value || '{}')
|
||
} catch {
|
||
importError.value = 'JSON 无效'
|
||
return
|
||
}
|
||
importing.value = true
|
||
try {
|
||
for (const [k, v] of Object.entries(parsed)) {
|
||
await apiClient.setKVItem(token.value, k, v)
|
||
}
|
||
toast.success('导入完成')
|
||
importOpen.value = false
|
||
await loadKeys()
|
||
} catch (e) {
|
||
importError.value = e.message || '导入失败'
|
||
} finally {
|
||
importing.value = false
|
||
}
|
||
}
|
||
|
||
// 工具函数
|
||
const previewValue = (v) => {
|
||
if (v === undefined) return '点击查看'
|
||
try {
|
||
return typeof v === 'string' ? v : JSON.stringify(v, null, 2)
|
||
} catch { return String(v) }
|
||
}
|
||
const copy = async (text) => {
|
||
try { await navigator.clipboard.writeText(text); toast.success('已复制') } catch { toast.error('复制失败') }
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="min-h-screen bg-background">
|
||
<!-- Header -->
|
||
<div class="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-10">
|
||
<div class="container mx-auto px-6 py-4">
|
||
<div class="flex items-center gap-3">
|
||
<Database class="h-6 w-6" />
|
||
<div>
|
||
<h1 class="text-2xl font-bold">KV 数据管理器</h1>
|
||
<p class="text-sm text-muted-foreground">自动授权获取 Token,使用现代表格进行数据管理</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="container mx-auto px-6 py-6 max-w-7xl space-y-6">
|
||
<!-- 自动授权 / Token 区域 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle class="flex items-center gap-2">
|
||
<ShieldCheck class="h-5 w-5" /> 自动授权 / Token
|
||
</CardTitle>
|
||
<CardDescription>通过命名空间快速获取 Token,或手动填写 Token</CardDescription>
|
||
</CardHeader>
|
||
<CardContent class="space-y-4">
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<div class="space-y-2">
|
||
<Label for="ns">命名空间</Label>
|
||
<Input id="ns" v-model="autoAuth.namespace" placeholder="例如: class-2024-1" />
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label for="pwd">授权密码(可选)</Label>
|
||
<Input id="pwd" type="password" v-model="autoAuth.password" placeholder="留空表示无密码" />
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label for="appid">App ID</Label>
|
||
<Input id="appid" disabled v-model="autoAuth.appId" placeholder="应用标识符" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<Button @click="acquireToken" :disabled="autoAuthLoading">
|
||
<Loader2 v-if="autoAuthLoading" class="mr-2 h-4 w-4 animate-spin" />
|
||
<Key v-else class="mr-2 h-4 w-4" />
|
||
自动授权获取 Token
|
||
</Button>
|
||
<div class="flex-1" />
|
||
<div class="flex items-center gap-2 min-w-[280px]">
|
||
<Input v-model="token" placeholder="或手动粘贴 Token" />
|
||
<Button variant="outline" @click="clearToken" :disabled="!isTokenSet">清除</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 工具栏 -->
|
||
<Card>
|
||
<CardContent class="py-4">
|
||
<div class="flex flex-col md:flex-row gap-3 md:items-center">
|
||
<div class="flex items-center gap-2 md:w-[700px] w-full flex-wrap">
|
||
<div class="relative flex-1">
|
||
<Search class="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
<Input class="pl-8" v-model="searchText" placeholder="本地过滤关键字" />
|
||
</div>
|
||
<Button variant="outline" @click="loadKeys" :disabled="!isTokenSet">
|
||
<RefreshCw class="h-4 w-4 mr-2" /> 刷新
|
||
</Button>
|
||
<div class="flex items-center gap-2 min-w-[300px]">
|
||
<Input v-model="specificKey" placeholder="输入完整键名(加载单项)" />
|
||
<Button variant="outline" @click="loadSpecificKey" :disabled="!isTokenSet || !specificKey.trim()">加载项</Button>
|
||
</div>
|
||
</div>
|
||
<div class="md:ml-auto flex items-center gap-2">
|
||
<Button variant="outline" @click="startImport" :disabled="!isTokenSet">
|
||
<Upload class="h-4 w-4 mr-2" /> 导入
|
||
</Button>
|
||
<Button variant="outline" @click="exportAll" :disabled="!isTokenSet || keys.length===0">
|
||
<Download class="h-4 w-4 mr-2" /> 导出
|
||
</Button>
|
||
<Separator orientation="vertical" class="h-6" />
|
||
<Button variant="secondary" @click="openCreate" :disabled="!isTokenSet">
|
||
<Plus class="h-4 w-4 mr-2" /> 新建
|
||
</Button>
|
||
<Button variant="destructive" @click="bulkDelete" :disabled="!isTokenSet || selected.size===0 || bulkDeleting">
|
||
<Loader2 v-if="bulkDeleting" class="mr-2 h-4 w-4 animate-spin" />
|
||
<Trash2 v-else class="h-4 w-4 mr-2" /> 删除所选
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 列表 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>键值列表</CardTitle>
|
||
<CardDescription>
|
||
共 {{ filteredKeys.length }} 项
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div v-if="error" class="text-sm text-red-500 mb-3">{{ error }}</div>
|
||
<div v-if="loading" class="py-10 text-center text-muted-foreground">加载中...</div>
|
||
<div v-else>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead class="w-10">
|
||
<Checkbox :checked="selected.size>0 && selected.size===pagedKeys.length" :indeterminate="selected.size>0 && selected.size<pagedKeys.length" @update:checked="val => { if(val){ pagedKeys.forEach(k=>selected.add(k)) } else { pagedKeys.forEach(k=>selected.delete(k)) } }" />
|
||
</TableHead>
|
||
<TableHead class="min-w-[260px]">键名</TableHead>
|
||
<TableHead>值预览</TableHead>
|
||
<TableHead class="w-[180px]">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
<TableRow v-for="k in pagedKeys" :key="k">
|
||
<TableCell>
|
||
<Checkbox :checked="selected.has(k)" @update:checked="val => { val ? selected.add(k) : selected.delete(k) }" />
|
||
</TableCell>
|
||
<TableCell
|
||
class="font-mono text-sm break-all cursor-pointer hover:underline"
|
||
@click="openEdit(k)"
|
||
title="点击查看/编辑"
|
||
>
|
||
{{ k }}
|
||
</TableCell>
|
||
<TableCell>
|
||
<div
|
||
class="text-xs whitespace-pre-wrap max-h-40 overflow-auto rounded-md bg-muted p-2 cursor-pointer hover:bg-muted/70 transition-colors"
|
||
@click="openEdit(k)"
|
||
title="点击查看/编辑"
|
||
>
|
||
{{ previewValue(values[k]) }}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell class="space-x-1 whitespace-nowrap">
|
||
<Button size="sm" variant="outline" @click="copy(k)"><Copy class="h-3.5 w-3.5 mr-1" />复制键</Button>
|
||
<Button size="sm" variant="outline" @click="openEdit(k)"><Edit class="h-3.5 w-3.5 mr-1" />编辑</Button>
|
||
<Button size="sm" variant="destructive" @click="deleteKey(k)"><Trash2 class="h-3.5 w-3.5 mr-1" />删除</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
<TableRow v-if="!pagedKeys.length">
|
||
<TableCell colspan="4" class="text-center text-muted-foreground py-10">暂无数据</TableCell>
|
||
</TableRow>
|
||
</TableBody>
|
||
</Table>
|
||
|
||
<!-- 分页 -->
|
||
<div class="flex items-center justify-between mt-4 text-sm">
|
||
<div class="flex items-center gap-2">
|
||
<span>每页</span>
|
||
<select v-model.number="pageSize" class="h-9 w-[80px] rounded-md border border-input bg-background px-2">
|
||
<option :value="10">10</option>
|
||
<option :value="20">20</option>
|
||
<option :value="50">50</option>
|
||
</select>
|
||
<span class="text-muted-foreground">共 {{ totalPages }} 页</span>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<Button variant="outline" :disabled="page===1" @click="page=Math.max(1,page-1)">上一页</Button>
|
||
<span>第 {{ page }} / {{ totalPages }} 页</span>
|
||
<Button variant="outline" :disabled="page===totalPages" @click="page=Math.min(totalPages,page+1)">下一页</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<!-- 新增/编辑对话框 -->
|
||
<Dialog :open="editOpen" @update:open="val => editOpen = val">
|
||
<DialogContent class="sm:max-w-[600px]">
|
||
<DialogHeader>
|
||
<DialogTitle>{{ isEditing ? '编辑键值' : '新增键值' }}</DialogTitle>
|
||
<DialogDescription>支持 JSON 或纯文本,JSON 将自动格式化保存</DialogDescription>
|
||
</DialogHeader>
|
||
<div class="space-y-3 py-2">
|
||
<div class="space-y-1">
|
||
<Label for="kv-key">键名</Label>
|
||
<Input id="kv-key" v-model="formKey" :disabled="isEditing" placeholder="请输入键名" />
|
||
</div>
|
||
<div class="space-y-1">
|
||
<Label for="kv-val">值(JSON 或文本)</Label>
|
||
<textarea id="kv-val" v-model="formValue" rows="10" class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"></textarea>
|
||
<p v-if="formError" class="text-sm text-red-500">{{ formError }}</p>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" @click="editOpen=false" :disabled="saving">取消</Button>
|
||
<Button @click="saveKeyValue" :disabled="saving">
|
||
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
|
||
保存
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<!-- 导入对话框 -->
|
||
<Dialog :open="importOpen" @update:open="val => importOpen = val">
|
||
<DialogContent class="sm:max-w-[600px]">
|
||
<DialogHeader>
|
||
<DialogTitle>导入 JSON</DialogTitle>
|
||
<DialogDescription>JSON 对象的每个键会写入为一个 KV 项</DialogDescription>
|
||
</DialogHeader>
|
||
<div class="space-y-3 py-2">
|
||
<textarea v-model="importJson" rows="12" class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono" placeholder='{"key":"value"}'></textarea>
|
||
<p v-if="importError" class="text-sm text-red-500">{{ importError }}</p>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" @click="importOpen=false" :disabled="importing">取消</Button>
|
||
<Button @click="doImport" :disabled="importing">
|
||
<Loader2 v-if="importing" class="mr-2 h-4 w-4 animate-spin" />
|
||
开始导入
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped></style>
|