feat: 重构链接生成器,合并预配置认证与设置分享功能,优化界面与安全性

This commit is contained in:
SunWuyuan
2025-11-02 15:48:39 +08:00
parent 7f166ffddc
commit 1999b09e59
4 changed files with 669 additions and 157 deletions
+506 -141
View File
@@ -1,142 +1,356 @@
<template>
<v-card border class="settings-link-generator mb-4">
<v-card-title class="text-h6">
<v-icon start icon="mdi-link-variant" class="mr-2" />
设置分享
</v-card-title>
<div>
<!-- 统一链接生成器卡片 -->
<v-card border class="unified-link-generator">
<v-card-title class="text-h6">
<v-icon start icon="mdi-link-variant" class="mr-2" />
统一链接生成器
</v-card-title>
<v-card-text>
<!-- 快速选择按钮 -->
<div class="d-flex mb-3 gap-2 flex-wrap">
<v-btn
size="small"
variant="tonal"
color="primary"
prepend-icon="mdi-select-all"
@click="selectAll"
>
全选
</v-btn>
<v-btn
size="small"
variant="tonal"
color="primary"
prepend-icon="mdi-server-network"
@click="selectDataSourceSettings"
>
数据源设置
</v-btn>
<v-btn
size="small"
variant="tonal"
color="primary"
prepend-icon="mdi-compare"
@click="selectChangedSettings"
>
已变更设置
</v-btn>
<v-card-text>
<div class="text-body-2 text-medium-emphasis mb-4">
生成包含预配置认证信息和设置的统一链接。可以同时预配置设备认证和应用设置。
</div>
<v-btn
size="small"
variant="tonal"
color="error"
prepend-icon="mdi-select-remove"
@click="resetSelection"
>
取消选择
</v-btn>
</div>
<!-- 预配置认证信息部分 -->
<v-card variant="tonal" class="mb-4">
<v-card-title class="text-subtitle-1">
<v-icon start>mdi-account-key</v-icon>
预配置认证信息
</v-card-title>
<!-- 选择摘要和链接 -->
<div class="d-flex align-center mt-3 mb-3 flex-wrap gap-2">
<v-chip color="primary" class="mr-2">
已选 {{ selectedItems.length }} 项设置
</v-chip>
<template v-if="selectedItems.length > 0">
<v-chip
v-for="item in selectedItems"
:key="item"
size="small"
class="mr-1"
variant="text"
>
{{ getSettingDescription(item) }}
</v-chip>
</template>
</div>
<v-text-field
v-model="generatedLink"
label="生成的链接"
readonly
variant="outlined"
class="mb-2"
:append-inner-icon="linkCopied ? 'mdi-check' : 'mdi-content-copy'"
@click:append-inner="copyLink"
/>
<!-- 设置列表折叠面板 -->
<v-expansion-panels variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title> 显示设置列表详情 </v-expansion-panel-title>
<v-expansion-panel-text>
<v-data-table
:items-per-page="settingItems.length"
:headers="headers"
:items="filteredItems"
item-value="key"
v-model="selectedItems"
show-select
density="compact"
class="rounded setting-table"
@update:selected="handleSelectionChange"
:sort-by="[{ key: 'isChanged', order: 'desc' }]"
>
<template v-slot:top>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="search"
label="搜索设置"
prepend-inner-icon="mdi-magnify"
single-line
hide-details
class="mb-4"
></v-text-field>
</template>
v-model="preconfigForm.namespace"
label="命名空间"
variant="outlined"
prepend-inner-icon="mdi-identifier"
placeholder="例如: classroom-001"
hint="设备的命名空间标识符"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="preconfigForm.authCode"
label="认证码"
variant="outlined"
prepend-inner-icon="mdi-lock-outline"
placeholder="设备认证码(可选)"
hint="留空则需要用户手动输入"
persistent-hint
/>
</v-col>
</v-row>
<template #[`item.description`]="{ item }">
<div class="d-flex align-center">
<v-icon size="small" :icon="item.icon" class="mr-2"></v-icon>
{{ item.description }}
</div>
</template>
<template #[`item.value`]="{ item }">
<span v-if="typeof item.value === 'boolean'">
{{ item.value ? "是" : "否" }}
</span>
<span v-else>{{ item.value }}</span>
</template>
<template #[`item.key`]="{ item }">
<span class="text-caption text-grey">{{ item.key }}</span>
</template>
<template #[`item.isChanged`]="{ item }">
<v-chip
size="x-small"
:color="item.isChanged ? 'warning' : 'success'"
:text="item.isChanged ? '已修改' : '默认'"
<v-row class="mt-2">
<v-col cols="12">
<v-checkbox
v-model="preconfigForm.autoExecute"
label="自动执行认证"
hint="启用后会自动尝试认证,即使没有认证码也会尝试"
persistent-hint
density="compact"
/>
</v-col>
</v-row>
<!-- 预配置信息预览 -->
<v-alert
v-if="preconfigForm.namespace"
type="info"
variant="tonal"
class="mt-3"
>
<div class="text-subtitle-2 mb-2">预配置信息:</div>
<v-chip size="small" class="mr-2 mb-1">
<v-icon start size="small">mdi-identifier</v-icon>
命名空间: {{ preconfigForm.namespace }}
</v-chip>
<v-chip
v-if="preconfigForm.authCode"
size="small"
class="mr-2 mb-1"
color="warning"
>
<v-icon start size="small">mdi-lock</v-icon>
认证码: {{ preconfigForm.authCode.length > 8 ? preconfigForm.authCode.substring(0, 8) + "..." : preconfigForm.authCode }}
</v-chip>
<v-chip v-else size="small" class="mr-2 mb-1" color="grey">
<v-icon start size="small">mdi-lock-open</v-icon>
无认证码
</v-chip>
<v-chip
size="small"
class="mr-2 mb-1"
:color="preconfigForm.autoExecute ? 'success' : 'orange'"
>
<v-icon start size="small">{{
preconfigForm.autoExecute ? "mdi-play-circle" : "mdi-hand-back-left"
}}</v-icon>
{{ preconfigForm.autoExecute ? "自动认证" : "手动认证" }}
</v-chip>
</v-alert>
</v-card-text>
</v-card>
<!-- 设置分享部分 -->
<v-card variant="tonal" class="mb-4">
<v-card-title class="text-subtitle-1">
<v-icon start>mdi-cog-transfer</v-icon>
设置分享(可选)
</v-card-title>
<v-card-text>
<div class="text-body-2 text-medium-emphasis mb-3">
选择需要包含在链接中的设置项。如果不选择任何设置,将只生成预配置认证链接。
</div>
<!-- 设置快速选择按钮 -->
<div class="d-flex mb-3 gap-2 flex-wrap">
<v-btn
size="small"
variant="tonal"
color="primary"
prepend-icon="mdi-server-network"
@click="selectDataSourceSettings"
>
数据源设置
</v-btn>
<v-btn
size="small"
variant="tonal"
color="primary"
prepend-icon="mdi-compare"
@click="selectChangedSettings"
>
已变更设置
</v-btn>
<v-btn
size="small"
variant="tonal"
color="success"
prepend-icon="mdi-select-all"
@click="selectAll"
>
全选
</v-btn>
<v-btn
size="small"
variant="tonal"
color="error"
prepend-icon="mdi-select-remove"
@click="resetSelection"
>
清除选择
</v-btn>
</div>
<!-- 选择摘要 -->
<div class="d-flex align-center mb-3 flex-wrap gap-2">
<v-chip color="primary" class="mr-2">
已选 {{ selectedItems.length }} 项设置
</v-chip>
<template v-if="selectedItems.length > 0">
<v-chip
v-for="item in selectedItems.slice(0, 3)"
:key="item"
size="small"
class="mr-1"
variant="text"
>
{{ getSettingDescription(item) }}
</v-chip>
<v-chip
v-if="selectedItems.length > 3"
size="small"
variant="text"
color="grey"
>
+{{ selectedItems.length - 3 }} 更多
</v-chip>
</template>
</v-data-table>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
</div>
<!-- 设置列表折叠面板 -->
<v-expansion-panels variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title>
<template #default="{ expanded }">
<div class="d-flex align-center">
<v-icon class="mr-2">{{ expanded ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
显示设置列表详情
</div>
</template>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-text-field
v-model="search"
label="搜索设置"
prepend-inner-icon="mdi-magnify"
single-line
hide-details
class="mb-4"
clearable
/>
<v-data-table
:items-per-page="settingItems.length"
:headers="headers"
:items="filteredItems"
item-value="key"
v-model="selectedItems"
show-select
density="compact"
class="rounded setting-table"
@update:selected="handleSelectionChange"
:sort-by="[{ key: 'isChanged', order: 'desc' }]"
>
<template #[`item.description`]="{ item }">
<div class="d-flex align-center">
<v-icon
size="small"
:icon="item.icon"
class="mr-2"
/>
{{ item.description }}
<v-chip
v-if="item.key === 'server.kvToken'"
size="x-small"
color="error"
class="ml-2"
>
敏感
</v-chip>
</div>
</template>
<template #[`item.value`]="{ item }">
<span v-if="typeof item.value === 'boolean'">
{{ item.value ? "是" : "否" }}
</span>
<span v-else-if="item.key === 'server.kvToken' && item.value">
{{ item.value.substring(0, 8) }}...
</span>
<span v-else>{{ item.value }}</span>
</template>
<template #[`item.key`]="{ item }">
<span class="text-caption text-grey">{{ item.key }}</span>
</template>
<template #[`item.isChanged`]="{ item }">
<v-chip
size="x-small"
:color="item.isChanged ? 'warning' : 'success'"
:text="item.isChanged ? '已修改' : '默认'"
density="compact"
/>
</template>
</v-data-table>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
<!-- 链接生成和操作部分 -->
<v-card variant="outlined" class="mb-4">
<v-card-title class="text-subtitle-1">
<v-icon start>mdi-link</v-icon>
生成的统一链接
</v-card-title>
<v-card-text>
<!-- 操作按钮 -->
<div class="d-flex mb-3 gap-2 flex-wrap">
<v-btn
variant="flat"
color="primary"
prepend-icon="mdi-auto-fix"
@click="generateUnifiedLink"
:disabled="!preconfigForm.namespace.trim()"
>
生成统一链接
</v-btn>
<v-btn
variant="tonal"
color="success"
prepend-icon="mdi-test-tube"
@click="openTestLink"
:disabled="!unifiedLink"
>
测试链接
</v-btn>
<v-btn
variant="tonal"
color="error"
prepend-icon="mdi-delete"
@click="clearAll"
>
清空所有
</v-btn>
</div>
<!-- 生成的链接 -->
<v-text-field
v-model="unifiedLink"
label="统一链接"
readonly
variant="outlined"
class="mb-3"
:append-inner-icon="linkCopied ? 'mdi-check' : 'mdi-content-copy'"
@click:append-inner="copyUnifiedLink"
:placeholder="preconfigForm.namespace ? '点击「生成统一链接」按钮' : '请先输入命名空间'"
/>
<!-- 链接内容预览 -->
<v-alert
v-if="unifiedLink"
type="success"
variant="tonal"
class="mb-3"
>
<div class="text-subtitle-2 mb-2">链接包含内容:</div>
<div class="d-flex flex-wrap gap-1">
<v-chip size="small" color="primary">
<v-icon start size="small">mdi-account-key</v-icon>
预配置认证
</v-chip>
<v-chip
v-if="selectedItems.length > 0"
size="small"
color="secondary"
>
<v-icon start size="small">mdi-cog</v-icon>
{{ selectedItems.length }} 项设置
</v-chip>
<v-chip v-else size="small" color="grey">
<v-icon start size="small">mdi-cog-off</v-icon>
无额外设置
</v-chip>
</div>
</v-alert>
</v-card-text>
</v-card>
<!-- 安全提醒 -->
<v-alert type="warning" variant="tonal">
<div class="text-subtitle-2 mb-2">⚠️ 安全提醒</div>
<ul class="text-body-2 pl-4">
<li>认证码和设置信息会在URL中传输,请谨慎分发</li>
<li>建议仅在受信任的网络环境中使用</li>
<li>生产环境建议使用HTTPS协议</li>
<li>数据源设置和已变更设置默认不包含敏感Token信息</li>
</ul>
</v-alert>
</v-card-text>
</v-card>
</div>
</template>
<script>
@@ -158,15 +372,22 @@ export default {
data() {
return {
// 选择的设置项键名列表
// 设置分享相关
selectedItems: [],
// 生成的链接
generatedLink: "",
// 是否已复制链接
linkCopied: false,
search: "",
// 预配置链接生成器相关
preconfigForm: {
namespace: "",
authCode: "",
autoExecute: false,
},
// 统一链接相关
unifiedLink: "",
headers: [
{ title: "", key: "data-table-select" },
{ title: "设置项", key: "description", sortable: true },
@@ -391,11 +612,14 @@ export default {
},
/**
* 选择数据源相关设置
* 选择数据源相关设置(默认排除 server.kvToken)
*/
selectDataSourceSettings() {
const dataSourceKeys = this.settingItems
.filter((item) => item.key.startsWith("server."))
.filter((item) =>
item.key.startsWith("server.") &&
item.key !== "server.kvToken" // 默认排除敏感的Token
)
.map((item) => item.key);
this.selectedItems = dataSourceKeys;
@@ -403,11 +627,14 @@ export default {
},
/**
* 选择已修改的设置
* 选择已修改的设置(默认排除 server.kvToken)
*/
selectChangedSettings() {
const changedKeys = this.settingItems
.filter((item) => item.isChanged)
.filter((item) =>
item.isChanged &&
item.key !== "server.kvToken" // 默认排除敏感的Token
)
.map((item) => item.key);
this.selectedItems = changedKeys;
@@ -443,16 +670,154 @@ export default {
const setting = this.settingItems.find((item) => item.key === key);
return setting ? setting.description : key;
},
// ===== 统一链接生成器方法 =====
/**
* 生成包含预配置信息和设置的统一链接
*/
generateUnifiedLink() {
if (!this.preconfigForm.namespace.trim()) {
return;
}
try {
const baseUrl = `${window.location.protocol}//${window.location.host}/`;
const params = new URLSearchParams();
// 添加预配置参数
params.append("namespace", this.preconfigForm.namespace.trim());
if (this.preconfigForm.authCode.trim()) {
params.append("authCode", this.preconfigForm.authCode.trim());
}
if (this.preconfigForm.autoExecute) {
params.append("autoExecute", "true");
}
// 添加设置配置(如果有选择的设置)
if (this.selectedItems.length > 0) {
const allSettings = exportSettingsAsKeyValue();
const configObj = {};
for (const key of this.selectedItems) {
configObj[key] = allSettings[key];
}
// 转换为JSON并进行base64编码
const jsonString = JSON.stringify(configObj);
const utf8Encoder = new TextEncoder();
const utf8Bytes = utf8Encoder.encode(jsonString);
const base64String = btoa(
Array.from(utf8Bytes)
.map((byte) => String.fromCharCode(byte))
.join("")
);
params.append("config", base64String);
}
// 生成完整URL
this.unifiedLink = `${baseUrl}?${params.toString()}`;
this.linkCopied = false;
console.log("生成统一链接:", this.unifiedLink);
console.log("包含预配置:", !!this.preconfigForm.namespace);
console.log("包含设置数量:", this.selectedItems.length);
} catch (error) {
console.error("生成统一链接失败:", error);
this.unifiedLink = "链接生成失败,请重试";
}
},
/**
* 复制统一链接到剪贴板
*/
async copyUnifiedLink() {
if (!this.unifiedLink) {
this.generateUnifiedLink();
}
if (!this.unifiedLink || this.unifiedLink.includes("失败")) {
return;
}
try {
await navigator.clipboard.writeText(this.unifiedLink);
this.linkCopied = true;
// 3秒后重置复制状态
setTimeout(() => {
this.linkCopied = false;
}, 3000);
} catch (error) {
console.error("复制统一链接失败:", error);
}
},
/**
* 在新窗口中测试统一链接
*/
openTestLink() {
if (this.unifiedLink && !this.unifiedLink.includes("失败")) {
window.open(this.unifiedLink, "_blank");
}
},
/**
* 清空所有数据
*/
clearAll() {
this.preconfigForm = {
namespace: "",
authCode: "",
autoExecute: false,
};
this.selectedItems = [];
this.unifiedLink = "";
this.generatedLink = "";
this.linkCopied = false;
},
},
watch: {
// 监听选择变化,自动生成链接
// 监听选择变化,自动生成统一链接
selectedItems: {
handler() {
this.autoGenerateLink();
if (this.preconfigForm.namespace.trim()) {
this.generateUnifiedLink();
}
},
deep: true,
},
// 监听预配置表单变化,自动生成统一链接
"preconfigForm.namespace": {
handler() {
if (this.preconfigForm.namespace.trim()) {
this.generateUnifiedLink();
} else {
this.unifiedLink = "";
}
},
},
"preconfigForm.authCode": {
handler() {
if (this.preconfigForm.namespace.trim()) {
this.generateUnifiedLink();
}
},
},
"preconfigForm.autoExecute": {
handler() {
if (this.preconfigForm.namespace.trim()) {
this.generateUnifiedLink();
}
},
},
},
};
</script>
+1 -1
View File
@@ -376,7 +376,7 @@ export default {
value: "student",
},
{
title: "分享设置",
title: "预配链接",
icon: "mdi-share",
value: "share",
},
+3 -15
View File
@@ -232,23 +232,11 @@ export default {
// 迁移失败不影响URL生成,继续执行
}
}
// 获取认证token
const authtoken = getSetting("server.kvToken");
// 构建云端访问URL
let url = `${serverUrl}/${machineId}/${key}`;
let url = `${serverUrl}/kv/${key}?token=${authtoken}`;
// 根据网站验证情况添加token参数
const namespaceInfo = await kvServerProvider.loadNamespaceInfo();
if (namespaceInfo && namespaceInfo.success !== false) {
const { accessType } = namespaceInfo;
// 如果是私有访问,添加token参数
if (accessType === 'private' && siteKey) {
const urlObj = new URL(url);
urlObj.searchParams.set('token', siteKey);
url = urlObj.toString();
}
// 公开或受保护访问不需要token参数
}
return {
success: true,