适配ClassworksKV

This commit is contained in:
SunWuyuan
2025-10-06 14:30:32 +08:00
parent 6cf67d17c6
commit 68d6582ae0
13 changed files with 835 additions and 1207 deletions
+195
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>
-223
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>
@@ -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>
@@ -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>
@@ -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>