mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2025-10-22 18:33:10 +00:00
Compare commits
4 Commits
8a9e000788
...
0dceb0c278
Author | SHA1 | Date | |
---|---|---|---|
![]() |
0dceb0c278 | ||
![]() |
779ca2b278 | ||
![]() |
3e3eba4759 | ||
![]() |
68d6582ae0 |
65
index.html
65
index.html
@ -9,9 +9,74 @@
|
||||
<link rel="apple-touch-icon" href="/image/apple-touch-icon.png" sizes="180x180" />
|
||||
<link rel="mask-icon" href="/image/mask-icon.svg" color="#212121" />
|
||||
<meta name="theme-color" content="#212121" />
|
||||
<style>
|
||||
/* Material 3 风格:纯 CSS 加载覆盖层 */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
/* 作为主色的近似值,后续由应用接管主题 */
|
||||
--md3-primary: #6750A4; /* light primary */
|
||||
--md3-primary-dark: #D0BCFF; /* dark primary */
|
||||
--loader-bg: #ffffff;
|
||||
--loader-fg: var(--md3-primary);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--loader-bg: #121212;
|
||||
--loader-fg: var(--md3-primary-dark);
|
||||
}
|
||||
}
|
||||
#app-loader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483647; /* 确保在最上层 */
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--loader-bg);
|
||||
transition: opacity .2s ease;
|
||||
}
|
||||
#app-loader .md3-loader {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
color: var(--loader-fg);
|
||||
}
|
||||
/* 圆形不确定进度条(近似 M3) */
|
||||
#app-loader .spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
/* 通过 conic-gradient 形成 90° 弧,并旋转实现不确定动画 */
|
||||
background:
|
||||
conic-gradient(from 0deg, currentColor 0 90deg, transparent 90deg 360deg);
|
||||
/* 用 mask 形成环形厚度(4px) */
|
||||
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 4px));
|
||||
mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 4px));
|
||||
animation: md3-spin 1s linear infinite;
|
||||
}
|
||||
@keyframes md3-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
#app-loader .label {
|
||||
font: 500 14px/1.2 system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
|
||||
color: var(--loader-fg);
|
||||
letter-spacing: .2px;
|
||||
opacity: .85;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
/* 当被移除或隐藏时可渐隐(由应用控制) */
|
||||
body.app-loaded #app-loader { opacity: 0; pointer-events: none; }
|
||||
</style>
|
||||
<script defer src="https://umami.wuyuan.dev/script.js" data-website-id="e3f8ed7a-4db4-4081-aaf4-45396b1f479c"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 应用加载前显示的覆盖层:纯 CSS,无脚本依赖 -->
|
||||
<div id="app-loader" aria-live="polite" aria-busy="true">
|
||||
<div class="md3-loader">
|
||||
<div class="spinner" role="progressbar" aria-label="正在加载"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener" style="display: none;">浙ICP备2024068645号-4</a>
|
||||
|
61
src/App.vue
61
src/App.vue
@ -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);
|
||||
}
|
||||
}
|
||||
// Clarity 标识(保留在 App 层)
|
||||
Clarity.identify(
|
||||
getSetting("device.uuid"),
|
||||
getSetting("server.domain"),
|
||||
getSetting("server.provider"),
|
||||
getSetting("server.classNumber")
|
||||
);
|
||||
});
|
||||
|
||||
// 检查存储提供者类型,如果是已废弃的类型则重定向
|
||||
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();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
<style>
|
||||
.md3-enter-active,
|
||||
|
195
src/components/KvInitialize.vue
Normal file
195
src/components/KvInitialize.vue
Normal 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 = "d158067f53627d2b98babe8bffd2fd7d";
|
||||
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>
|
@ -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>
|
181
src/components/settings/cards/CloudNamespaceInfoCard.vue
Normal file
181
src/components/settings/cards/CloudNamespaceInfoCard.vue
Normal 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>
|
@ -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>
|
||||
|
18
src/main.js
18
src/main.js
@ -28,3 +28,21 @@ app.use(messageService);
|
||||
app.component('GlobalMessage', GlobalMessage)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// 移除首屏 CSS 加载覆盖层(在 Vue 挂载完成后)
|
||||
try {
|
||||
const removeLoader = () => {
|
||||
document.body.classList.add('app-loaded');
|
||||
const el = document.getElementById('app-loader');
|
||||
if (!el) return;
|
||||
// 与 CSS 过渡对齐,稍等再移除节点,避免闪烁
|
||||
setTimeout(() => el.remove(), 220);
|
||||
};
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
removeLoader();
|
||||
} else {
|
||||
window.addEventListener('DOMContentLoaded', removeLoader, { once: true });
|
||||
}
|
||||
} catch {
|
||||
// 安全失败:即便移除失败也不影响应用
|
||||
}
|
||||
|
74
src/pages/authorize.vue
Normal file
74
src/pages/authorize.vue
Normal 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
132
src/pages/debug-init.vue
Normal 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>
|
@ -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,
|
||||
|
@ -104,16 +104,9 @@
|
||||
@saved="onSettingsSaved"
|
||||
/>
|
||||
<data-provider-settings-card border class="mt-4" />
|
||||
<kv-database-card border class="mt-4" />
|
||||
<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",
|
||||
|
@ -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(),
|
||||
});
|
||||
|
||||
@ -44,36 +44,4 @@ export const getNamespaceInfo = async () => {
|
||||
} catch (error) {
|
||||
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 || "更新命名空间密码失败");
|
||||
}
|
||||
};
|
@ -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);
|
||||
@ -166,7 +103,7 @@ export const kvServerProvider = {
|
||||
* @param {number} options.limit - 每页返回的记录数,默认为 100
|
||||
* @param {number} options.skip - 跳过的记录数,默认为 0
|
||||
* @returns {Promise<Object>} 包含键名列表和分页信息的响应对象
|
||||
*
|
||||
*
|
||||
* 返回值示例:
|
||||
* {
|
||||
* keys: ["key1", "key2", "key3"],
|
||||
@ -182,8 +119,7 @@ export const kvServerProvider = {
|
||||
async loadKeys(options = {}) {
|
||||
try {
|
||||
const serverUrl = getSetting("server.domain");
|
||||
const machineId = getSetting("device.uuid");
|
||||
|
||||
|
||||
// 设置默认参数
|
||||
const {
|
||||
sortBy = "key",
|
||||
@ -191,7 +127,7 @@ export const kvServerProvider = {
|
||||
limit = 100,
|
||||
skip = 0
|
||||
} = options;
|
||||
|
||||
|
||||
// 构建查询参数
|
||||
const params = new URLSearchParams({
|
||||
sortBy,
|
||||
@ -199,11 +135,11 @@ export const kvServerProvider = {
|
||||
limit: limit.toString(),
|
||||
skip: skip.toString()
|
||||
});
|
||||
|
||||
const res = await axios.get(`${serverUrl}/${machineId}/_keys?${params}`, {
|
||||
|
||||
const res = await axios.get(`${serverUrl}/kv/_keys?${params}`, {
|
||||
headers: getHeaders(),
|
||||
});
|
||||
|
||||
|
||||
return formatResponse(res.data);
|
||||
} catch (error) {
|
||||
if (error.response?.status === 404) {
|
||||
@ -222,4 +158,4 @@ export const kvServerProvider = {
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -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",
|
||||
@ -219,6 +197,32 @@ 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: "classworkscloud",
|
||||
|
Loading…
x
Reference in New Issue
Block a user