1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2025-10-22 10:23:09 +00:00

适配ClassworksKV

This commit is contained in:
SunWuyuan 2025-10-06 14:30:32 +08:00
parent 6cf67d17c6
commit 68d6582ae0
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
13 changed files with 835 additions and 1207 deletions

View File

@ -1,5 +1,8 @@
<template>
<v-app>
<!-- KvInitialize 组件自行决定是否展示或执行跳转 -->
<kv-initialize />
<!-- 正常路由 -->
<router-view v-slot="{ Component, route }">
<transition name="md3" mode="out-in">
<component :is="Component" :key="route.path" />
@ -11,67 +14,29 @@
</template>
<script setup>
import { onMounted, watch } from "vue";
import { onMounted } from "vue";
import { useTheme } from "vuetify";
import { getSetting } from "@/utils/settings";
import { useRouter, useRoute } from "vue-router";
import RateLimitModal from "@/components/RateLimitModal.vue";
import KvInitialize from "@/components/KvInitialize.vue";
import Clarity from "@microsoft/clarity";
import { kvServerProvider } from '@/utils/providers/kvServerProvider';
const theme = useTheme();
const router = useRouter();
const route = useRoute();
onMounted(async () => {
onMounted(() => {
//
const savedTheme = getSetting("theme.mode");
theme.global.name.value = savedTheme;
//
checkProviderType();
Clarity.identify(getSetting("device.uuid"), getSetting("server.domain"), getSetting("server.provider"), getSetting("server.classNumber")); // only custom-id is required
// 使KV
const provider = getSetting('server.provider');
if (provider === 'kv-server' || provider === 'classworkscloud') {
try {
await kvServerProvider.loadNamespaceInfo();
} catch (error) {
console.error('加载命名空间信息失败:', error);
}
}
});
//
function checkProviderType() {
const currentProvider = getSetting("server.provider");
//
if (
(currentProvider === "server" || currentProvider === "indexedDB") &&
route.path !== "/datamigration"
) {
console.log("检测到旧的数据提供者类型,正在重定向到数据迁移页面...");
router.push({
path: "/datamigration",
query: {
reason: "legacy_provider",
provider: currentProvider,
},
});
}
}
//
watch(
() => route.path,
(newPath) => {
if (newPath !== "/datamigration") {
checkProviderType();
}
}
// Clarity App
Clarity.identify(
getSetting("device.uuid"),
getSetting("server.domain"),
getSetting("server.provider"),
getSetting("server.classNumber")
);
});
</script>
<style>
.md3-enter-active,

View File

@ -0,0 +1,195 @@
<template>
<div>
<v-dialog
v-model="visible"
persistent
transition="dialog-bottom-transition"
>
<v-card
class="kvinit-card"
elevation="8"
title="初始化云端存储授权"
subtitle="请完成授权以启用云端存储功能"
prepend-icon="mdi-cloud-lock"
>
<v-card-actions class="justify-end">
<v-btn
text
class="me-3"
@click="useLocalMode"
>
使用本地模式
</v-btn>
<v-btn
color="primary"
variant="flat"
:loading="loading"
@click="goToAuthorize"
>
前往授权
</v-btn>
</v-card-actions>
<div class="d-flex align-center justify-space-between">
<div>
<div
v-if="loading"
class="d-flex align-center"
>
<v-progress-circular
indeterminate
size="20"
width="2"
class="me-2"
/>
<span class="body-2"> 正在检查授权状态 </span>
</div>
<div
v-else-if="error"
class="body-2 text-error"
>
检查出错{{ error }}
</div>
</div>
</div>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from "vue";
import { useRoute } from "vue-router";
import { getSetting,setSetting } from "@/utils/settings";
import { kvServerProvider } from "@/utils/providers/kvServerProvider";
const visible = ref(false);
const loading = ref(false);
const error = ref("");
const route = useRoute();
// allow external components to reopen the dialog via an event
const onExternalOpen = () => {
visible.value = true;
};
// Guard key to avoid infinite redirect loops across reloads
const REDIRECT_GUARD_KEY = "kvinit.redirecting";
const isKvProvider = (provider) =>
provider === "kv-server" || provider === "classworkscloud";
const shouldInitialize = () => {
const provider = getSetting("server.provider");
if (!isKvProvider(provider)) return false;
if (route.path === "/authorize") return false; // don't run during callback
const kvToken = getSetting("server.kvToken");
return kvToken === "" || kvToken == null;
};
const goToAuthorize = () => {
const authDomain = getSetting("server.authDomain");
const appId = import.meta.env.VITE_APP_ID || "1";
const currentDomain = window.location.origin;
const callbackUrl = encodeURIComponent(`${currentDomain}/authorize`);
const uuid =
getSetting("device.uuid") || "00000000-0000-4000-8000-000000000000";
let authorizeUrl = `${authDomain}/authorize?app_id=${appId}&mode=callback&callback_url=${callbackUrl}`;
// UUID uuid
if (uuid !== "00000000-0000-4000-8000-000000000000") {
authorizeUrl += `&uuid=${encodeURIComponent(uuid)}`;
}
// set a short-lived guard to prevent immediate re-redirect
try {
const guardObj = { ts: Date.now() };
sessionStorage.setItem(REDIRECT_GUARD_KEY, JSON.stringify(guardObj));
} catch (err) {
// sessionStorage may be unavailable in some environments
console.debug("sessionStorage set failed", err);
}
window.location.href = authorizeUrl;
};
const tryLoadNamespace = async () => {
try {
await kvServerProvider.loadNamespaceInfo();
} catch (err) {
console.error("加载命名空间信息失败:", err);
// not fatal, show non-blocking error
error.value = err && err.message ? err.message : String(err);
}
};
const useLocalMode = () => {
// Switch to local provider and hide dialog
setSetting("server.provider", "kv-local");
visible.value = false;
// Reload to let app re-evaluate
location.reload();
};
onMounted(async () => {
const provider = getSetting("server.provider");
// If not using kv provider, hide component immediately
if (!isKvProvider(provider)) {
visible.value = false;
return;
}
// First try loading namespace info (safe operation) so the app can continue if already authorized
loading.value = true;
await tryLoadNamespace();
loading.value = false;
// Decide whether we must show initialization UI / redirect
if (shouldInitialize()) {
// If there's a guard in sessionStorage and it's recent, don't auto-redirect to avoid loops
let guarded = false;
try {
const raw = sessionStorage.getItem(REDIRECT_GUARD_KEY);
if (raw) {
const obj = JSON.parse(raw);
// guard valid for 30 seconds
if (obj && obj.ts && Date.now() - obj.ts < 30000) guarded = true;
}
} catch (err) {
// ignore parse errors but log for debugging
console.debug("sessionStorage parse guard failed", err);
}
visible.value = true;
// Only auto-redirect if UUID is non-default (we have a device to migrate)
const uuid =
getSetting("device.uuid") || "00000000-0000-4000-8000-000000000000";
const isDefaultUuid = uuid === "00000000-0000-4000-8000-000000000000";
if (!guarded && !isDefaultUuid) {
// auto-redirect to authorize for better UX
goToAuthorize();
} else {
// if guarded or uuid is default, stay on the init UI and let user click button
// clear guard so subsequent attempts can redirect
try {
sessionStorage.removeItem(REDIRECT_GUARD_KEY);
} catch (err) {
console.debug("sessionStorage remove failed", err);
}
}
} else {
// not initializing: hide component
visible.value = false;
}
});
// add/remove listener in lifecycle hooks
if (typeof window !== "undefined") {
window.addEventListener('kvinit:open', onExternalOpen);
}
onBeforeUnmount(() => {
if (typeof window !== "undefined") {
window.removeEventListener('kvinit:open', onExternalOpen);
}
});
</script>

View File

@ -1,223 +0,0 @@
<template>
<div class="namespace-access" v-if="shouldShowAccess">
<!-- 只读状态显示 -->
<v-chip
v-if="isReadOnly"
color="warning"
prepend-icon="mdi-lock-outline"
>
只读
</v-chip>
<v-btn
v-if="isReadOnly"
color="primary"
class="rounded-xl"
prepend-icon="mdi-lock-open-variant"
@click="openPasswordDialog"
:disabled="loading"
>
启用编辑
</v-btn>
<!-- 密码输入对话框 -->
<v-dialog v-model="dialog" max-width="400" persistent>
<v-card class="rounded-xl" border hover>
<v-card-title class="text-h6">输入访问密码</v-card-title>
<v-card-text>
<v-text-field
v-model="password"
label="密码"
variant="outlined"
:error="!!error"
:error-messages="error"
@keyup.enter="checkPassword"
@click:append-inner="showPassword = !showPassword"
:disabled="loading"
autofocus
/>
<p v-if="passwordHint">密码提示{{ passwordHint }}</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey"
variant="text"
class="rounded-xl"
@click="dialog = false"
:disabled="loading"
>
取消
</v-btn>
<v-btn
color="primary"
class="rounded-xl"
variant="tonal"
@click="checkPassword"
:loading="loading"
:disabled="!password"
>
确认
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import { getSetting, setSetting } from "@/utils/settings";
import axios from "@/axios/axios";
export default {
name: "NamespaceAccess",
data() {
return {
dialog: false,
password: "",
error: "",
loading: false,
showPassword: false,
isReadOnly: false,
accessType: "PUBLIC", // 访
passwordHint: null, //
};
},
computed: {
shouldShowAccess() {
const provider = getSetting("server.provider");
return provider === "kv-server" || provider === "classworkscloud";
}
},
async created() {
if (this.shouldShowAccess) {
await this.checkAccess();
}
},
methods: {
async checkAccess() {
if (!this.shouldShowAccess) {
return;
}
try {
// 访
const response = await axios.get(
`${getSetting("server.domain")}/${getSetting("device.uuid")}/_info`
);
if (
response.data &&
response.data.accessType &&
["PRIVATE", "PROTECTED", "PUBLIC"].includes(response.data.accessType)
) {
this.accessType = response.data.accessType;
} else {
return;
}
//
if (this.accessType === "PRIVATE" || this.accessType === "PROTECTED") {
const storedPassword = getSetting("namespace.password");
if (storedPassword) {
await this.verifyPassword(storedPassword);
} else if (this.accessType === "PRIVATE") {
//
this.openPasswordDialog();
} else {
//
this.setReadOnly(true);
}
}
const passwordHintresponse = await axios.get(
`${getSetting("server.domain")}/${getSetting("device.uuid")}/_hint`
);
if (passwordHintresponse.data && passwordHintresponse.data.passwordHint) {
this.passwordHint = passwordHintresponse.data.passwordHint || null;
}
} catch (error) {
// 403
if (error.response && error.response.status === 403) {
this.accessType = "PRIVATE";
this.setReadOnly(true);
this.openPasswordDialog();
} else {
console.error("访问检查失败:", error);
this.$message?.error("访问检查失败");
}
}
},
async verifyPassword(password) {
try {
const uuid = getSetting("device.uuid");
const response = await axios.post(
`${getSetting("server.domain")}/${uuid}/_checkpassword`,
{ password }
);
if (response.status != 200) {
throw new Error(response.data?.error?.message || "密码错误");
}
//
setSetting("namespace.password", password);
this.setReadOnly(false);
this.dialog = false;
this.$message?.success("验证成功", "已启用编辑功能");
} catch (error) {
//
setSetting("namespace.password", "");
this.setReadOnly(true);
throw error;
}
},
openPasswordDialog() {
this.password = "";
this.error = "";
this.dialog = true;
},
async checkPassword() {
if (!this.password) {
this.error = "请输入密码";
return;
}
this.loading = true;
this.error = "";
try {
await this.verifyPassword(this.password);
} catch (error) {
console.error("密码验证失败:", error);
this.error = "密码验证失败";
} finally {
this.loading = false;
}
},
setReadOnly(value) {
this.isReadOnly = value;
setSetting("namespace.accessType", value ? "readonly" : "readwrite");
},
},
};
</script>
<style scoped>
.namespace-access {
display: inline-flex;
align-items: center;
gap: 8px;
}
.password-hint {
max-width: 100%;
word-wrap: break-word;
}
</style>

View File

@ -0,0 +1,181 @@
<template>
<v-card class="my-4" :loading="loading" :disabled="!hasNamespaceInfo">
<template #loader>
<v-progress-linear v-if="loading" indeterminate color="primary" />
</template>
<v-card-title>
<v-icon class="me-2"> mdi-cloud-check </v-icon>
设备信息
</v-card-title>
<v-card-text v-if="hasNamespaceInfo">
<!-- 用户信息与头像 -->
<div v-if="namespaceInfo.account" class="d-flex align-center mb-4">
<v-card
border hover
class="w-100"
variant="tonal"
:prepend-avatar="namespaceInfo.account.avatarUrl"
:title="namespaceInfo.account.name || '未命名用户'"
:subtitle="
'此设备由贵校管理 管理员账号 ID: ' + namespaceInfo.account.id
"
>
<v-card-text
>此设备由贵校或贵单位管理该管理员系此空间所有者如有疑问请咨询他对于恶意绑定滥用行为请反馈</v-card-text
>
</v-card>
</div>
<!-- 设备信息卡片 -->
<v-card v-if="namespaceInfo.device" variant="tonal" class="mb-4" border hover>
<v-card-title class="pb-1"> 设备信息 </v-card-title>
<v-card-text>
<div class="d-flex flex-column gap-1">
<div class="d-flex align-center">
<v-icon size="small" class="me-2"> mdi-tag </v-icon>
<span class="font-weight-medium me-2">设备名称:</span>
<span>{{ namespaceInfo.device.name || "未命名设备" }}</span>
</div>
<div class="d-flex align-center">
<v-icon size="small" class="me-2"> mdi-identifier </v-icon>
<span class="font-weight-medium me-2">设备 ID:</span>
<span>{{ namespaceInfo.device.id }}</span>
</div>
<div class="d-flex align-center">
<v-icon size="small" class="me-2"> mdi-uuid </v-icon>
<span class="font-weight-medium me-2">UUID:</span>
<span class="text-truncate">{{
namespaceInfo.device.uuid || "未知"
}}</span>
</div>
<div class="d-flex align-center">
<v-icon size="small" class="me-2"> mdi-calendar </v-icon>
<span class="font-weight-medium me-2">创建时间:</span>
<span>{{ formatDate(namespaceInfo.device.createdAt) }}</span>
</div>
<div
v-if="namespaceInfo.device.updatedAt"
class="d-flex align-center"
>
<v-icon size="small" class="me-2"> mdi-calendar-clock </v-icon>
<span class="font-weight-medium me-2">更新时间:</span>
<span>{{ formatDate(namespaceInfo.device.updatedAt) }}</span>
</div>
</div>
</v-card-text> </v-card
><v-card title="Classworks KV" subtitle="云原生键值数据库" border hover
><v-card-text
>Classworks KV
是厚浪云推出的云原生键值数据库其是一个开放的云应用平台为各种应用提供存储服务此设备正在使用其服务如果您希望管理设备信息请前往
Classworks KV
的网站如果您在服务推出前就在使用 Classworks您的数据已被自动迁移
<br/><br/>Classworks KV 的全域管理员是 <a href="https://wuyuan.dev" target="_blank">孙悟元</a></v-card-text
><v-card-actions
><v-btn
href="https://kv.wuyuan.dev"
class="text-none"
append-icon="mdi-open-in-new"
target="_blank"
>前往 Classworks KV</v-btn
></v-card-actions
></v-card
>
</v-card-text>
<v-card-text v-else>
<v-alert type="info" variant="tonal">
<v-alert-title>未获取到设备信息</v-alert-title>
<p>您尚未完成云端存储授权或连接失败请点击下方按钮进行初始化</p>
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="primary"
variant="outlined"
:loading="loading"
@click="reloadInfo"
>
刷新设备信息
</v-btn>
<v-btn color="primary" @click="reinitializeCloudStorage">
重新初始化云端存储
</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
import { kvServerProvider } from "@/utils/providers/kvServerProvider";
export default {
name: "CloudNamespaceInfoCard",
props: {
visible: {
type: Boolean,
default: true,
},
},
data() {
return {
namespaceInfo: {},
loading: false,
hasNamespaceInfo: false,
};
},
watch: {
visible(newVal) {
if (newVal === true) {
this.fetchNamespaceInfo();
}
},
},
mounted() {
if (this.visible) {
this.fetchNamespaceInfo();
}
},
methods: {
formatDate(dateString) {
if (!dateString) return "未知";
try {
const date = new Date(dateString);
return date.toLocaleString("zh-CN");
} catch {
return dateString;
}
},
async fetchNamespaceInfo() {
this.loading = true;
try {
const response = await kvServerProvider.loadNamespaceInfo();
this.namespaceInfo = response;
this.hasNamespaceInfo = true;
this.loading = false;
} catch (e) {
console.error("获取命名空间信息失败:", e);
this.hasNamespaceInfo = false;
this.namespaceInfo = {};
} finally {
this.loading = false;
}
},
async reloadInfo() {
await this.fetchNamespaceInfo();
},
reinitializeCloudStorage() {
// KvInitialize
try {
window.dispatchEvent(new CustomEvent("kvinit:open"));
} catch (e) {
console.error("重新初始化云端存储失败:", e);
}
},
},
};
</script>

View File

@ -1,729 +0,0 @@
<template>
<settings-card
v-if="shouldShowCard"
title="命名空间设置"
icon="mdi-database-lock"
:loading="loading"
>
<namespace-access ref="namespaceAccess" />
<!-- 命名空间标识符 -->
<v-card variant="tonal" class="rounded-lg mb-4">
<v-card-item>
<template #prepend>
<v-icon
icon="mdi-identifier"
size="large"
class="mr-3"
color="primary"
/>
</template>
<v-card-title>命名空间标识符</v-card-title>
<v-card-subtitle>
<div class="d-flex align-center mt-2">
<code class="text-body-1">{{ namespaceInfo.uuid }}</code>
<v-btn
icon="mdi-content-copy"
variant="text"
size="small"
class="ml-2"
@click="copyUuid"
/>
</div>
</v-card-subtitle>
</v-card-item>
</v-card>
<!-- 命名空间信息表单 -->
<v-card variant="tonal" class="rounded-lg mb-4">
<v-card-item>
<template #prepend>
<v-icon
icon="mdi-form-textbox"
size="large"
class="mr-3"
color="primary"
/>
</template>
<v-card-title>命名空间信息</v-card-title>
</v-card-item>
<v-card-text>
<v-form ref="form" @submit.prevent="saveNamespaceInfo">
<v-text-field
v-model="namespaceForm.name"
label="命名空间名称"
variant="outlined"
density="comfortable"
hide-details="auto"
class="mb-4"
:loading="loading"
:rules="[(v) => !!v || '请输入命名空间名称']"
>
<template #prepend-inner>
<v-icon icon="mdi-tag-text" />
</template>
</v-text-field>
<v-select
v-model="namespaceForm.accessType"
:items="accessTypeOptions"
label="访问权限"
variant="outlined"
density="comfortable"
hide-details="auto"
class="mb-6"
:loading="loading"
>
<template #prepend-inner>
<v-icon icon="mdi-shield-lock" />
</template>
</v-select>
<div class="d-flex justify-end">
<v-btn
color="primary"
:loading="loading"
:disabled="!isFormChanged"
@click="saveNamespaceInfo"
>
保存更改
<template #prepend>
<v-icon icon="mdi-content-save" />
</template>
</v-btn>
</div>
</v-form>
</v-card-text>
</v-card>
<!-- 访问密码设置 -->
<v-card variant="tonal" class="rounded-lg">
<v-card-item>
<template #prepend>
<v-icon icon="mdi-key" size="large" class="mr-3" color="primary" />
</template>
<v-card-title>访问密码</v-card-title>
<v-card-subtitle class="mt-2">
设置访问密码以保护数据安全可以将老师电教的名字学号等作为密码
</v-card-subtitle>
</v-card-item>
<v-card-text>
<v-form ref="passwordForm" @submit.prevent="savePassword">
<v-text-field
v-if="namespaceInfo.hasPassword"
v-model="passwordForm.oldPassword"
label="当前密码"
variant="outlined"
density="comfortable"
hide-details="auto"
class="mb-4"
:loading="passwordLoading"
:rules="[(v) => !!v || '请输入当前密码']"
>
<template #prepend-inner>
<v-icon icon="mdi-lock" />
</template>
</v-text-field><v-text-field
v-model="passwordForm.newPassword"
label="新密码"
variant="outlined"
density="comfortable"
hide-details="auto"
class="mb-4"
:loading="passwordLoading"
>
<template #prepend-inner>
<v-icon icon="mdi-lock-plus" />
</template>
</v-text-field>
<v-text-field
v-model="passwordForm.confirmPassword"
label="确认新密码"
variant="outlined"
density="comfortable"
hide-details="auto"
class="mb-4"
:loading="passwordLoading"
:rules="[
(v) =>
!passwordForm.newPassword ||
v === passwordForm.newPassword ||
'两次输入的密码不一致',
]"
>
<template #prepend-inner>
<v-icon icon="mdi-lock-check" />
</template>
</v-text-field>
<div class="d-flex justify-space-between align-center">
<div>
<v-btn
v-if="namespaceInfo.hasPassword"
color="error"
variant="tonal"
:loading="passwordLoading"
@click="confirmDeletePassword"
class="mr-2"
>
删除密码
<template #prepend>
<v-icon icon="mdi-lock-remove" />
</template>
</v-btn>
<v-btn
v-if="namespaceInfo.hasPassword"
color="primary"
variant="tonal"
:loading="hintLoading"
@click="openHintDialog"
class="mr-2"
>
设置密码提示
<template #prepend>
<v-icon icon="mdi-lightbulb-outline" />
</template>
</v-btn>
<v-btn
color="primary"
variant="tonal"
@click="modifyLocalPassword"
>
修改本地密码
<template #prepend>
<v-icon icon="mdi-key-variant" />
</template>
</v-btn>
</div>
<v-btn
color="primary"
:loading="passwordLoading"
:disabled="!isPasswordFormValid"
@click="savePassword"
>
保存密码
<template #prepend>
<v-icon icon="mdi-content-save" />
</template>
</v-btn>
</div>
</v-form>
<!--<setting-item
setting-key="namespace.password"
title="访问密码"
></setting-item>-->
</v-card-text>
</v-card>
<!-- 密码提示设置对话框 -->
<v-dialog v-model="showHintDialog" max-width="400">
<v-card>
<v-card-item>
<v-card-title>设置密码提示</v-card-title>
<v-card-subtitle class="mt-2">
设置一个提示帮助记忆密码
</v-card-subtitle>
</v-card-item>
<v-card-text>
<v-text-field
v-model="passwordHintForm.hint"
label="密码提示"
variant="outlined"
density="comfortable"
hide-details="auto"
class="mb-4"
:loading="hintLoading"
placeholder="例如:我的生日"
>
<template #prepend-inner>
<v-icon icon="mdi-lightbulb-outline" />
</template>
</v-text-field>
<div class="text-caption text-grey">
当前提示{{ namespaceInfo.passwordHint || "未设置" }}
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey-darken-1"
variant="text"
@click="showHintDialog = false"
:disabled="hintLoading"
>
取消
</v-btn>
<v-btn
color="primary"
variant="text"
:loading="hintLoading"
@click="savePasswordHint"
>
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除密码确认对话框 -->
<v-dialog v-model="showDeleteConfirm" max-width="400">
<v-card>
<v-card-item>
<v-card-title>确认删除密码</v-card-title>
<v-card-text class="mt-4">
删除密码后任何人都可以访问和修改此命名空间的数据确定要继续吗
</v-card-text>
</v-card-item>
<v-card-actions>
<v-spacer />
<v-btn
color="grey-darken-1"
variant="text"
@click="showDeleteConfirm = false"
>
取消
</v-btn>
<v-btn
color="error"
variant="text"
:loading="passwordLoading"
@click="deletePassword"
>
确认删除
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 密码验证对话框 -->
<v-dialog v-model="showVerifyDialog" max-width="400" persistent>
<v-card>
<v-card-item>
<v-card-title>验证密码</v-card-title>
<v-card-subtitle class="mt-2">
请输入当前密码以继续操作
</v-card-subtitle>
</v-card-item>
<v-card-text>
<v-text-field
v-model="verifyForm.password"
label="当前密码"
variant="outlined"
density="comfortable"
hide-details="auto"
class="mb-4"
:loading="verifyLoading"
:error="!!verifyForm.error"
:error-messages="verifyForm.error"
@keyup.enter="verifyPassword"
>
<template #prepend-inner>
<v-icon icon="mdi-lock" />
</template>
</v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey-darken-1"
variant="text"
@click="cancelVerify"
:disabled="verifyLoading"
>
取消
</v-btn>
<v-btn
color="primary"
variant="text"
:loading="verifyLoading"
:disabled="!verifyForm.password"
@click="verifyPassword"
>
确认
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="showSnackbar"
:timeout="3000"
:color="snackbarColor"
location="top"
>
{{ snackbarText }}
<template #actions>
<v-btn variant="text" @click="showSnackbar = false"> 关闭 </v-btn>
</template>
</v-snackbar>
</settings-card>
</template>
<script>
import SettingsCard from "@/components/SettingsCard.vue";
import NamespaceAccess from "@/components/NamespaceAccess.vue";
import { kvServerProvider } from "@/utils/providers/kvServerProvider";
import { getSetting } from "@/utils/settings";
import axios from "@/axios/axios";
// Helper function to get request headers
const getHeaders = () => {
const headers = { Accept: "application/json" };
const siteKey = getSetting("server.siteKey");
const password = getSetting("namespace.password");
if (siteKey) {
headers["x-site-key"] = siteKey;
}
if (password) {
headers["x-namespace-password"] = password;
}
return headers;
};
export default {
name: "NamespaceSettingsCard",
components: {
SettingsCard,
NamespaceAccess
},
data() {
return {
loading: false,
passwordLoading: false,
hintLoading: false,
verifyLoading: false,
showSnackbar: false,
showDeleteConfirm: false,
showHintDialog: false,
showVerifyDialog: false,
snackbarText: "",
snackbarColor: "success",
namespaceInfo: {
uuid: "",
name: "",
accessType: "PUBLIC",
hasPassword: false,
passwordHint: null,
},
namespaceForm: {
name: "",
accessType: "PUBLIC",
},
passwordForm: {
newPassword: "",
oldPassword: "",
confirmPassword: "",
},
passwordHintForm: {
hint: "",
},
verifyForm: {
password: "",
error: "",
action: null, // 'delete' | 'hint'
onSuccess: null,
},
originalForm: {
name: "",
accessType: "PUBLIC",
},
accessTypeOptions: [
{
title: "公开(无需密码)",
value: "PUBLIC",
icon: "mdi-lock-open",
},
{
title: "受保护(需要密码写入)",
value: "PROTECTED",
icon: "mdi-lock",
},
{
title: "私有(需要密码读写)",
value: "PRIVATE",
icon: "mdi-lock-alert",
},
],
};
},
computed: {
shouldShowCard() {
const provider = getSetting("server.provider");
return provider === "kv-server" || provider === "classworkscloud";
},
deviceUuid() {
return this.namespaceInfo.uuid;
},
isFormChanged() {
return (
this.namespaceForm.name !== this.originalForm.name ||
this.namespaceForm.accessType !== this.originalForm.accessType
);
},
isPasswordFormValid() {
if (!this.passwordForm.newPassword) {
return true; //
}
const isConfirmMatch = this.passwordForm.newPassword === this.passwordForm.confirmPassword;
if (this.namespaceInfo.hasPassword) {
return isConfirmMatch && !!this.passwordForm.oldPassword;
}
return isConfirmMatch;
},
},
async created() {
if (this.shouldShowCard) {
await this.loadNamespaceInfo();
await this.loadPasswordHint();
}
},
methods: {
async loadNamespaceInfo() {
this.loading = true;
try {
const response = await kvServerProvider.loadNamespaceInfo();
if (response.status == 200 && response.data) {
this.namespaceInfo = response.data;
this.namespaceForm.name = response.data.name;
this.namespaceForm.accessType = response.data.accessType;
this.passwordForm.passwordHint = response.data.passwordHint || "";
//
this.originalForm = { ...this.namespaceForm };
}
} catch (error) {
console.error("加载命名空间信息失败:", error);
this.showError("加载命名空间信息失败");
} finally {
this.loading = false;
}
},
async saveNamespaceInfo() {
if (!this.isFormChanged) return;
this.loading = true;
try {
const response = await kvServerProvider.updateNamespaceInfo({
name: this.namespaceForm.name,
accessType: this.namespaceForm.accessType,
});
console.log(response);
if (response.status == 200) {
this.originalForm = { ...this.namespaceForm };
this.showSuccess("命名空间信息已更新");
} else {
throw new Error(response.error.message || "保存失败");
}
} catch (error) {
console.error("保存命名空间信息失败:", error);
this.showError(error.message || "保存命名空间信息失败");
} finally {
this.loading = false;
}
},
async copyUuid() {
try {
await navigator.clipboard.writeText(this.namespaceInfo.uuid);
this.showSuccess("命名空间标识符已复制到剪贴板");
} catch (error) {
console.error("复制失败:", error);
this.showError("复制失败");
}
},
async savePassword() {
if (!this.isPasswordFormValid) return;
this.passwordLoading = true;
try {
const response = await kvServerProvider.updatePassword(
this.passwordForm.newPassword || null,
this.passwordForm.oldPassword || null
);
if (response.status === 200) {
this.namespaceInfo.hasPassword = !!this.passwordForm.newPassword;
this.passwordForm = {
newPassword: "",
oldPassword: "",
confirmPassword: "",
};
this.showSuccess("密码已更新");
this.$router.push("/");
} else {
throw new Error(response.error?.message || "保存失败");
}
} catch (error) {
console.error("保存密码失败:", error);
this.showError(error.response?.data?.message || "保存密码失败");
} finally {
this.passwordLoading = false;
}
},
async confirmDeletePassword() {
this.verifyForm = {
password: "",
error: "",
action: "delete",
onSuccess: () => {
this.showDeleteConfirm = true;
},
};
this.showVerifyDialog = true;
},
async deletePassword() {
this.passwordLoading = true;
try {
const response = await kvServerProvider.deletePassword();
if (response.status === 200) {
this.namespaceInfo.hasPassword = false;
this.namespaceInfo.passwordHint = null;
this.passwordForm = {
newPassword: "",
oldPassword: "",
confirmPassword: "",
};
this.showDeleteConfirm = false;
this.showSuccess("密码已删除");
} else {
throw new Error(response.error?.message || "删除失败");
}
} catch (error) {
console.error("删除密码失败:", error);
this.showError(error.response?.data?.message || "删除密码失败");
} finally {
this.passwordLoading = false;
}
},
async loadPasswordHint() {
try {
const serverUrl = getSetting("server.domain");
const machineId = getSetting("device.uuid");
const response = await axios.get(
`${serverUrl}/${machineId}/_hint`,
{ headers: getHeaders() }
);
if (response.data && response.data.passwordHint !== undefined) {
this.namespaceInfo.passwordHint = response.data.passwordHint;
this.passwordHintForm.hint = response.data.passwordHint || "";
}
} catch (error) {
console.error("加载密码提示失败:", error);
this.showError("加载密码提示失败");
}
},
async savePasswordHint() {
this.hintLoading = true;
try {
const serverUrl = getSetting("server.domain");
const machineId = getSetting("device.uuid");
const response = await axios.put(
`${serverUrl}/${machineId}/_hint`,
{ hint: this.passwordHintForm.hint || null },
{ headers: getHeaders() }
);
if (response.data) {
this.namespaceInfo.passwordHint = response.data.passwordHint;
this.showSuccess("密码提示已更新");
this.showHintDialog = false;
}
} catch (error) {
console.error("保存密码提示失败:", error);
this.showError(error.response?.data?.message || "保存密码提示失败");
} finally {
this.hintLoading = false;
}
},
async openHintDialog() {
this.verifyForm = {
password: "",
error: "",
action: "hint",
onSuccess: () => {
this.showHintDialog = true;
},
};
this.showVerifyDialog = true;
},
cancelVerify() {
this.showVerifyDialog = false;
this.verifyForm = {
password: "",
error: "",
action: null,
onSuccess: null,
};
},
async verifyPassword() {
if (!this.verifyForm.password) return;
this.verifyLoading = true;
this.verifyForm.error = "";
try {
const response = await axios.post(
`${getSetting("server.domain")}/${getSetting("device.uuid")}/_checkpassword`,
{ password: this.verifyForm.password },
{ headers: getHeaders() }
);
if (response.status == 200) {
//
this.showVerifyDialog = false;
if (this.verifyForm.onSuccess) {
this.verifyForm.onSuccess();
}
} else {
this.verifyForm.error = "密码错误";
}
} catch (error) {
console.error("密码验证失败:", error);
this.verifyForm.error = "密码验证失败";
} finally {
this.verifyLoading = false;
}
},
showSuccess(message) {
this.snackbarColor = "success";
this.snackbarText = message;
this.showSnackbar = true;
},
showError(message) {
this.snackbarColor = "error";
this.snackbarText = message;
this.showSnackbar = true;
},
modifyLocalPassword() {
// NamespaceAccess
const namespaceAccess = this.$refs.namespaceAccess;
if (namespaceAccess) {
namespaceAccess.openPasswordDialog();
}
},
},
};
</script>

