feat(kv-storage): 实现 KV 存储服务及管理界面

- 重构 KV 存储服务,遵循 SECTL-One-Stop SDK 规范
- 新增 KV 存储管理组件,支持增删改查操作
- 优化 setKV 接口,移除冗余参数
- 添加类型定义和列表查询选项
This commit is contained in:
Yukino_fox
2026-04-19 20:55:50 +08:00
parent 6ce87541b7
commit 1cbca5a5c1
6 changed files with 520 additions and 70 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+436
View File
@@ -0,0 +1,436 @@
/**
* SECTL KV 存储管理组件
* 基于 SECTL-One-Stop SDK 规范实现
*/
import React, { useState, useEffect } from "react"
import {
Card,
Table,
Button,
Space,
Modal,
Input,
message,
Popconfirm,
Typography,
Tag,
Form,
Switch,
InputNumber,
Tooltip,
} from "antd"
import {
DatabaseOutlined,
PlusOutlined,
DeleteOutlined,
EditOutlined,
ReloadOutlined,
CodeOutlined,
FieldStringOutlined,
} from "@ant-design/icons"
import { sectlKVStorage, KVItem, ListKVOptions } from "../services/sectlKVStorage"
import { useSectl } from "../contexts/SectlContext"
const { Title, Text } = Typography
const { TextArea } = Input
export const SectlKVStorageManager: React.FC = () => {
const { isAuthenticated } = useSectl()
const [kvList, setKvList] = useState<KVItem[]>([])
const [loading, setLoading] = useState(false)
const [total, setTotal] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [offset, setOffset] = useState(0)
const [prefix, setPrefix] = useState("")
// 编辑/创建对话框状态
const [editModal, setEditModal] = useState<{
visible: boolean
isEdit: boolean
key?: string
}>({ visible: false, isEdit: false })
// 查看详情对话框状态
const [detailModal, setDetailModal] = useState<{
visible: boolean
item?: KVItem
}>({ visible: false })
// 表单
const [form] = Form.useForm()
// 加载 KV 列表
const loadKVList = async (resetOffset = false) => {
if (!isAuthenticated) return
try {
setLoading(true)
const currentOffset = resetOffset ? 0 : offset
const options: ListKVOptions = {
limit: 20,
offset: currentOffset,
}
if (prefix) {
options.prefix = prefix
}
const result = await sectlKVStorage.listKV(options)
setKvList(resetOffset ? result.kv_list : [...kvList, ...result.kv_list])
setTotal(result.total)
setHasMore(result.has_more)
if (resetOffset) {
setOffset(20)
} else {
setOffset(currentOffset + 20)
}
} catch (error: any) {
message.error(`加载键值对列表失败:${error.message}`)
} finally {
setLoading(false)
}
}
// 初始加载
useEffect(() => {
if (isAuthenticated) {
loadKVList(true)
}
}, [isAuthenticated, prefix])
// 处理创建/更新
const handleSave = async (values: {
key: string
value: string
isJson: boolean
ttl?: number
}) => {
try {
let parsedValue: unknown
if (values.isJson) {
try {
parsedValue = JSON.parse(values.value)
} catch {
message.error("JSON 格式无效")
return
}
} else {
parsedValue = values.value
}
await sectlKVStorage.setKV(values.key, parsedValue, values.ttl)
message.success(editModal.isEdit ? "键值对已更新" : "键值对已创建")
setEditModal({ visible: false, isEdit: false })
form.resetFields()
loadKVList(true)
} catch (error: any) {
message.error(`保存失败:${error.message}`)
}
}
// 处理删除
const handleDelete = async (key: string) => {
try {
await sectlKVStorage.deleteKV(key)
message.success("键值对已删除")
loadKVList(true)
} catch (error: any) {
message.error(`删除失败:${error.message}`)
}
}
// 打开编辑对话框
const openEditModal = (item: KVItem) => {
form.setFieldsValue({
key: item.key,
value: item.is_json ? JSON.stringify(item.value, null, 2) : String(item.value),
isJson: item.is_json,
})
setEditModal({ visible: true, isEdit: true, key: item.key })
}
// 打开创建对话框
const openCreateModal = () => {
form.resetFields()
form.setFieldsValue({ isJson: true })
setEditModal({ visible: true, isEdit: false })
}
// 打开详情对话框
const openDetailModal = (item: KVItem) => {
setDetailModal({ visible: true, item })
}
// 格式化值显示
const formatValue = (value: unknown, isJson: boolean): string => {
if (isJson) {
return JSON.stringify(value).slice(0, 100) + (JSON.stringify(value).length > 100 ? "..." : "")
}
return String(value).slice(0, 100) + (String(value).length > 100 ? "..." : "")
}
// 表格列定义
const columns = [
{
title: "键名",
dataIndex: "key",
key: "key",
render: (text: string, record: KVItem) => (
<Space>
<DatabaseOutlined />
<a onClick={() => openDetailModal(record)}>{text}</a>
</Space>
),
},
{
title: "类型",
dataIndex: "is_json",
key: "is_json",
width: 100,
render: (isJson: boolean) =>
isJson ? (
<Tag color="blue" icon={<CodeOutlined />}>
JSON
</Tag>
) : (
<Tag icon={<FieldStringOutlined />}></Tag>
),
},
{
title: "大小",
dataIndex: "size",
key: "size",
width: 100,
render: (size: number) => {
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`
return `${(size / (1024 * 1024)).toFixed(2)} MB`
},
},
{
title: "值预览",
dataIndex: "value",
key: "value",
ellipsis: true,
render: (_: unknown, record: KVItem) => (
<Text type="secondary" style={{ fontSize: "12px" }}>
{formatValue(record.value, record.is_json)}
</Text>
),
},
{
title: "更新时间",
dataIndex: "updated_at",
key: "updated_at",
width: 180,
render: (text: string) => new Date(text).toLocaleString("zh-CN"),
},
{
title: "操作",
key: "action",
width: 150,
render: (_: unknown, record: KVItem) => (
<Space>
<Tooltip title="编辑">
<Button type="link" icon={<EditOutlined />} onClick={() => openEditModal(record)} />
</Tooltip>
<Popconfirm
title="确定要删除这个键值对吗?"
onConfirm={() => handleDelete(record.key)}
okText="确定"
cancelText="取消"
>
<Button type="link" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
]
if (!isAuthenticated) {
return (
<Card>
<div style={{ textAlign: "center", padding: "40px" }}>
<DatabaseOutlined style={{ fontSize: 48, color: "#999" }} />
<Title level={4} style={{ marginTop: 16 }}>
SECTL 使 KV
</Title>
</div>
</Card>
)
}
return (
<div>
<Card
title={
<Space>
<DatabaseOutlined />
<span>KV </span>
</Space>
}
extra={
<Space>
<Input
placeholder="键名前缀过滤"
value={prefix}
onChange={(e) => setPrefix(e.target.value)}
style={{ width: 200 }}
allowClear
/>
<Button icon={<ReloadOutlined />} onClick={() => loadKVList(true)} loading={loading}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
</Button>
</Space>
}
>
{/* 统计信息 */}
<div style={{ marginBottom: 16 }}>
<Text type="secondary">
{total}
{hasMore && `(已加载 ${kvList.length} 个)`}
</Text>
</div>
{/* KV 列表 */}
<Table
columns={columns}
dataSource={kvList}
rowKey="key"
loading={loading}
pagination={false}
/>
{/* 加载更多 */}
{hasMore && (
<div style={{ textAlign: "center", marginTop: 16 }}>
<Button onClick={() => loadKVList(false)} loading={loading}>
</Button>
</div>
)}
</Card>
{/* 创建/编辑对话框 */}
<Modal
title={editModal.isEdit ? "编辑键值对" : "新建键值对"}
open={editModal.visible}
onOk={() => form.submit()}
onCancel={() => {
setEditModal({ visible: false, isEdit: false })
form.resetFields()
}}
okText="保存"
cancelText="取消"
width={600}
>
<Form form={form} layout="vertical" onFinish={handleSave}>
<Form.Item
name="key"
label="键名"
rules={[
{ required: true, message: "请输入键名" },
{ max: 255, message: "键名最大 255 字符" },
]}
>
<Input placeholder="请输入键名" disabled={editModal.isEdit} />
</Form.Item>
<Form.Item name="isJson" label="数据类型" valuePropName="checked">
<Switch checkedChildren="JSON" unCheckedChildren="文本" />
</Form.Item>
<Form.Item name="value" label="值" rules={[{ required: true, message: "请输入值" }]}>
<TextArea
rows={8}
placeholder={
form.getFieldValue("isJson")
? '请输入 JSON 格式数据,例如:\n{\n "theme": "dark",\n "language": "zh-CN"\n}'
: "请输入文本值"
}
/>
</Form.Item>
<Form.Item name="ttl" label="过期时间(秒)">
<InputNumber style={{ width: "100%" }} placeholder="留空表示永不过期" min={1} />
</Form.Item>
</Form>
</Modal>
{/* 详情对话框 */}
<Modal
title="键值对详情"
open={detailModal.visible}
onCancel={() => setDetailModal({ visible: false })}
footer={[
<Button key="close" onClick={() => setDetailModal({ visible: false })}>
</Button>,
]}
width={700}
>
{detailModal.item && (
<Space direction="vertical" style={{ width: "100%" }} size="large">
<div>
<Text strong></Text>
<Text copyable>{detailModal.item.key}</Text>
</div>
<div>
<Text strong></Text>
{detailModal.item.is_json ? (
<Tag color="blue" icon={<CodeOutlined />}>
JSON
</Tag>
) : (
<Tag icon={<FieldStringOutlined />}></Tag>
)}
</div>
<div>
<Text strong></Text>
<Text>
{detailModal.item.size < 1024
? `${detailModal.item.size} B`
: detailModal.item.size < 1024 * 1024
? `${(detailModal.item.size / 1024).toFixed(2)} KB`
: `${(detailModal.item.size / (1024 * 1024)).toFixed(2)} MB`}
</Text>
</div>
<div>
<Text strong></Text>
<Text>{new Date(detailModal.item.created_at).toLocaleString("zh-CN")}</Text>
</div>
<div>
<Text strong></Text>
<Text>{new Date(detailModal.item.updated_at).toLocaleString("zh-CN")}</Text>
</div>
<div>
<Text strong></Text>
<pre
style={{
background: "#f5f5f5",
padding: 16,
borderRadius: 8,
overflow: "auto",
maxHeight: 400,
}}
>
{detailModal.item.is_json
? JSON.stringify(detailModal.item.value, null, 2)
: String(detailModal.item.value)}
</pre>
</div>
</Space>
)}
</Modal>
</div>
)
}
+8
View File
@@ -13,6 +13,8 @@ import {
import { useSectl } from "../contexts/SectlContext" import { useSectl } from "../contexts/SectlContext"
import { SectlLoginButton } from "./SectlLoginButton" import { SectlLoginButton } from "./SectlLoginButton"
import { ScoreSyncPanel } from "./ScoreSyncPanel" import { ScoreSyncPanel } from "./ScoreSyncPanel"
import { SectlCloudStorageManager } from "./SectlCloudStorageManager"
import { SectlKVStorageManager } from "./SectlKVStorageManager"
export const SectlSettingsPanel: React.FC = () => { export const SectlSettingsPanel: React.FC = () => {
const { isAuthenticated, isLoading, userInfo, platformId, setPlatformId, refreshUserInfo } = const { isAuthenticated, isLoading, userInfo, platformId, setPlatformId, refreshUserInfo } =
@@ -152,6 +154,12 @@ export const SectlSettingsPanel: React.FC = () => {
{/* 数据同步面板 */} {/* 数据同步面板 */}
<ScoreSyncPanel /> <ScoreSyncPanel />
{/* 云存储管理 */}
<SectlCloudStorageManager />
{/* KV 存储管理 */}
<SectlKVStorageManager />
</div> </div>
) )
} }
+1 -1
View File
@@ -836,7 +836,7 @@ const api = {
}> => invoke("plugin_get_runtime_modules"), }> => invoke("plugin_get_runtime_modules"),
// Generic invoke wrapper for backward compatibility with callers using `api.invoke` // Generic invoke wrapper for backward compatibility with callers using `api.invoke`
invoke: async (channel: string, ..._args: any[]): Promise<any> => { invoke: async (channel: string): Promise<any> => {
switch (channel) { switch (channel) {
default: default:
throw new Error(`Unsupported legacy invoke channel: ${channel}`) throw new Error(`Unsupported legacy invoke channel: ${channel}`)
+4 -12
View File
@@ -60,9 +60,7 @@ class ScoreSyncService {
})) }))
// 保存到云端 // 保存到云端
await sectlKVStorage.setKV(DATA_KEYS.STUDENTS, scoreData, { await sectlKVStorage.setKV(DATA_KEYS.STUDENTS, scoreData)
is_json: true,
})
console.log("学生数据同步成功") console.log("学生数据同步成功")
} catch (error: any) { } catch (error: any) {
@@ -107,9 +105,7 @@ class ScoreSyncService {
createdAt: event.created_at, createdAt: event.created_at,
})) }))
await sectlKVStorage.setKV(DATA_KEYS.EVENTS, scoreEvents, { await sectlKVStorage.setKV(DATA_KEYS.EVENTS, scoreEvents)
is_json: true,
})
console.log("积分事件同步成功") console.log("积分事件同步成功")
} catch (error: any) { } catch (error: any) {
@@ -146,9 +142,7 @@ class ScoreSyncService {
} }
try { try {
await sectlKVStorage.setKV(DATA_KEYS.SETTINGS, settings, { await sectlKVStorage.setKV(DATA_KEYS.SETTINGS, settings)
is_json: true,
})
console.log("设置数据同步成功") console.log("设置数据同步成功")
} catch (error: any) { } catch (error: any) {
@@ -186,9 +180,7 @@ class ScoreSyncService {
platform: "secscore", platform: "secscore",
} }
await sectlKVStorage.setKV(DATA_KEYS.SYNC_META, metadata, { await sectlKVStorage.setKV(DATA_KEYS.SYNC_META, metadata)
is_json: true,
})
} }
/** /**
+70 -56
View File
@@ -1,30 +1,47 @@
/** /**
* SECTL KV 键值对存储服务 * SECTL KV 键值对存储服务
* 基于 SECTL-One-Stop SDK 规范实现
* 提供键值对存储功能,支持 JSON 格式和字段级操作 * 提供键值对存储功能,支持 JSON 格式和字段级操作
*/ */
import { SECTL_CONFIG, sectlAuth } from "./sectlAuth" import { SECTL_CONFIG, sectlAuth } from "./sectlAuth"
import { KVData } from "./sectlCloudStorage"
// KV 数据类型
export interface KVItem {
key: string
value: any
size: number
is_json: boolean
created_at: string
updated_at: string
}
// KV 列表选项
export interface ListKVOptions {
prefix?: string
limit?: number
offset?: number
}
class SectlKVStorageService { class SectlKVStorageService {
/** /**
* 创建或更新键值对 * 设置 KV 键值对
* @param key 键名(最大 255 字符)
* @param value 值(任意 JSON 可序列化数据,最大 64KB)
* @param ttl 过期时间(秒),可选
*/ */
async setKV( async setKV(
key: string, key: string,
value: any, value: unknown,
options?: { ttl?: number
is_json?: boolean
}
): Promise<{ ): Promise<{
success: boolean success: boolean
kv_id: string
key: string key: string
value: any
is_json: boolean
size: number size: number
created_at: string is_json: boolean
updated_at: string created_at?: string
updated_at?: string
message: string
}> { }> {
const accessToken = sectlAuth.getAccessToken() const accessToken = sectlAuth.getAccessToken()
if (!accessToken) { if (!accessToken) {
@@ -33,26 +50,15 @@ class SectlKVStorageService {
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv` const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv`
// 自动检测是否为 JSON 格式 const body: Record<string, unknown> = {
let valueStr: string client_id: SECTL_CONFIG.platformId,
let isJson = options?.is_json user_id: sectlAuth.getToken()?.user_id || "",
key,
value,
}
if (typeof value === "object") { if (ttl !== undefined) {
valueStr = JSON.stringify(value) body.ttl = ttl
if (isJson === undefined) {
isJson = true
}
} else {
valueStr = String(value)
if (isJson === undefined) {
// 尝试检测是否为 JSON 字符串
try {
JSON.parse(valueStr)
isJson = true
} catch {
isJson = false
}
}
} }
try { try {
@@ -62,13 +68,7 @@ class SectlKVStorageService {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify(body),
client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "",
key,
value: valueStr,
is_json: isJson,
}),
}) })
if (!response.ok) { if (!response.ok) {
@@ -84,14 +84,17 @@ class SectlKVStorageService {
} }
/** /**
* 获取键值对 * 获取 KV 键值对
* @param key 键名
* @param field JSON 字段路径,如 "theme" 或 "user.name",可选
*/ */
async getKV<T = any>( async getKV<T = unknown>(
key: string, key: string,
options?: { field?: string
field?: string // 获取 JSON 中的特定字段 ): Promise<
} | (KVItem & { key: string; value: T })
): Promise<KVData | { key: string; field: string; value: T }> { | { key: string; field: string; value: T; is_json: boolean }
> {
const accessToken = sectlAuth.getAccessToken() const accessToken = sectlAuth.getAccessToken()
if (!accessToken) { if (!accessToken) {
throw new Error("未授权,请先登录") throw new Error("未授权,请先登录")
@@ -102,8 +105,8 @@ class SectlKVStorageService {
user_id: sectlAuth.getToken()?.user_id || "", user_id: sectlAuth.getToken()?.user_id || "",
}) })
if (options?.field) { if (field) {
params.append("field", options.field) params.append("field", field)
} }
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}?${params.toString()}` const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}?${params.toString()}`
@@ -128,12 +131,14 @@ class SectlKVStorageService {
} }
/** /**
* 获取键值对列表 * 获取 KV 键值对列表
* @param options 列表选项
*/ */
async listKV(options?: { async listKV(options: ListKVOptions = {}): Promise<{
limit?: number kv_list: KVItem[]
offset?: number total: number
}): Promise<{ kvs: KVData[]; total: number; has_more: boolean }> { has_more: boolean
}> {
const accessToken = sectlAuth.getAccessToken() const accessToken = sectlAuth.getAccessToken()
if (!accessToken) { if (!accessToken) {
throw new Error("未授权,请先登录") throw new Error("未授权,请先登录")
@@ -142,10 +147,14 @@ class SectlKVStorageService {
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: SECTL_CONFIG.platformId, client_id: SECTL_CONFIG.platformId,
user_id: sectlAuth.getToken()?.user_id || "", user_id: sectlAuth.getToken()?.user_id || "",
limit: String(options?.limit || 100), limit: String(options.limit || 100),
offset: String(options?.offset || 0), offset: String(options.offset || 0),
}) })
if (options.prefix) {
params.append("prefix", options.prefix)
}
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv?${params.toString()}` const url = `${SECTL_CONFIG.baseUrl}/api/cloud/kv?${params.toString()}`
try { try {
@@ -168,18 +177,22 @@ class SectlKVStorageService {
} }
/** /**
* 更新 JSON 字段 * 更新 KV JSON 字段
* @param key 键名
* @param field JSON 字段路径,支持嵌套如 "user.name" 或数组索引 "items.0.id"
* @param value 要设置的值
*/ */
async updateKVField( async updateKVField(
key: string, key: string,
field: string, field: string,
value: any value: unknown
): Promise<{ ): Promise<{
success: boolean success: boolean
key: string key: string
field: string field: string
value: any size: number
updated_at: string updated_at: string
message: string
}> { }> {
const accessToken = sectlAuth.getAccessToken() const accessToken = sectlAuth.getAccessToken()
if (!accessToken) { if (!accessToken) {
@@ -216,7 +229,8 @@ class SectlKVStorageService {
} }
/** /**
* 删除键值对 * 删除 KV 键值对
* @param key 键名
*/ */
async deleteKV(key: string): Promise<{ success: boolean; message: string }> { async deleteKV(key: string): Promise<{ success: boolean; message: string }> {
const accessToken = sectlAuth.getAccessToken() const accessToken = sectlAuth.getAccessToken()