ClassworksKVAdmin/src/pages/kv-manager.vue

542 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>