View File

@ -1,9 +1,33 @@
<template>
<settings-card title="数据源设置" icon="mdi-database" :loading="loading">
<settings-card
title="数据源设置"
icon="mdi-database"
:loading="loading"
>
<v-form>
<setting-item setting-key="server.provider" title="数据提供者" />
<!-- 使用双向绑定来替代 setting-key -->
<v-select
v-model="serverSettings.provider"
:items="[
{ title: 'Classworks云端存储', value: 'classworkscloud' },
{ title: 'KV本地存储', value: 'kv-local' },
{ title: 'KV远程服务器', value: 'kv-server' }
]"
label="数据提供者"
variant="outlined"
density="comfortable"
item-title="title"
item-value="value"
prepend-icon="mdi-database"
class="mb-3"
/>
<v-alert v-if="isKvProvider" type="info" variant="tonal" class="my-2">
<v-alert
v-if="isKvProvider"
type="info"
variant="tonal"
class="my-2"
>
<v-alert-title>KV 存储系统</v-alert-title>
<p>KV存储系统使用本机唯一标识符(UUID)来区分不同设备的数据</p>
<p v-if="currentProvider === 'kv-server'">
@ -12,49 +36,113 @@
</p>
</v-alert>
<v-alert v-if="isClassworksCloud" type="info" color="success" variant="tonal" class="my-2">
<v-alert
v-if="isClassworksCloud"
type="info"
color="success"
variant="tonal"
class="my-2"
>
<v-alert-title>Classworks云端存储</v-alert-title>
<p>Classworks云端存储是官方提供的存储解决方案自动配置了最优的访问设置</p>
<p>使用此选项时服务器域名和网站令牌将自动配置无需手动设置</p>
</v-alert>
<v-divider class="my-2" />
<setting-item setting-key="server.domain" title="服务器域名" :disabled="isClassworksCloud" />
<v-divider class="my-2" />
<setting-item setting-key="server.classNumber" title="班号" />
<v-divider class="my-2" />
<setting-item setting-key="server.siteKey" title="网站令牌" :disabled="isClassworksCloud">
<template #description>
用于后端验证请求的安全令牌如需要请从系统管理员获取
</template>
</setting-item>
<v-alert v-if="useServer" type="info" variant="tonal" class="my-2">
<v-icon icon="mdi-information-outline" class="mr-2"></v-icon>
<span>网站令牌将作为 <code>x-site-key</code> 请求头发送给服务器用于验证请求的合法性如果您的服务器需要此验证请在上方输入有效的令牌</span>
</v-alert>
<v-divider class="my-2" />
<setting-item setting-key="device.uuid" title="设备UUID" />
<v-divider
class="my-2"
/>
<!-- For classworkscloud show kv token and namespace info card -->
<div v-if="isClassworksCloud">
<v-text-field
v-model="serverSettings.kvToken"
label="KV 授权令牌"
variant="outlined"
density="comfortable"
prepend-icon="mdi-shield-key"
class="mb-2"
hint="令牌用于云端存储授权"
persistent-hint
/>
<cloud-namespace-info-card
:visible="isClassworksCloud"
class="mt-4"
/>
</div>
<!-- For kv-server show domain + kv token -->
<div v-else-if="currentProvider === 'kv-server'">
<v-text-field
v-model="serverSettings.domain"
label="服务器域名"
variant="outlined"
density="comfortable"
prepend-icon="mdi-web"
class="mb-2"
hint="例如: https://example.com (不需要路径)"
persistent-hint
/>
<v-text-field
v-model="serverSettings.kvToken"
label="KV 授权令牌"
variant="outlined"
density="comfortable"
prepend-icon="mdi-shield-key"
class="mb-2"
hint="令牌用于服务器验证"
persistent-hint
/>
</div>
<!-- For kv-local show only class number -->
<div v-else-if="currentProvider === 'kv-local'">
<v-text-field
v-model="serverSettings.classNumber"
label="班级编号"
variant="outlined"
density="comfortable"
prepend-icon="mdi-account-group"
class="mb-2"
hint="例如: 高三八班"
persistent-hint
/>
</div>
</v-form>
</settings-card>
</template>
<script>
import SettingsCard from "@/components/SettingsCard.vue";
import SettingItem from "@/components/settings/SettingItem.vue";
import { getSetting } from "@/utils/settings";
import CloudNamespaceInfoCard from "./CloudNamespaceInfoCard.vue";
import { getSetting, setSetting, watchSettings } from "@/utils/settings";
export default {
name: "ServerSettingsCard",
components: { SettingsCard, SettingItem },
components: { SettingsCard, CloudNamespaceInfoCard },
props: {
loading: Boolean,
},
data() {
return {};
return {
unwatch: null,
//
serverSettings: {
provider: getSetting("server.provider"),
domain: getSetting("server.domain"),
classNumber: getSetting("server.classNumber"),
kvToken: getSetting("server.kvToken"),
},
// UI
settingsChangeTimeout: null
};
},
computed: {
currentProvider() {
return getSetting("server.provider");
return this.serverSettings.provider;
},
isKvProvider() {
return this.currentProvider === 'kv-local' || this.currentProvider === 'kv-server';
@ -65,6 +153,70 @@ export default {
useServer() {
return this.currentProvider === 'server' || this.currentProvider === 'kv-server' || this.currentProvider === 'classworkscloud';
}
},
watch: {
// serverSettings
serverSettings: {
handler() {
// 使
if (this.settingsChangeTimeout) {
clearTimeout(this.settingsChangeTimeout);
}
//
this.settingsChangeTimeout = setTimeout(() => {
this.saveAllSettings();
}, 100);
},
deep: true
}
},
mounted() {
//
this.loadAllSettings();
//
this.unwatch = watchSettings(() => {
//
this.loadAllSettings();
//
this.$forceUpdate && this.$forceUpdate();
});
},
beforeUnmount() {
if (this.unwatch) this.unwatch();
},
methods: {
//
loadAllSettings() {
this.serverSettings = {
provider: getSetting("server.provider"),
domain: getSetting("server.domain"),
classNumber: getSetting("server.classNumber"),
kvToken: getSetting("server.kvToken"),
};
},
//
saveAllSettings() {
Object.entries(this.serverSettings).forEach(([key, value]) => {
const settingKey = `server.${key}`;
const currentValue = getSetting(settingKey);
//
if (value !== currentValue) {
const success = setSetting(settingKey, value);
if (success) {
console.log(`设置已更新: ${settingKey} = ${value}`);
} else {
console.error(`设置失败: ${settingKey}`);
//
this.serverSettings[key] = currentValue;
}
}
});
},
}
};
</script>

74
src/pages/authorize.vue Normal file
View File

@ -0,0 +1,74 @@
<template>
<v-container class="fill-height" fluid>
<v-row align="center" justify="center">
<v-col cols="12" sm="8" md="6">
<v-card>
<v-card-title class="text-h5">
{{ status === 'processing' ? '正在处理授权...' : status === 'success' ? '授权成功' : '授权失败' }}
</v-card-title>
<v-card-text>
<v-progress-linear
v-if="status === 'processing'"
indeterminate
color="primary"
class="mb-4"
></v-progress-linear>
<p>{{ message }}</p>
</v-card-text>
<v-card-actions v-if="status !== 'processing'">
<v-spacer></v-spacer>
<v-btn color="primary" @click="goToHome">返回首页</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getSetting, setSetting } from '@/utils/settings';
const route = useRoute();
const router = useRouter();
const status = ref('processing');
const message = ref('正在验证授权信息...');
onMounted(async () => {
try {
const token = route.query.token;
if (!token) {
status.value = 'error';
message.value = '未获取到授权令牌';
return;
}
// token
setSetting('server.kvToken', token);
const uuid = getSetting('device.uuid');
if (uuid && uuid !== '00000000-0000-4000-8000-000000000000') {
// uuid
setSetting('device.uuid', '00000000-0000-4000-8000-000000000000');
message.value = '授权成功!已完成数据迁移。';
} else {
message.value = '授权成功!';
}
status.value = 'success';
router.push('/');
} catch (error) {
console.error('授权处理失败:', error);
status.value = 'error';
message.value = `授权失败: ${error.message}`;
}
});
const goToHome = () => {
router.push('/');
};
</script>

132
src/pages/debug-init.vue Normal file
View File

@ -0,0 +1,132 @@
<template>
<v-container>
<v-row>
<v-col
cols="12"
md="6"
>
<v-card>
<v-card-title>KvInitialize 调试面板</v-card-title>
<v-card-text>
<v-form>
<v-text-field
v-model="provider"
label="server.provider (kv-server/classworkscloud/other)"
/>
<v-text-field
v-model="kvToken"
label="server.kvToken (空表示未授权)"
/>
<v-text-field
v-model="uuid"
label="device.uuid"
/>
<v-text-field
v-model="authDomain"
label="server.authDomain"
/>
</v-form>
<v-divider class="my-4" />
<v-btn
color="primary"
class="me-2"
@click="applySettings"
>
应用设置
</v-btn>
<v-btn
color="secondary"
class="me-2"
@click="clearGuard"
>
清除重定向守卫
</v-btn>
<v-btn
color="error"
@click="simulateLoadError"
>
模拟命名空间加载错误
</v-btn>
<v-list two-line>
<v-list-item>
<v-list-item-content>
<v-list-item-title>当前 sessionGuard</v-list-item-title>
<v-list-item-subtitle>{{ guardRaw }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title>当前 settings</v-list-item-title>
<v-list-item-subtitle>{{ settingsDump }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
<v-col
cols="12"
md="6"
>
<v-card>
<v-card-title>KvInitialize 预览</v-card-title>
<v-card-text>
<kv-initialize />
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup>
import { ref, computed } from 'vue'
import KvInitialize from '@/components/KvInitialize.vue'
import { getSetting, setSetting } from '@/utils/settings'
import { kvServerProvider } from '@/utils/providers/kvServerProvider'
const REDIRECT_GUARD_KEY = 'kvinit.redirecting'
const provider = ref(getSetting('server.provider') || 'kv-server')
const kvToken = ref(getSetting('server.kvToken') || '')
const uuid = ref(getSetting('device.uuid') || '00000000-0000-4000-8000-000000000000')
const authDomain = ref(getSetting('server.authDomain') || 'https://cs.example.com')
const applySettings = () => {
setSetting('server.provider', provider.value)
setSetting('server.kvToken', kvToken.value)
setSetting('device.uuid', uuid.value)
setSetting('server.authDomain', authDomain.value)
// reload to let app re-evaluate
location.reload()
}
const clearGuard = () => {
try { sessionStorage.removeItem(REDIRECT_GUARD_KEY) } catch (e) { console.debug(e) }
}
const simulateLoadError = () => {
// Monkey-patch kvServerProvider.loadNamespaceInfo to throw once
kvServerProvider.loadNamespaceInfo = async () => {
throw new Error('模拟加载错误')
}
// reload to apply
location.reload()
}
const guardRaw = computed(() => {
try { return sessionStorage.getItem(REDIRECT_GUARD_KEY) } catch (e) { return String(e) }
})
const settingsDump = computed(() => {
return JSON.stringify({
provider: getSetting('server.provider'),
kvToken: getSetting('server.kvToken'),
uuid: getSetting('device.uuid'),
authDomain: getSetting('server.authDomain')
}, null, 2)
})
</script>

View File

@ -7,7 +7,7 @@
<v-spacer />
<template #append>
<namespace-access /> <v-btn
<v-btn
icon="mdi-bell"
variant="text"
:badge="unreadCount || undefined"
@ -615,7 +615,6 @@
<script>
import MessageLog from "@/components/MessageLog.vue";
import RandomPicker from "@/components/RandomPicker.vue";
import NamespaceAccess from "@/components/NamespaceAccess.vue";
import FloatingToolbar from "@/components/FloatingToolbar.vue";
import FloatingICP from "@/components/FloatingICP.vue";
import HomeworkEditDialog from "@/components/HomeworkEditDialog.vue";
@ -639,7 +638,6 @@ export default {
components: {
MessageLog,
RandomPicker,
NamespaceAccess,
FloatingToolbar,
FloatingICP,
HomeworkEditDialog,

View File

@ -107,13 +107,6 @@
<kv-database-card border class="mt-4" />
</v-tabs-window-item>
<v-tabs-window-item value="namespace">
<namespace-settings-card
border
:loading="loading.namespace"
@saved="onSettingsSaved"
/>
</v-tabs-window-item>
<v-tabs-window-item value="student">
<student-list-card border :is-mobile="isMobile" />
</v-tabs-window-item>
@ -238,7 +231,6 @@ import AboutCard from "@/components/settings/AboutCard.vue";
import "../styles/settings.scss";
import SettingsExplorer from "@/components/settings/SettingsExplorer.vue";
import SettingsLinkGenerator from "@/components/SettingsLinkGenerator.vue";
import NamespaceSettingsCard from "@/components/settings/cards/NamespaceSettingsCard.vue";
import RandomPickerCard from "@/components/settings/cards/RandomPickerCard.vue";
import HomeworkTemplateCard from "@/components/settings/cards/HomeworkTemplateCard.vue";
import SubjectManagementCard from "@/components/settings/cards/SubjectManagementCard.vue";
@ -259,7 +251,6 @@ export default {
EchoChamberCard,
SettingsExplorer,
SettingsLinkGenerator,
NamespaceSettingsCard,
RandomPickerCard,
HomeworkTemplateCard,
SubjectManagementCard,
@ -271,8 +262,6 @@ export default {
},
data() {
const provider = getSetting("server.provider");
const showNamespaceSettings =
provider === "kv-server" || provider === "classworkscloud";
const settings = {
server: {
@ -280,11 +269,6 @@ export default {
classNumber: getSetting("server.classNumber"),
provider: getSetting("server.provider"),
},
namespace: {
name: getSetting("namespace.name"),
accessType: getSetting("namespace.accessType"),
password: getSetting("namespace.password"),
},
refresh: {
auto: getSetting("refresh.auto"),
interval: getSetting("refresh.interval"),
@ -357,15 +341,6 @@ export default {
icon: "mdi-server",
value: "server",
},
...(showNamespaceSettings
? [
{
title: "命名空间",
icon: "mdi-database-lock",
value: "namespace",
},
]
: []),
{
title: "科目",
icon: "mdi-book-edit",

View File

@ -7,18 +7,19 @@ const isValidProvider = () => {
return provider === "kv-server" || provider === "classworkscloud";
};
// Helper function to get request headers with site key and namespace password
// Helper function to get request headers with kvtoken
const getHeaders = () => {
const headers = { Accept: "application/json" };
const kvToken = getSetting("server.kvToken");
const siteKey = getSetting("server.siteKey");
const namespacePassword = getSetting("namespace.password");
if (siteKey) {
// 优先使用新的kvToken
if (kvToken) {
headers["x-app-token"] = kvToken;
} else if (siteKey) {
// 向后兼容旧的siteKey
headers["x-site-key"] = siteKey;
}
if (namespacePassword) {
headers["x-namespace-password"] = namespacePassword;
}
return headers;
};
@ -33,10 +34,9 @@ export const getNamespaceInfo = async () => {
}
const serverUrl = getSetting("server.domain");
const machineId = getSetting("device.uuid");
try {
const response = await axios.get(`${serverUrl}/${machineId}/_info`, {
const response = await axios.get(`${serverUrl}/kv/_info`, {
headers: getHeaders(),
});
@ -45,35 +45,3 @@ export const getNamespaceInfo = async () => {
throw new Error(error.response?.data?.message || "获取命名空间信息失败");
}
};
/**
* Update namespace password
* @param {string} oldPassword - Current password (if exists)
* @param {string} newPassword - New password to set
* @returns {Promise<Object>} Response data
*/
export const updateNamespacePassword = async (oldPassword, newPassword) => {
if (!isValidProvider()) {
throw new Error("当前数据提供者不支持此操作");
}
const serverUrl = getSetting("server.domain");
const machineId = getSetting("device.uuid");
try {
const response = await axios.put(
`${serverUrl}/${machineId}/_infopassword`,
{
oldPassword,
newPassword,
},
{
headers: getHeaders(),
}
);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || "更新命名空间密码失败");
}
};

View File

@ -1,19 +1,20 @@
import axios from "@/axios/axios";
import { formatResponse, formatError } from "../dataProvider";
import { getSetting, setSetting } from "../settings";
import { getSetting } from "../settings";
// Helper function to get request headers with site key and password if available
// Helper function to get request headers with kvtoken
const getHeaders = () => {
const headers = { Accept: "application/json" };
const kvToken = getSetting("server.kvToken");
const siteKey = getSetting("server.siteKey");
const password = getSetting("namespace.password");
if (siteKey) {
// 优先使用新的kvToken
if (kvToken) {
headers["x-app-token"] = kvToken;
} else if (siteKey) {
// 向后兼容旧的siteKey
headers["x-site-key"] = siteKey;
}
if (password) {
headers["x-namespace-password"] = password;
}
return headers;
};
@ -21,30 +22,18 @@ const getHeaders = () => {
export const kvServerProvider = {
async loadNamespaceInfo() {
try {
// 使用 Classworks Cloud 或者用户配置的服务器域名
const provider = getSetting("server.provider");
const serverUrl = getSetting("server.domain");
const machineId = getSetting("device.uuid");
const res = await axios.get(`${serverUrl}/${machineId}/_info`, {
const res = await axios.get(`${serverUrl}/kv/_info`, {
headers: getHeaders(),
});
const { name, accessType } = res.data;
// 如果name为null使用班级号作为名称并更新
if (name === null) {
const classNumber = getSetting("server.classNumber");
await this.updateNamespaceInfo({ name: classNumber });
// 重新加载命名空间信息
return await this.loadNamespaceInfo();
}
// 更新本地访问权限设置
if (accessType) {
setSetting("namespace.accessType", accessType);
}
return formatResponse(res);
// 直接返回新格式 API 数据,包含 device 和 account 信息
return formatResponse(res.data);
} catch (error) {
console.error("获取命名空间信息失败:", error);
return formatError(
error.response?.data?.message || "获取命名空间信息失败",
"NAMESPACE_ERROR"
@ -55,9 +44,8 @@ export const kvServerProvider = {
async updateNamespaceInfo(data) {
try {
const serverUrl = getSetting("server.domain");
const machineId = getSetting("device.uuid");
const res = await axios.put(`${serverUrl}/${machineId}/_info`, data, {
const res = await axios.put(`${serverUrl}/kv/_info`, data, {
headers: getHeaders(),
});
@ -70,61 +58,11 @@ export const kvServerProvider = {
}
},
async updatePassword(newPassword, oldPassword, passwordHint = null) {
try {
const serverUrl = getSetting("server.domain");
const machineId = getSetting("device.uuid");
const res = await axios.post(
`${serverUrl}/${machineId}/_password`,
{
password: newPassword,
oldPassword,
passwordHint,
},
{
headers: getHeaders(),
}
);
if (res.status === 200) {
// 更新本地存储的密码
setSetting("namespace.password", newPassword || "");
}
return res;
} catch (error) {
return formatError(
error.response?.data?.message || "更新密码失败",
"PASSWORD_ERROR"
);
}
},
async deletePassword() {
try {
const serverUrl = getSetting("server.domain");
const machineId = getSetting("device.uuid");
const res = await axios.delete(`${serverUrl}/${machineId}/_password`, {
headers: getHeaders(),
});
setSetting("namespace.password", "");
return res;
} catch (error) {
return formatError(
error.response?.data?.message || "删除密码失败",
"PASSWORD_ERROR"
);
}
},
async loadData(key) {
try {
const serverUrl = getSetting("server.domain");
const machineId = getSetting("device.uuid");
const res = await axios.get(`${serverUrl}/${machineId}/${key}`, {
const res = await axios.get(`${serverUrl}/kv/${key}`, {
headers: getHeaders(),
});
@ -144,8 +82,7 @@ export const kvServerProvider = {
async saveData(key, data) {
try {
const serverUrl = getSetting("server.domain");
const machineId = getSetting("device.uuid");
await axios.post(`${serverUrl}/${machineId}/${key}`, data, {
await axios.post(`${serverUrl}/kv/${key}`, data, {
headers: getHeaders(),
});
return formatResponse(true);
@ -182,7 +119,6 @@ export const kvServerProvider = {
async loadKeys(options = {}) {
try {
const serverUrl = getSetting("server.domain");
const machineId = getSetting("device.uuid");
// 设置默认参数
const {
@ -200,7 +136,7 @@ export const kvServerProvider = {
skip: skip.toString()
});
const res = await axios.get(`${serverUrl}/${machineId}/_keys?${params}`, {
const res = await axios.get(`${serverUrl}/kv/_keys?${params}`, {
headers: getHeaders(),
});

View File

@ -1,4 +1,3 @@
import { v4 as uuidv4 } from "uuid";
// 请求通知权限
async function requestNotificationPermission() {
if (Notification && Notification.requestPermission) {
@ -66,17 +65,11 @@ if (typeof window !== "undefined") {
// 存储所有设置的localStorage键名
const SETTINGS_STORAGE_KEY = "Classworks_settings";
/**
* 生成UUID v4
* @returns {string} 生成的UUID字符串
*/
function generateUUID() {
return uuidv4();
}
// 新增: Classworks云端存储的默认设置
const classworksCloudDefaults = {
"server.domain": "https://kv.wuyuan.dev",
//"server.domain": "http://localhost:3030",
"server.siteKey": "",
};
@ -88,26 +81,11 @@ const settingsDefinitions = {
// 设备标识
"device.uuid": {
type: "string",
default: generateUUID(),
default: '00000000-0000-4000-8000-000000000000',
description: "设备唯一标识符",
icon: "mdi-identifier",
},
// 命名空间设置
"namespace.password": {
type: "string",
default: "",
description: "命名空间访问密码",
icon: "mdi-key",
},
"namespace.accessType": {
type: "string",
default: "readwrite",
description: "访问权限类型",
icon: "mdi-shield-lock",
validate: (value) => ["readonly", "readwrite"].includes(value),
},
// 存储设置
"storage.persistOnLoad": {
type: "boolean",
@ -213,9 +191,35 @@ const settingsDefinitions = {
icon: "mdi-key-chain",
// 用于后端验证请求的令牌,将作为请求头 x-site-key 发送
},
"server.kvToken": {
type: "string",
default: "",
description: "KV授权令牌",
icon: "mdi-shield-key",
// 用于KV服务器认证的令牌将作为请求头 x-app-token 发送
},
"server.authDomain": {
type: "string",
default: "https://kv.houlang.cloud",
description: "授权服务器域名",
icon: "mdi-shield-account",
validate: (value) => {
// 如果值为空,直接通过
if (!value) return true;
// 验证URL格式
try {
new URL(value);
return true;
} catch (e) {
console.error("授权域名格式无效:", e);
return false;
}
},
// 用于CSKV授权跳转的服务器域名
},
"server.provider": {
type: "string",
default: "kv-local",
default: "classworkscloud",
validate: (value) =>
["kv-local", "kv-server", "classworkscloud"].includes(value),
description: "数据提供者",