feat: 添加SECTL云服务集成功能

实现SECTL云服务的全面集成,包括:
1. 添加OAuth认证服务,支持PKCE流程
2. 实现云存储管理功能
3. 添加积分数据同步服务
4. 实现通知服务和KV存储服务
5. 添加多语言支持
6. 创建相关React组件和上下文
7. 更新依赖项添加crypto-js
8. 优化HTTP客户端重用
This commit is contained in:
Yukino_fox
2026-04-10 21:47:24 +08:00
parent 448df1a40b
commit bf9cb9af41
27 changed files with 8447 additions and 8 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
if (result.code) {
console.log("[OAuth] 授权码:", result.code)
console.log("[OAuth] State:", result.state, "期望:", getExpectedState())
// 验证 state 防止 CSRF
const expectedState = getExpectedState()
if (expectedState && result.state !== expectedState) {
+285
View File
@@ -0,0 +1,285 @@
/**
* SECTL 积分数据同步组件
* 提供云端数据同步功能
*/
import React, { useState, useEffect } from "react"
import { Card, Button, Space, Alert, Descriptions, message, Modal, Typography, Divider } from "antd"
import {
CloudSyncOutlined,
UploadOutlined,
DownloadOutlined,
DeleteOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
} from "@ant-design/icons"
import { scoreSyncService } from "../services/scoreSyncService"
import { useSectl } from "../contexts/SectlContext"
const { Title, Text } = Typography
interface SyncPanelProps {
// 从父组件获取数据的方法
onGetLocalData?: () => {
students: any[]
events: any[]
settings: { reasons: any[]; tags: any[] }
}
// 从云端恢复数据到本地的方法
onRestoreData?: (data: {
students: any[]
events: any[]
settings: { reasons: any[]; tags: any[] }
}) => void
}
export const ScoreSyncPanel: React.FC<SyncPanelProps> = ({ onGetLocalData, onRestoreData }) => {
const { isAuthenticated } = useSectl()
const [syncing, setSyncing] = useState(false)
const [restoring, setRestoring] = useState(false)
const [clearing, setClearing] = useState(false)
const [loading, setLoading] = useState(false)
const [metadata, setMetadata] = useState<any>(null)
const [hasData, setHasData] = useState(false)
// 检查云端数据状态
const checkCloudStatus = async () => {
if (!isAuthenticated) return
try {
setLoading(true)
const [meta, hasCloudData] = await Promise.all([
scoreSyncService.getSyncMetadata(),
scoreSyncService.hasCloudData(),
])
setMetadata(meta)
setHasData(hasCloudData)
} catch (error: any) {
console.error("检查云端状态失败:", error)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (isAuthenticated) {
checkCloudStatus()
}
}, [isAuthenticated])
// 上传数据到云端
const handleSyncToCloud = async () => {
if (!onGetLocalData) {
message.error("未配置数据获取方法")
return
}
try {
setSyncing(true)
const localData = onGetLocalData()
await scoreSyncService.fullSync(localData)
await checkCloudStatus()
message.success("数据已同步到云端")
} catch (error: any) {
message.error(`同步失败:${error.message}`)
} finally {
setSyncing(false)
}
}
// 从云端下载数据
const handleRestoreFromCloud = async () => {
Modal.confirm({
title: "确认恢复云端数据",
icon: <ExclamationCircleOutlined />,
content: "此操作将用云端数据覆盖本地数据,确定继续吗?",
okText: "确定",
cancelText: "取消",
onOk: async () => {
if (!onRestoreData) {
message.error("未配置数据恢复方法")
return
}
try {
setRestoring(true)
const cloudData = await scoreSyncService.restoreFromCloud()
onRestoreData({
students: cloudData.students as any[],
events: cloudData.events as any[],
settings: cloudData.settings,
})
message.success("数据已从云端恢复")
} catch (error: any) {
message.error(`恢复失败:${error.message}`)
} finally {
setRestoring(false)
}
},
})
}
// 清除云端数据
const handleClearCloudData = async () => {
Modal.confirm({
title: "确认清除云端数据",
icon: <ExclamationCircleOutlined />,
content: "此操作将永久删除云端的所有数据,且不可恢复!确定继续吗?",
okText: "确定",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: async () => {
try {
setClearing(true)
await scoreSyncService.clearCloudData()
await checkCloudStatus()
message.success("云端数据已清除")
} catch (error: any) {
message.error(`清除失败:${error.message}`)
} finally {
setClearing(false)
}
},
})
}
if (!isAuthenticated) {
return (
<Card>
<Alert
message="请先登录 SECTL"
description="登录后可使用云端数据同步功能"
type="info"
showIcon
/>
</Card>
)
}
return (
<div>
<Card
title={
<Space>
<CloudSyncOutlined />
<span></span>
</Space>
}
>
<Space direction="vertical" style={{ width: "100%" }} size="large">
{/* 云端状态 */}
<Alert
message={
hasData ? (
<Space>
<CheckCircleOutlined style={{ color: "#52c41a" }} />
<span></span>
</Space>
) : (
<Space>
<ExclamationCircleOutlined style={{ color: "#faad14" }} />
<span></span>
</Space>
)
}
description={
metadata ? (
<div>
<Text>
{new Date(metadata.lastSyncTime).toLocaleString("zh-CN")}
</Text>
<br />
<Text>{metadata.version}</Text>
</div>
) : (
"暂无同步记录"
)
}
type={hasData ? "success" : "warning"}
showIcon
/>
<Divider />
{/* 同步操作 */}
<div>
<Title level={5}></Title>
<Space direction="vertical" style={{ width: "100%" }}>
<Alert
message="数据同步说明"
description={
<div>
<ul style={{ margin: 0, paddingLeft: 20 }}>
<li></li>
<li></li>
<li></li>
</ul>
</div>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Space wrap>
<Button
type="primary"
icon={<UploadOutlined />}
onClick={handleSyncToCloud}
loading={syncing}
disabled={!onGetLocalData}
>
</Button>
<Button
icon={<DownloadOutlined />}
onClick={handleRestoreFromCloud}
loading={restoring}
disabled={!hasData || !onRestoreData}
>
</Button>
<Button
danger
icon={<DeleteOutlined />}
onClick={handleClearCloudData}
loading={clearing}
disabled={!hasData}
>
</Button>
<Button icon={<CloudSyncOutlined />} onClick={checkCloudStatus} loading={loading}>
</Button>
</Space>
</Space>
</div>
<Divider />
{/* 同步记录 */}
<div>
<Title level={5}></Title>
{metadata ? (
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="最后同步时间">
{new Date(metadata.lastSyncTime).toLocaleString("zh-CN")}
</Descriptions.Item>
<Descriptions.Item label="数据版本">{metadata.version}</Descriptions.Item>
<Descriptions.Item label="平台">
{metadata.platform || "SecScore"}
</Descriptions.Item>
</Descriptions>
) : (
<Text type="secondary"></Text>
)}
</div>
</Space>
</Card>
</div>
)
}
+313
View File
@@ -0,0 +1,313 @@
/**
* SECTL 云存储管理组件
*/
import React, { useState, useEffect } from "react"
import {
Card,
Table,
Button,
Upload,
Space,
Progress,
Modal,
Input,
message,
Popconfirm,
Typography,
} from "antd"
import {
CloudOutlined,
UploadOutlined,
DownloadOutlined,
DeleteOutlined,
ShareAltOutlined,
ReloadOutlined,
FileOutlined,
} from "@ant-design/icons"
import type { UploadFile } from "antd"
import { sectlCloudStorage, CloudFile, StorageUsage } from "../services/sectlCloudStorage"
import { useSectl } from "../contexts/SectlContext"
const { Title } = Typography
export const SectlCloudStorageManager: React.FC = () => {
const { isAuthenticated } = useSectl()
const [files, setFiles] = useState<CloudFile[]>([])
const [loading, setLoading] = useState(false)
const [storageUsage, setStorageUsage] = useState<StorageUsage | null>(null)
const [uploading, setUploading] = useState(false)
const [renameModal, setRenameModal] = useState<{
visible: boolean
fileId?: string
filename?: string
}>({ visible: false })
const [newFilename, setNewFilename] = useState("")
// 加载文件列表
const loadFiles = async () => {
if (!isAuthenticated) return
try {
setLoading(true)
const result = await sectlCloudStorage.listFiles({ limit: 100 })
setFiles(result.files)
} catch (error: any) {
message.error(`加载文件列表失败:${error.message}`)
} finally {
setLoading(false)
}
}
// 加载存储使用情况
const loadStorageUsage = async () => {
if (!isAuthenticated) return
try {
const usage = await sectlCloudStorage.getStorageUsage()
setStorageUsage(usage)
} catch (error: any) {
console.error("加载存储使用情况失败:", error)
}
}
useEffect(() => {
if (isAuthenticated) {
loadFiles()
loadStorageUsage()
}
}, [isAuthenticated])
// 处理文件上传
const handleUpload = async (file: UploadFile): Promise<void> => {
if (!file.originFileObj) return
try {
setUploading(true)
await sectlCloudStorage.uploadFile(file.originFileObj)
message.success(`文件 ${file.name} 上传成功`)
loadFiles()
loadStorageUsage()
} catch (error: any) {
message.error(`上传失败:${error.message}`)
} finally {
setUploading(false)
}
}
// 处理文件下载
const handleDownload = async (file: CloudFile) => {
try {
const { download_url } = await sectlCloudStorage.downloadFile(file.file_id)
window.open(download_url, "_blank")
message.success("开始下载")
} catch (error: any) {
message.error(`下载失败:${error.message}`)
}
}
// 处理文件删除
const handleDelete = async (file: CloudFile) => {
try {
await sectlCloudStorage.deleteFile(file.file_id)
message.success("文件已删除")
loadFiles()
loadStorageUsage()
} catch (error: any) {
message.error(`删除失败:${error.message}`)
}
}
// 处理文件重命名
const handleRename = async () => {
if (!renameModal.fileId || !newFilename) return
try {
await sectlCloudStorage.renameFile(renameModal.fileId, newFilename)
message.success("重命名成功")
setRenameModal({ visible: false })
setNewFilename("")
loadFiles()
} catch (error: any) {
message.error(`重命名失败:${error.message}`)
}
}
// 创建分享链接
const handleCreateShare = async (file: CloudFile) => {
try {
const share = await sectlCloudStorage.createShare(file.file_id, {
expires_in: 86400, // 1 天
})
// 复制到剪贴板
await navigator.clipboard.writeText(share.share_url)
message.success("分享链接已创建并复制到剪贴板")
} catch (error: any) {
message.error(`创建分享失败:${error.message}`)
}
}
// 表格列定义
const columns = [
{
title: "文件名",
dataIndex: "filename",
key: "filename",
render: (text: string, record: CloudFile) => (
<Space>
<FileOutlined />
<span>{text}</span>
<Typography.Text type="secondary" style={{ fontSize: "12px" }}>
.{record.extension}
</Typography.Text>
</Space>
),
},
{
title: "大小",
dataIndex: "size_formatted",
key: "size_formatted",
width: 100,
},
{
title: "上传时间",
dataIndex: "created_at",
key: "created_at",
width: 180,
render: (text: string) => new Date(text).toLocaleString("zh-CN"),
},
{
title: "操作",
key: "action",
width: 250,
render: (_: any, record: CloudFile) => (
<Space>
<Button type="link" icon={<DownloadOutlined />} onClick={() => handleDownload(record)}>
</Button>
<Button type="link" icon={<ShareAltOutlined />} onClick={() => handleCreateShare(record)}>
</Button>
<Button
type="link"
onClick={() =>
setRenameModal({
visible: true,
fileId: record.file_id,
filename: record.filename,
})
}
>
</Button>
<Popconfirm
title="确定要删除这个文件吗?"
onConfirm={() => handleDelete(record)}
okText="确定"
cancelText="取消"
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
]
if (!isAuthenticated) {
return (
<Card>
<div style={{ textAlign: "center", padding: "40px" }}>
<CloudOutlined style={{ fontSize: 48, color: "#999" }} />
<Title level={4} style={{ marginTop: 16 }}>
SECTL 使
</Title>
</div>
</Card>
)
}
return (
<div>
<Card
title={
<Space>
<CloudOutlined />
<span></span>
</Space>
}
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={loadFiles} loading={loading}>
</Button>
<Upload
accept="*/*"
showUploadList={false}
beforeUpload={handleUpload}
disabled={uploading}
>
<Button type="primary" icon={<UploadOutlined />} loading={uploading}>
</Button>
</Upload>
</Space>
}
>
{/* 存储使用情况 */}
{storageUsage && (
<div style={{ marginBottom: 24 }}>
<Space direction="vertical" style={{ width: "100%" }} size="small">
<div>
{storageUsage.used_storage_formatted} / {storageUsage.total_storage_formatted}
</div>
<Progress
percent={storageUsage.percentage}
status={storageUsage.percentage > 90 ? "exception" : "active"}
/>
<div style={{ fontSize: "12px", color: "#999" }}>
{storageUsage.file_count}
</div>
</Space>
</div>
)}
{/* 文件列表 */}
<Table
columns={columns}
dataSource={files}
rowKey="file_id"
loading={loading}
pagination={{
pageSize: 20,
showSizeChanger: true,
showTotal: (total) => `${total} 个文件`,
}}
/>
</Card>
{/* 重命名对话框 */}
<Modal
title="重命名文件"
open={renameModal.visible}
onOk={handleRename}
onCancel={() => {
setRenameModal({ visible: false })
setNewFilename("")
}}
okText="确定"
cancelText="取消"
>
<Input
placeholder="请输入新文件名"
value={newFilename}
onChange={(e) => setNewFilename(e.target.value)}
onPressEnter={handleRename}
defaultValue={renameModal.filename}
/>
</Modal>
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
/**
* SECTL 登录按钮组件
*/
import React, { useState } from "react"
import { Button, message } from "antd"
import { LoginOutlined, UserOutlined } from "@ant-design/icons"
import { useSectl } from "../contexts/SectlContext"
interface SectlLoginButtonProps {
onLoginSuccess?: () => void
onLogoutSuccess?: () => void
scope?: string[]
}
export const SectlLoginButton: React.FC<SectlLoginButtonProps> = ({
onLoginSuccess,
onLogoutSuccess,
scope = ["user:read", "cloud:read", "cloud:write"],
}) => {
const { isAuthenticated, isLoading, userInfo, login, logout } = useSectl()
const [isLogging, setIsLogging] = useState(false)
const handleLogin = async () => {
try {
setIsLogging(true)
await login(scope)
message.success("登录成功!")
onLoginSuccess?.()
} catch (error: any) {
message.error(`登录失败:${error.message}`)
} finally {
setIsLogging(false)
}
}
const handleLogout = async () => {
try {
setIsLogging(true)
await logout()
message.success("已退出登录")
onLogoutSuccess?.()
} catch (error: any) {
message.error(`登出失败:${error.message}`)
} finally {
setIsLogging(false)
}
}
if (isLoading) {
return <Button loading>...</Button>
}
if (isAuthenticated) {
return (
<Button icon={<UserOutlined />} onClick={handleLogout} loading={isLogging} danger>
{userInfo?.name || "退出登录"}
</Button>
)
}
return (
<Button type="primary" icon={<LoginOutlined />} onClick={handleLogin} loading={isLogging}>
使 SECTL
</Button>
)
}
+157
View File
@@ -0,0 +1,157 @@
/**
* SECTL 设置面板组件
*/
import React, { useState } from "react"
import { Card, Form, Input, Button, Space, Alert, Tag, Descriptions, message } from "antd"
import {
SettingOutlined,
UserOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from "@ant-design/icons"
import { useSectl } from "../contexts/SectlContext"
import { SectlLoginButton } from "./SectlLoginButton"
import { ScoreSyncPanel } from "./ScoreSyncPanel"
export const SectlSettingsPanel: React.FC = () => {
const { isAuthenticated, isLoading, userInfo, platformId, setPlatformId, refreshUserInfo } =
useSectl()
const [form] = Form.useForm()
const [isSaving, setIsSaving] = useState(false)
// 保存平台 ID 配置
const handleSaveConfig = async (values: any) => {
try {
setIsSaving(true)
setPlatformId(values.platformId)
// 保存到本地存储
localStorage.setItem("sectl_platform_id", values.platformId)
message.success("配置已保存")
} catch (error: any) {
message.error(`保存失败:${error.message}`)
} finally {
setIsSaving(false)
}
}
// 加载已保存的配置
React.useEffect(() => {
const savedPlatformId = localStorage.getItem("sectl_platform_id")
if (savedPlatformId) {
form.setFieldsValue({ platformId: savedPlatformId })
}
}, [form])
return (
<div>
{/* 认证状态卡片 */}
<Card
title={
<Space>
<UserOutlined />
<span>SECTL </span>
</Space>
}
style={{ marginBottom: 24 }}
>
<Space direction="vertical" style={{ width: "100%" }} size="large">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<Space>
<span></span>
{isLoading ? (
<Tag color="processing">...</Tag>
) : isAuthenticated ? (
<Tag icon={<CheckCircleOutlined />} color="success">
</Tag>
) : (
<Tag icon={<CloseCircleOutlined />} color="default">
</Tag>
)}
</Space>
</div>
<SectlLoginButton onLoginSuccess={refreshUserInfo} onLogoutSuccess={() => {}} />
</div>
{isAuthenticated && userInfo && (
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="用户 ID">{userInfo.user_id}</Descriptions.Item>
<Descriptions.Item label="用户名">{userInfo.name}</Descriptions.Item>
<Descriptions.Item label="邮箱">{userInfo.email}</Descriptions.Item>
<Descriptions.Item label="权限">{userInfo.permission}</Descriptions.Item>
<Descriptions.Item label="角色">{userInfo.role}</Descriptions.Item>
{userInfo.github_username && (
<Descriptions.Item label="GitHub">{userInfo.github_username}</Descriptions.Item>
)}
</Descriptions>
)}
</Space>
</Card>
{/* 平台配置卡片 */}
<Card
title={
<Space>
<SettingOutlined />
<span></span>
</Space>
}
style={{ marginBottom: 24 }}
>
<Form
form={form}
layout="vertical"
onFinish={handleSaveConfig}
initialValues={{ platformId }}
>
<Alert
message="平台 ID 配置"
description="请在 SECTL 控制台创建应用后填写平台 IDClient ID"
type="info"
showIcon
style={{ marginBottom: 24 }}
/>
<Form.Item
label="平台 ID (Client ID)"
name="platformId"
rules={[
{ required: true, message: "请输入平台 ID" },
{ pattern: /^pf_[a-zA-Z0-9]+$/, message: "平台 ID 格式不正确" },
]}
>
<Input placeholder="例如:pf_xxxxxxxxxxxx" />
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
loading={isSaving}
icon={<SettingOutlined />}
>
</Button>
<Button
onClick={() => {
form.resetFields()
}}
>
</Button>
</Space>
</Form.Item>
</Form>
</Card>
{/* 数据同步面板 */}
<ScoreSyncPanel />
</div>
)
}