1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2025-07-02 00:59:23 +00:00

Add js-base64 library for Base64 encoding/decoding and enhance password management features. Update axios.js to use Base64 for encoding site key and namespace password. Implement password hint functionality in NamespaceAccess and NamespaceSettingsCard components, including dialogs for setting and verifying password hints. Refactor kvServerProvider to support password hint updates during password management operations.

This commit is contained in:
SunWuyuan 2025-05-18 14:33:39 +08:00
parent c42c878ac8
commit 31cff8a867
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
8 changed files with 8064 additions and 76 deletions

7711
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@
"@microsoft/clarity": "^1.0.0",
"axios": "^1.8.4",
"idb": "^8.0.2",
"js-base64": "^3.7.7",
"js-yaml": "^4.1.0",
"pinyin-pro": "^3.26.0",
"ratelimit-header-parser": "^0.1.0",

8
pnpm-lock.yaml generated
View File

@ -20,6 +20,9 @@ importers:
idb:
specifier: ^8.0.2
version: 8.0.2
js-base64:
specifier: ^3.7.7
version: 3.7.7
js-yaml:
specifier: ^4.1.0
version: 4.1.0
@ -2305,6 +2308,9 @@ packages:
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
hasBin: true
js-base64@3.7.7:
resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -5887,6 +5893,8 @@ snapshots:
jiti@2.4.2: {}
js-base64@3.7.7: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}

View File

@ -2,6 +2,8 @@ import axios from "axios";
import { getSetting } from "@/utils/settings";
import { parseRateLimit } from "ratelimit-header-parser";
import RateLimitModal from "@/components/RateLimitModal.vue";
import { Base64 } from "js-base64";
// 基本配置
const axiosInstance = axios.create({
// 可以在这里添加基础配置,例如超时时间等
@ -14,13 +16,14 @@ axiosInstance.interceptors.request.use(
// 确保每次请求时都获取最新的 siteKey
const siteKey = getSetting("server.siteKey");
if (siteKey) {
requestConfig.headers["x-site-key"] = siteKey;
requestConfig.headers["x-site-key"] = Base64.encode(siteKey);
}
// 自动添加命名空间密码
const namespacePassword = getSetting("namespace.password");
if (namespacePassword) {
requestConfig.headers["x-namespace-password"] = namespacePassword;
requestConfig.headers["x-namespace-password"] =
Base64.encode(namespacePassword);
}
return requestConfig;

View File

@ -26,9 +26,12 @@
<v-card>
<v-card-title class="text-h6">输入访问密码</v-card-title>
<v-card-text>
<div v-if="passwordHint" class="text-body-2 mb-4">
<v-icon icon="mdi-lightbulb-outline" color="warning" class="mr-1" />
提示{{ passwordHint }}
</div>
<v-text-field
v-model="password"
:type="showPassword ? 'text' : 'password'"
label="密码"
variant="outlined"
:error="!!error"
@ -67,6 +70,7 @@
<script>
import { getSetting, setSetting } from "@/utils/settings";
import axios from "@/axios/axios";
export default {
name: "NamespaceAccess",
data() {
@ -78,6 +82,7 @@ export default {
showPassword: false,
isReadOnly: false,
accessType: "PUBLIC", // 访
passwordHint: null, //
};
},
async created() {
@ -97,6 +102,8 @@ export default {
["PRIVATE", "PROTECTED", "PUBLIC"].includes(response.data.accessType)
) {
this.accessType = response.data.accessType;
//
this.passwordHint = response.data.passwordHint || null;
} else {
//this.$router.push("/settings");
return;
@ -132,11 +139,11 @@ export default {
try {
const uuid = getSetting("device.uuid");
const response = await axios.post(
`${getSetting("server.domain")}/${uuid}/_check`,
`${getSetting("server.domain")}/${uuid}/_checkpassword`,
{ password }
);
if (!response.data || response.data.success === false) {
if (response.status != 200) {
throw new Error(response.data?.error?.message || "密码错误");
}
@ -185,3 +192,11 @@ export default {
},
};
</script>
<style scoped>
.namespace-access {
display: inline-flex;
align-items: center;
gap: 8px;
}
</style>

View File

@ -102,19 +102,32 @@
</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"
type="password"
:loading="passwordLoading"
>
<template #prepend-inner>
@ -122,14 +135,15 @@
</template>
</v-text-field>
<v-text-field
v-model="passwordForm.confirmPassword"
label="确认新密码"
variant="outlined"
density="comfortable"
hide-details="auto"
class="mb-6"
type="password"
class="mb-4"
:loading="passwordLoading"
:rules="[
(v) =>
@ -144,19 +158,33 @@
</v-text-field>
<div class="d-flex justify-space-between align-center">
<v-btn
v-if="namespaceInfo.hasPassword"
color="error"
variant="tonal"
:loading="passwordLoading"
@click="showDeleteConfirm = true"
>
删除密码
<template #prepend>
<v-icon icon="mdi-lock-remove" />
</template>
</v-btn>
<v-spacer v-else />
<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"
>
设置密码提示
<template #prepend>
<v-icon icon="mdi-lightbulb-outline" />
</template>
</v-btn>
</div>
<v-btn
color="primary"
@ -171,13 +199,63 @@
</v-btn>
</div>
</v-form>
<setting-item
<!--<setting-item
setting-key="namespace.password"
title="访问密码"
></setting-item>
></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>
@ -208,6 +286,56 @@
</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"
@ -226,18 +354,38 @@
import SettingsCard from "@/components/SettingsCard.vue";
import { kvServerProvider } from "@/utils/providers/kvServerProvider";
import { getSetting } from "@/utils/settings";
import SettingItem from "@/components/settings/SettingItem.vue";
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, SettingItem },
components: { SettingsCard },
data() {
return {
loading: false,
passwordLoading: false,
hintLoading: false,
verifyLoading: false,
showSnackbar: false,
showDeleteConfirm: false,
showHintDialog: false,
showVerifyDialog: false,
snackbarText: "",
snackbarColor: "success",
namespaceInfo: {
@ -245,6 +393,7 @@ export default {
name: "",
accessType: "PUBLIC",
hasPassword: false,
passwordHint: null,
},
namespaceForm: {
name: "",
@ -252,8 +401,18 @@ export default {
},
passwordForm: {
newPassword: "",
oldPassword: "",
confirmPassword: "",
},
passwordHintForm: {
hint: "",
},
verifyForm: {
password: "",
error: "",
action: null, // 'delete' | 'hint'
onSuccess: null,
},
originalForm: {
name: "",
accessType: "PUBLIC",
@ -292,14 +451,17 @@ export default {
if (!this.passwordForm.newPassword) {
return true; //
}
return (
this.passwordForm.newPassword === this.passwordForm.confirmPassword
);
const isConfirmMatch = this.passwordForm.newPassword === this.passwordForm.confirmPassword;
if (this.namespaceInfo.hasPassword) {
return isConfirmMatch && !!this.passwordForm.oldPassword;
}
return isConfirmMatch;
},
},
async created() {
await this.loadNamespaceInfo();
await this.loadPasswordHint();
},
methods: {
@ -311,6 +473,7 @@ export default {
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 };
}
@ -361,56 +524,164 @@ export default {
this.passwordLoading = true;
try {
const oldPassword = getSetting("namespace.password");
const response = await kvServerProvider.updatePassword(
this.passwordForm.newPassword || null, // null
oldPassword
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 {
console.log(response);
throw new Error(response.error.message || "保存失败 #1");
throw new Error(response.error?.message || "保存失败");
}
} catch (error) {
console.error("保存密码失败:", error);
this.showError(error.message || "保存密码失败");
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) {
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 || "删除失败");
throw new Error(response.error?.message || "删除失败");
}
} catch (error) {
console.error("删除密码失败:", error);
this.showError(error.message || "删除密码失败");
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;

View File

@ -649,6 +649,7 @@ import "../styles/transitions.scss"; // 添加新的样式导入
import "../styles/global.scss";
import { pinyin } from "pinyin-pro";
import { debounce, throttle } from "@/utils/debounce";
import { Base64 } from 'js-base64';
export default {
name: "Classworks 作业板",
@ -1815,9 +1816,12 @@ export default {
if (!configParam) return false;
try {
// base64JSON
// 使base64UTF-8
const decodedString = this.safeBase64Decode(configParam);
// base64
const binaryString = atob(configParam);
// Uint8Array
const bytes = Uint8Array.from(binaryString, c => c.charCodeAt(0));
// Uint8ArrayUTF-8
const decodedString = new TextDecoder().decode(bytes);
const decodedConfig = JSON.parse(decodedString);
console.log("从URL读取配置:", decodedConfig);
@ -2067,30 +2071,7 @@ export default {
// Base64UTF-8
safeBase64Decode(base64String) {
try {
// URLbase64
const normalizedString = base64String
.replace(/-/g, "+")
.replace(/_/g, "/");
//
const paddedString = normalizedString.padEnd(
normalizedString.length +
((4 - (normalizedString.length % 4 || 4)) % 4),
"="
);
// base64
const binaryString = atob(paddedString);
// Uint8Array
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// Uint8ArrayUTF-8
const decoder = new TextDecoder("utf-8");
return decoder.decode(bytes);
return Base64.decode(base64String);
} catch (e) {
console.error("Base64解码错误:", e);
throw new Error("无法解码配置数据");

View File

@ -70,7 +70,7 @@ export const kvServerProvider = {
}
},
async updatePassword(newPassword, oldPassword) {
async updatePassword(newPassword, oldPassword, passwordHint = null) {
try {
const serverUrl = getSetting("server.domain");
const machineId = getSetting("device.uuid");
@ -78,19 +78,21 @@ export const kvServerProvider = {
const res = await axios.post(
`${serverUrl}/${machineId}/_password`,
{
newPassword,
password: newPassword,
oldPassword,
passwordHint,
},
{
headers: getHeaders(),
}
);
if (res.status == 200) {
setSetting("namespace.password", newPassword);
if (res.status === 200) {
// 更新本地存储的密码
setSetting("namespace.password", newPassword || "");
}
return formatResponse(res);
return res;
} catch (error) {
return formatError(
error.response?.data?.message || "更新密码失败",
@ -107,12 +109,8 @@ export const kvServerProvider = {
const res = await axios.delete(`${serverUrl}/${machineId}/_password`, {
headers: getHeaders(),
});
if (res.status === 200) {
setSetting("namespace.password", null); // 清除本地存储的密码
}
return formatResponse(res);
setSetting("namespace.password", "");
return res;
} catch (error) {
return formatError(
error.response?.data?.message || "删除密码失败",