feat: 优化发布清单和安装结构自动识别
This commit is contained in:
@@ -11,12 +11,13 @@ import { useLayout } from "@/layout/hooks/useLayout";
|
||||
import { initRouter, getTopMenu } from "@/router/utils";
|
||||
import { bg, avatar, illustration } from "./utils/static";
|
||||
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
|
||||
import { setToken } from "@/utils/auth";
|
||||
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
|
||||
|
||||
import dayIcon from "@/assets/svg/day.svg?component";
|
||||
import darkIcon from "@/assets/svg/dark.svg?component";
|
||||
import Lock from "~icons/ri/lock-fill";
|
||||
import User from "~icons/ri/user-3-fill";
|
||||
import { useUserStoreHook } from "@/store/modules/user";
|
||||
|
||||
defineOptions({
|
||||
name: "Login"
|
||||
@@ -33,50 +34,32 @@ initStorage();
|
||||
const { dataTheme, overallStyle, dataThemeChange } = useDataThemeChange();
|
||||
dataThemeChange(overallStyle.value);
|
||||
const { title } = useNav();
|
||||
const userStore = useUserStoreHook();
|
||||
|
||||
const ruleForm = reactive({
|
||||
adminToken: localStorage.getItem("admin_token") || ""
|
||||
username: localStorage.getItem("admin_username") || "admin",
|
||||
password: ""
|
||||
});
|
||||
|
||||
async function checkAdminToken(adminToken: string) {
|
||||
const response = await fetch("/admin/auth/check", {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
"X-Admin-Token": adminToken
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "管理员令牌验证失败");
|
||||
}
|
||||
}
|
||||
|
||||
const onLogin = async (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return;
|
||||
await formEl.validate(async valid => {
|
||||
if (!valid) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const adminToken = ruleForm.adminToken.trim();
|
||||
await checkAdminToken(adminToken);
|
||||
localStorage.setItem("admin_token", adminToken);
|
||||
setToken({
|
||||
accessToken: adminToken,
|
||||
refreshToken: adminToken,
|
||||
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
avatar: "",
|
||||
username: "admin",
|
||||
nickname: "管理员",
|
||||
roles: ["admin"],
|
||||
permissions: ["*:*:*"]
|
||||
const username = ruleForm.username.trim();
|
||||
await userStore.loginByUsername({
|
||||
username,
|
||||
password: ruleForm.password
|
||||
});
|
||||
localStorage.setItem("admin_username", username);
|
||||
await initRouter();
|
||||
disabled.value = true;
|
||||
await router.push(getTopMenu(true).path);
|
||||
message("登录成功", { type: "success" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message("管理员令牌错误或服务端不可访问", { type: "error" });
|
||||
message("用户名、密码错误或服务端不可访问", { type: "error" });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
disabled.value = false;
|
||||
@@ -126,20 +109,40 @@ useEventListener(document, "keydown", ({ code }) => {
|
||||
<el-form ref="ruleFormRef" :model="ruleForm" size="large">
|
||||
<Motion :delay="100">
|
||||
<el-form-item
|
||||
prop="adminToken"
|
||||
prop="username"
|
||||
:rules="[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入管理员令牌',
|
||||
message: '请输入用户名',
|
||||
trigger: 'blur'
|
||||
}
|
||||
]"
|
||||
>
|
||||
<el-input
|
||||
v-model="ruleForm.adminToken"
|
||||
v-model="ruleForm.username"
|
||||
clearable
|
||||
placeholder="用户名"
|
||||
:prefix-icon="useRenderIcon(User)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</Motion>
|
||||
|
||||
<Motion :delay="180">
|
||||
<el-form-item
|
||||
prop="password"
|
||||
:rules="[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入密码',
|
||||
trigger: 'blur'
|
||||
}
|
||||
]"
|
||||
>
|
||||
<el-input
|
||||
v-model="ruleForm.password"
|
||||
clearable
|
||||
show-password
|
||||
placeholder="管理员令牌"
|
||||
placeholder="密码"
|
||||
:prefix-icon="useRenderIcon(Lock)"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
@@ -7,11 +7,22 @@ import {
|
||||
downloadAdminFile,
|
||||
friendlyError
|
||||
} from "@/api/simcae";
|
||||
import { removeToken } from "@/utils/auth";
|
||||
|
||||
defineOptions({
|
||||
name: "SimCAEConsole"
|
||||
});
|
||||
|
||||
type UploadEntry = { file: File; path: string };
|
||||
type PublishManifestRule = {
|
||||
line: number;
|
||||
raw: string;
|
||||
pattern: string;
|
||||
exclude: boolean;
|
||||
};
|
||||
|
||||
const RELEASE_MANIFEST_NAMES = ["发布清单.txt", "release_manifest.txt"];
|
||||
|
||||
const activeTab = ref("overview");
|
||||
const loading = ref(false);
|
||||
const publishing = ref(false);
|
||||
@@ -24,9 +35,13 @@ const crashReports = ref<any[]>([]);
|
||||
const upgradeLogs = ref<any[]>([]);
|
||||
const downloadLogs = ref<any[]>([]);
|
||||
const auditLogs = ref<any[]>([]);
|
||||
const adminUsers = ref<any[]>([]);
|
||||
const adminRoleOptions = ref<any[]>([]);
|
||||
const currentAdminUsername = ref("");
|
||||
const currentAppId = ref("");
|
||||
const createdLicenseKey = ref("");
|
||||
const generatedClientConfig = ref("");
|
||||
const generatedServerConfig = ref("");
|
||||
const generatedCrashTestInfo = ref("");
|
||||
const licenseConfigWarning = ref("");
|
||||
const runtime = reactive<any>({});
|
||||
@@ -77,12 +92,37 @@ const licenseForm = reactive({
|
||||
});
|
||||
const configForm = reactive({
|
||||
api_base_url: "",
|
||||
install_root: "..",
|
||||
main_executable: "SimCAE.exe",
|
||||
license_key: ""
|
||||
});
|
||||
const securityForm = reactive({ next: "", confirm: "" });
|
||||
const securityForm = reactive({ current: "", next: "", confirm: "" });
|
||||
const adminUserForm = reactive({
|
||||
username: "",
|
||||
password: "",
|
||||
display_name: "",
|
||||
roles: ["auditor"]
|
||||
});
|
||||
const adminUserEditVisible = ref(false);
|
||||
const adminUserEditForm = reactive({
|
||||
username: "",
|
||||
display_name: "",
|
||||
roles: [] as string[]
|
||||
});
|
||||
const adminUserPasswordVisible = ref(false);
|
||||
const adminUserPasswordForm = reactive({
|
||||
username: "",
|
||||
new_password: ""
|
||||
});
|
||||
const rawFiles = ref<File[]>([]);
|
||||
const archiveFile = ref<File | null>(null);
|
||||
const selectedReleaseRoot = ref("");
|
||||
const releaseManifestName = ref("");
|
||||
const releaseManifestText = ref("");
|
||||
const releaseManifestError = ref("");
|
||||
const installRootDetectMessage = ref("");
|
||||
const installRootDialogVisible = ref(false);
|
||||
const installRootDialogReason = ref("");
|
||||
|
||||
const enabledChannels = computed(() =>
|
||||
channels.value.filter(item => item.enabled)
|
||||
@@ -108,43 +148,28 @@ const currentPolicyDisabledText = computed(() => {
|
||||
return disabled.length ? disabled.join(", ") : "无";
|
||||
});
|
||||
|
||||
const uploadEntries = computed(() => {
|
||||
const protectedFiles = new Set([
|
||||
"client.ini",
|
||||
"bootstrap.exe",
|
||||
"config/app_config.json",
|
||||
"config/local_state.json",
|
||||
"config/client_identity.dat",
|
||||
"config/version_policy.dat"
|
||||
]);
|
||||
const releaseManifestRules = computed(() =>
|
||||
parseReleaseManifest(releaseManifestText.value, selectedReleaseRoot.value)
|
||||
);
|
||||
|
||||
const uploadEntries = computed<UploadEntry[]>(() => {
|
||||
if (!rawFiles.value.length || !releaseManifestName.value || releaseManifestError.value) {
|
||||
return [];
|
||||
}
|
||||
const rules = releaseManifestRules.value;
|
||||
if (!rules.length) return [];
|
||||
const hasIncludeRules = rules.some(rule => !rule.exclude);
|
||||
return rawFiles.value
|
||||
.map(file => ({ file, path: relativePathForFile(file) }))
|
||||
.filter(item => item.path && !isReleaseManifestFile(item.path))
|
||||
.filter(item => {
|
||||
const path = item.path.toLowerCase();
|
||||
const parts = path.split("/");
|
||||
const blockedDirectory = parts
|
||||
.slice(0, -1)
|
||||
.some(
|
||||
part =>
|
||||
part === ".git" ||
|
||||
part === ".vs" ||
|
||||
part === "debug" ||
|
||||
part === "update" ||
|
||||
part === "update_temp" ||
|
||||
part === "cmakefiles" ||
|
||||
part.startsWith("build") ||
|
||||
part.endsWith("_autogen")
|
||||
);
|
||||
const runtimeFile =
|
||||
protectedFiles.has(path) ||
|
||||
(path.startsWith("bin/") && protectedFiles.has(path.slice(4)));
|
||||
return (
|
||||
!blockedDirectory &&
|
||||
!runtimeFile &&
|
||||
!path.endsWith(".pdb") &&
|
||||
!path.endsWith(".ilk") &&
|
||||
!path.endsWith(".obj")
|
||||
);
|
||||
let selected = !hasIncludeRules;
|
||||
for (const rule of rules) {
|
||||
if (manifestRuleMatches(rule.pattern, item.path)) {
|
||||
selected = !rule.exclude;
|
||||
}
|
||||
}
|
||||
return selected && isSafePublishPath(item.path);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,25 +182,271 @@ const publishSummary = computed(() => {
|
||||
if (!rawFiles.value.length) {
|
||||
return "未选择文件夹,请选择干净的 Release 发布根目录";
|
||||
}
|
||||
if (!releaseManifestName.value) {
|
||||
return `未找到发布清单,请在所选目录根部放置 ${RELEASE_MANIFEST_NAMES.join(" 或 ")}`;
|
||||
}
|
||||
if (releaseManifestError.value) {
|
||||
return releaseManifestError.value;
|
||||
}
|
||||
if (!uploadEntries.value.length) {
|
||||
return "已选择文件夹,但有效发布文件为空,请检查是否选到了 build/update/debug 等运行目录";
|
||||
return "发布清单没有匹配到有效文件,请检查清单路径或是否被安全规则过滤";
|
||||
}
|
||||
const preview = uploadEntries.value
|
||||
.slice(0, 6)
|
||||
.map(item => item.path)
|
||||
.join("、");
|
||||
return `已选择 ${uploadEntries.value.length} 个有效文件:${preview}${
|
||||
return `已读取 ${releaseManifestName.value},将发布 ${uploadEntries.value.length} 个文件:${preview}${
|
||||
uploadEntries.value.length > 6 ? " ..." : ""
|
||||
}`;
|
||||
});
|
||||
|
||||
function relativePathForFile(file: File) {
|
||||
const expectedReleaseMainExecutable = computed(() =>
|
||||
releaseMainExecutableForInstall(configForm.install_root, configForm.main_executable)
|
||||
);
|
||||
|
||||
function rawPathForFile(file: File) {
|
||||
const raw = ((file as any).webkitRelativePath || file.name) as string;
|
||||
return raw.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function relativePathForFile(file: File) {
|
||||
const raw = rawPathForFile(file);
|
||||
const parts = raw.replaceAll("\\", "/").split("/").filter(Boolean);
|
||||
if ((file as any).webkitRelativePath && parts.length > 1) parts.shift();
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function detectSelectedRoot(files: File[]) {
|
||||
const first = files[0];
|
||||
if (!first || !(first as any).webkitRelativePath) return "";
|
||||
const parts = rawPathForFile(first).split("/").filter(Boolean);
|
||||
return parts.length > 1 ? parts[0] : "";
|
||||
}
|
||||
|
||||
function normalizePublishPath(value: string) {
|
||||
let normalized = value.replaceAll("\\", "/").trim();
|
||||
while (normalized.startsWith("./")) normalized = normalized.slice(2);
|
||||
normalized = normalized.replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeManifestPattern(value: string, rootName: string) {
|
||||
let pattern = normalizePublishPath(value);
|
||||
const root = normalizePublishPath(rootName);
|
||||
if (root) {
|
||||
if (pattern === root) return "*";
|
||||
if (pattern.startsWith(`${root}/`)) {
|
||||
pattern = pattern.slice(root.length + 1);
|
||||
}
|
||||
}
|
||||
return pattern || "*";
|
||||
}
|
||||
|
||||
function parseReleaseManifest(text: string, rootName: string): PublishManifestRule[] {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((raw, index) => ({ raw, line: index + 1, trimmed: raw.trim() }))
|
||||
.filter(item => item.trimmed && !item.trimmed.startsWith("#"))
|
||||
.map(item => {
|
||||
const exclude = item.trimmed.startsWith("!");
|
||||
const body = exclude ? item.trimmed.slice(1).trim() : item.trimmed;
|
||||
return {
|
||||
line: item.line,
|
||||
raw: item.raw,
|
||||
pattern: normalizeManifestPattern(body, rootName),
|
||||
exclude
|
||||
};
|
||||
})
|
||||
.filter(rule => rule.pattern);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
||||
}
|
||||
|
||||
function manifestRuleMatches(pattern: string, filePath: string) {
|
||||
const normalizedPattern = normalizePublishPath(pattern);
|
||||
const normalizedPath = normalizePublishPath(filePath);
|
||||
if (normalizedPattern === "*") return true;
|
||||
if (normalizedPattern.endsWith("/*")) {
|
||||
const prefix = normalizedPattern.slice(0, -2);
|
||||
return normalizedPath.startsWith(`${prefix}/`);
|
||||
}
|
||||
if (!normalizedPattern.includes("*")) {
|
||||
return normalizedPath === normalizedPattern;
|
||||
}
|
||||
const regex = new RegExp(
|
||||
`^${normalizedPattern.split("*").map(escapeRegExp).join(".*")}$`
|
||||
);
|
||||
return regex.test(normalizedPath);
|
||||
}
|
||||
|
||||
function isReleaseManifestFile(path: string) {
|
||||
const normalized = normalizePublishPath(path).toLowerCase();
|
||||
return RELEASE_MANIFEST_NAMES.some(name => normalized === name.toLowerCase());
|
||||
}
|
||||
|
||||
function isSafePublishPath(itemPath: string) {
|
||||
const protectedFiles = new Set([
|
||||
"client.ini",
|
||||
"bootstrap.exe",
|
||||
"config/app_config.json",
|
||||
"config/local_state.json",
|
||||
"config/client_identity.dat",
|
||||
"config/version_policy.dat"
|
||||
]);
|
||||
const path = normalizePublishPath(itemPath).toLowerCase();
|
||||
const parts = path.split("/");
|
||||
const blockedDirectory = parts
|
||||
.slice(0, -1)
|
||||
.some(
|
||||
part =>
|
||||
part === ".git" ||
|
||||
part === ".vs" ||
|
||||
part === "debug" ||
|
||||
part === "update" ||
|
||||
part === "update_temp" ||
|
||||
part === "cmakefiles" ||
|
||||
part.startsWith("build") ||
|
||||
part.endsWith("_autogen")
|
||||
);
|
||||
const runtimeFile =
|
||||
protectedFiles.has(path) ||
|
||||
(path.startsWith("bin/") && protectedFiles.has(path.slice(4)));
|
||||
return (
|
||||
!blockedDirectory &&
|
||||
!runtimeFile &&
|
||||
!path.endsWith(".pdb") &&
|
||||
!path.endsWith(".ilk") &&
|
||||
!path.endsWith(".obj")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeExecutableName(value: string) {
|
||||
const normalized = normalizePublishPath(value || "SimCAE.exe");
|
||||
if (!normalized) return "SimCAE.exe";
|
||||
const parts = normalized.split("/");
|
||||
const leaf = parts[parts.length - 1];
|
||||
if (!leaf.includes(".")) {
|
||||
parts[parts.length - 1] = `${leaf}.exe`;
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function releaseMainExecutableForInstall(installRoot: string, mainExecutable: string) {
|
||||
const root = (installRoot || "..").trim();
|
||||
const main = normalizeExecutableName(mainExecutable);
|
||||
if (root === ".") return main;
|
||||
if (main.toLowerCase().startsWith("bin/")) return main;
|
||||
return `bin/${main}`;
|
||||
}
|
||||
|
||||
function executableNameCandidates(value: string, fallback: string) {
|
||||
const leaf =
|
||||
normalizePublishPath(value || fallback)
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.pop() || fallback;
|
||||
const lower = leaf.toLowerCase();
|
||||
const candidates = new Set([lower]);
|
||||
if (lower.endsWith(".exe")) {
|
||||
candidates.add(lower.slice(0, -4));
|
||||
} else {
|
||||
candidates.add(`${lower}.exe`);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function executableLeafName(value: string, fallback: string) {
|
||||
return (
|
||||
normalizePublishPath(value || fallback)
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.pop() || fallback
|
||||
);
|
||||
}
|
||||
|
||||
function launcherDisplayName() {
|
||||
return executableLeafName(
|
||||
runtime?.default_client_config?.launcher_executable,
|
||||
"Launcher.exe"
|
||||
);
|
||||
}
|
||||
|
||||
function dirname(path: string) {
|
||||
const parts = normalizePublishPath(path).split("/").filter(Boolean);
|
||||
parts.pop();
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function findExecutablePath(entries: UploadEntry[], candidates: Set<string>) {
|
||||
return entries
|
||||
.map(item => normalizePublishPath(item.path))
|
||||
.find(path => candidates.has(path.split("/").pop()?.toLowerCase() || ""));
|
||||
}
|
||||
|
||||
function installRootDescription() {
|
||||
const launcher = launcherDisplayName();
|
||||
return configForm.install_root === "."
|
||||
? `${launcher} 在发布根目录/,install_root=.`
|
||||
: `${launcher} 在发布根目录/bin/,install_root=..`;
|
||||
}
|
||||
|
||||
function showInstallRootDialog(reason: string) {
|
||||
installRootDialogReason.value = reason;
|
||||
installRootDetectMessage.value = reason;
|
||||
installRootDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function confirmInstallRootDialog() {
|
||||
installRootDialogVisible.value = false;
|
||||
installRootDetectMessage.value =
|
||||
`已手动确认:${installRootDescription()};当前发布包必须包含 ${expectedReleaseMainExecutable.value}`;
|
||||
}
|
||||
|
||||
function autoDetectInstallRootFromPublishEntries() {
|
||||
installRootDetectMessage.value = "";
|
||||
installRootDialogVisible.value = false;
|
||||
installRootDialogReason.value = "";
|
||||
const entries = uploadEntries.value;
|
||||
if (!entries.length) return;
|
||||
|
||||
const launcherName = launcherDisplayName();
|
||||
const launcherPath = findExecutablePath(
|
||||
entries,
|
||||
executableNameCandidates(launcherName, "Launcher.exe")
|
||||
);
|
||||
const mainPath = findExecutablePath(
|
||||
entries,
|
||||
executableNameCandidates(configForm.main_executable, "SimCAE.exe")
|
||||
);
|
||||
|
||||
if (!launcherPath) {
|
||||
showInstallRootDialog(`未在根据发布清单将要发布的文件里找到 ${launcherName},请确认安装结构。`);
|
||||
return;
|
||||
}
|
||||
|
||||
const launcherDir = dirname(launcherPath);
|
||||
if (!launcherDir) {
|
||||
configForm.install_root = ".";
|
||||
installRootDetectMessage.value =
|
||||
`已根据发布清单判断:${launcherName} 在发布根目录/,install_root=.;当前发布包必须包含 ${expectedReleaseMainExecutable.value}`;
|
||||
} else if (launcherDir.toLowerCase() === "bin") {
|
||||
configForm.install_root = "..";
|
||||
installRootDetectMessage.value =
|
||||
`已根据发布清单判断:${launcherName} 在发布根目录/bin/,install_root=..;当前发布包必须包含 ${expectedReleaseMainExecutable.value}`;
|
||||
} else {
|
||||
showInstallRootDialog(
|
||||
`发布清单里的 ${launcherName} 位于 ${launcherDir}/,当前只支持 ${launcherName} 在发布根目录/ 或 发布根目录/bin/,请确认安装结构。`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainPath && dirname(mainPath).toLowerCase() !== dirname(expectedReleaseMainExecutable.value).toLowerCase()) {
|
||||
installRootDetectMessage.value += `;注意:根据发布清单匹配到的主程序位于 ${mainPath},请确认主程序文件名和安装结构是否一致。`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
const n = Number(bytes) || 0;
|
||||
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||||
@@ -194,6 +465,10 @@ function handleError(prefix: string, error: unknown) {
|
||||
ElMessage.error(`${prefix}:${friendlyError(error)}`);
|
||||
}
|
||||
|
||||
function roleLabel(role: string) {
|
||||
return adminRoleOptions.value.find(item => item.value === role)?.label || role;
|
||||
}
|
||||
|
||||
function isLicenseFull(row: any) {
|
||||
return Number(row?.used_devices || 0) >= Number(row?.max_devices || 0);
|
||||
}
|
||||
@@ -206,9 +481,11 @@ async function loadRuntime() {
|
||||
const data = await adminRequest<any>("/admin/runtime-config");
|
||||
Object.assign(runtime, data || {});
|
||||
configForm.api_base_url =
|
||||
data?.api_base_url || data?.default_client_config?.api_base_url || location.origin;
|
||||
data?.api_base_url || location.origin;
|
||||
configForm.main_executable =
|
||||
data?.default_client_config?.main_executable || configForm.main_executable;
|
||||
configForm.install_root =
|
||||
data?.default_client_config?.install_root || configForm.install_root;
|
||||
}
|
||||
|
||||
async function loadApps() {
|
||||
@@ -282,12 +559,47 @@ async function saveChannel() {
|
||||
}
|
||||
}
|
||||
|
||||
function onDirectoryFiles(event: Event) {
|
||||
rawFiles.value = Array.from((event.target as HTMLInputElement).files || []);
|
||||
function resetReleaseManifest() {
|
||||
selectedReleaseRoot.value = "";
|
||||
releaseManifestName.value = "";
|
||||
releaseManifestText.value = "";
|
||||
releaseManifestError.value = "";
|
||||
installRootDetectMessage.value = "";
|
||||
installRootDialogVisible.value = false;
|
||||
installRootDialogReason.value = "";
|
||||
}
|
||||
|
||||
async function onDirectoryFiles(event: Event) {
|
||||
const files = Array.from((event.target as HTMLInputElement).files || []);
|
||||
rawFiles.value = files;
|
||||
archiveFile.value = null;
|
||||
resetReleaseManifest();
|
||||
if (!files.length) return;
|
||||
selectedReleaseRoot.value = detectSelectedRoot(files);
|
||||
const manifest = files.find(file => isReleaseManifestFile(relativePathForFile(file)));
|
||||
if (!manifest) {
|
||||
releaseManifestError.value = `未找到发布清单,请在目录根部放置 ${RELEASE_MANIFEST_NAMES.join(" 或 ")}`;
|
||||
return;
|
||||
}
|
||||
releaseManifestName.value = relativePathForFile(manifest);
|
||||
try {
|
||||
const text = await manifest.text();
|
||||
releaseManifestText.value = text;
|
||||
if (!parseReleaseManifest(text, selectedReleaseRoot.value).length) {
|
||||
releaseManifestError.value = "发布清单为空,请至少写入一条发布或排除规则";
|
||||
} else {
|
||||
autoDetectInstallRootFromPublishEntries();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("read release manifest error", error);
|
||||
releaseManifestError.value = "读取发布清单失败,请确认清单是普通文本文件";
|
||||
}
|
||||
}
|
||||
|
||||
function onArchiveFile(event: Event) {
|
||||
archiveFile.value = (event.target as HTMLInputElement).files?.[0] || null;
|
||||
rawFiles.value = [];
|
||||
resetReleaseManifest();
|
||||
}
|
||||
|
||||
async function publishVersion() {
|
||||
@@ -302,11 +614,23 @@ async function publishVersion() {
|
||||
"client_protocol",
|
||||
String(Math.max(1, Number(publishForm.client_protocol) || 3))
|
||||
);
|
||||
formData.append("install_root", configForm.install_root || "..");
|
||||
formData.append("main_executable", configForm.main_executable || "SimCAE.exe");
|
||||
if (publishForm.mode === "archive") {
|
||||
if (!archiveFile.value) return ElMessage.warning("未选择压缩包");
|
||||
formData.append("archive", archiveFile.value, archiveFile.value.name);
|
||||
} else {
|
||||
if (!uploadEntries.value.length) return ElMessage.warning("未选择文件夹");
|
||||
if (!rawFiles.value.length) return ElMessage.warning("未选择文件夹");
|
||||
if (!releaseManifestName.value) {
|
||||
return ElMessage.warning(`未找到发布清单:${RELEASE_MANIFEST_NAMES.join(" 或 ")}`);
|
||||
}
|
||||
if (releaseManifestError.value) return ElMessage.warning(releaseManifestError.value);
|
||||
if (!uploadEntries.value.length) {
|
||||
return ElMessage.warning("发布清单没有匹配到可发布文件");
|
||||
}
|
||||
if (installRootDialogVisible.value) {
|
||||
return ElMessage.warning("请先确认安装结构");
|
||||
}
|
||||
uploadEntries.value.forEach(item => {
|
||||
formData.append("files", item.file, item.file.name);
|
||||
formData.append("relative_paths", item.path);
|
||||
@@ -529,6 +853,7 @@ async function deleteLicense(row: any) {
|
||||
}
|
||||
|
||||
async function generateClientConfig() {
|
||||
if (installRootDialogVisible.value) return ElMessage.warning("请先确认安装结构");
|
||||
try {
|
||||
const result = await adminRequest<any>("/admin/client-config/generate", {
|
||||
method: "POST",
|
||||
@@ -539,11 +864,14 @@ async function generateClientConfig() {
|
||||
client_protocol: publishForm.client_protocol || 3,
|
||||
license_key: configForm.license_key,
|
||||
api_base_url: configForm.api_base_url,
|
||||
install_root: configForm.install_root,
|
||||
main_executable: configForm.main_executable
|
||||
}
|
||||
});
|
||||
generatedClientConfig.value =
|
||||
result.json_text || JSON.stringify(result.config || {}, null, 2);
|
||||
generatedServerConfig.value =
|
||||
result.server_config_text || JSON.stringify(result.server_config || {}, null, 2);
|
||||
generatedCrashTestInfo.value =
|
||||
result.crash_test_text ||
|
||||
JSON.stringify(result.crash_test_config || {}, null, 2);
|
||||
@@ -569,6 +897,11 @@ async function copyGeneratedCrashTestInfo() {
|
||||
ElMessage.success("崩溃报告联调信息已复制");
|
||||
}
|
||||
|
||||
async function copyGeneratedServerConfig() {
|
||||
await copyText(generatedServerConfig.value);
|
||||
ElMessage.success("qrc 服务端配置已复制");
|
||||
}
|
||||
|
||||
async function loadDevices() {
|
||||
if (!currentAppId.value) return;
|
||||
const data = await adminRequest<any>(
|
||||
@@ -666,22 +999,129 @@ async function clearLog(endpoint: string, reload: () => Promise<void>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function changeAdminToken() {
|
||||
if (securityForm.next.length < 8) return ElMessage.warning("新令牌至少 8 个字符");
|
||||
if (securityForm.next !== securityForm.confirm) {
|
||||
return ElMessage.warning("两次输入的新令牌不一致");
|
||||
async function loadAdminUsers() {
|
||||
try {
|
||||
const data = await adminRequest<any>("/admin/user/list");
|
||||
adminUsers.value = data?.list || [];
|
||||
adminRoleOptions.value = data?.roles || [];
|
||||
currentAdminUsername.value = data?.current_username || "";
|
||||
} catch (error) {
|
||||
adminUsers.value = [];
|
||||
adminRoleOptions.value = [];
|
||||
if ((error as any)?.status !== 403) handleError("加载管理员用户失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function createAdminUser() {
|
||||
if (!adminUserForm.username.trim()) return ElMessage.warning("请输入用户名");
|
||||
if (adminUserForm.password.length < 8) return ElMessage.warning("密码至少 8 个字符");
|
||||
if (!adminUserForm.roles.length) return ElMessage.warning("请至少选择一个角色");
|
||||
try {
|
||||
await adminRequest("/admin/user/create", {
|
||||
method: "POST",
|
||||
json: adminUserForm
|
||||
});
|
||||
ElMessage.success("管理员用户已创建");
|
||||
adminUserForm.username = "";
|
||||
adminUserForm.password = "";
|
||||
adminUserForm.display_name = "";
|
||||
adminUserForm.roles = ["auditor"];
|
||||
await loadAdminUsers();
|
||||
} catch (error) {
|
||||
handleError("创建管理员用户失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
function openEditAdminUser(row: any) {
|
||||
adminUserEditForm.username = row.username;
|
||||
adminUserEditForm.display_name = row.display_name || row.username;
|
||||
adminUserEditForm.roles = Array.isArray(row.roles) ? [...row.roles] : [];
|
||||
adminUserEditVisible.value = true;
|
||||
}
|
||||
|
||||
async function saveAdminUser() {
|
||||
if (!adminUserEditForm.roles.length) return ElMessage.warning("请至少选择一个角色");
|
||||
try {
|
||||
await adminRequest("/admin/user/update", {
|
||||
method: "POST",
|
||||
json: adminUserEditForm
|
||||
});
|
||||
ElMessage.success("管理员用户已更新");
|
||||
adminUserEditVisible.value = false;
|
||||
await loadAdminUsers();
|
||||
} catch (error) {
|
||||
handleError("更新管理员用户失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAdminUser(row: any) {
|
||||
const nextStatus = row.status === "active" ? "disabled" : "active";
|
||||
try {
|
||||
if (nextStatus === "disabled") {
|
||||
await ElMessageBox.confirm(
|
||||
`确定禁用管理员 ${row.username} 吗?禁用后该账号不能再登录。`,
|
||||
"禁用管理员",
|
||||
{ type: "warning" }
|
||||
);
|
||||
}
|
||||
await adminRequest("/admin/user/status", {
|
||||
method: "POST",
|
||||
json: { username: row.username, status: nextStatus }
|
||||
});
|
||||
ElMessage.success("管理员状态已更新");
|
||||
await loadAdminUsers();
|
||||
} catch (error) {
|
||||
if (error !== "cancel" && error !== "close") handleError("修改管理员状态失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
function openResetAdminPassword(row: any) {
|
||||
adminUserPasswordForm.username = row.username;
|
||||
adminUserPasswordForm.new_password = "";
|
||||
adminUserPasswordVisible.value = true;
|
||||
}
|
||||
|
||||
async function resetAdminPassword() {
|
||||
if (adminUserPasswordForm.new_password.length < 8) {
|
||||
return ElMessage.warning("新密码至少 8 个字符");
|
||||
}
|
||||
try {
|
||||
await adminRequest("/admin/token/change", {
|
||||
await adminRequest("/admin/user/password/reset", {
|
||||
method: "POST",
|
||||
json: { new_token: securityForm.next }
|
||||
json: adminUserPasswordForm
|
||||
});
|
||||
localStorage.setItem("admin_token", securityForm.next);
|
||||
ElMessage.success("管理员密码已重置");
|
||||
adminUserPasswordVisible.value = false;
|
||||
await loadAdminUsers();
|
||||
} catch (error) {
|
||||
handleError("重置管理员密码失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeAdminPassword() {
|
||||
if (!securityForm.current) return ElMessage.warning("请输入当前密码");
|
||||
if (securityForm.next.length < 8) return ElMessage.warning("新密码至少 8 个字符");
|
||||
if (securityForm.next !== securityForm.confirm) {
|
||||
return ElMessage.warning("两次输入的新密码不一致");
|
||||
}
|
||||
try {
|
||||
await adminRequest("/admin/user/password/change", {
|
||||
method: "POST",
|
||||
json: {
|
||||
current_password: securityForm.current,
|
||||
new_password: securityForm.next
|
||||
}
|
||||
});
|
||||
securityForm.current = "";
|
||||
securityForm.next = "";
|
||||
securityForm.confirm = "";
|
||||
ElMessage.success("管理员令牌已更新,请重新登录确认");
|
||||
removeToken();
|
||||
ElMessage.success("密码已更新,请重新登录");
|
||||
setTimeout(() => {
|
||||
window.location.href = "/login";
|
||||
}, 800);
|
||||
} catch (error) {
|
||||
handleError("更改令牌失败", error);
|
||||
handleError("修改密码失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,6 +1130,7 @@ async function loadAll() {
|
||||
try {
|
||||
await loadRuntime();
|
||||
await loadApps();
|
||||
await loadAdminUsers();
|
||||
await loadCrashReports();
|
||||
await loadLogs();
|
||||
} finally {
|
||||
@@ -808,7 +1249,7 @@ onMounted(loadAll);
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="发布前请选择 SimCAE 的 Release 发布根目录,或上传已经压缩好的发布包。不要选择 build、debug、update 等运行目录。"
|
||||
title="发布前请选择 SimCAE 的 Release 发布根目录,或上传已经压缩好的发布包。目录模式会按发布清单决定上传范围。"
|
||||
/>
|
||||
<el-form label-position="top" :model="publishForm" class="form-grid">
|
||||
<el-form-item label="App ID">
|
||||
@@ -827,10 +1268,36 @@ onMounted(loadAll);
|
||||
<div class="field-help">不确定时保持默认值;只有客户端升级协议变化时才需要调整。</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert
|
||||
v-if="installRootDetectMessage"
|
||||
class="mb"
|
||||
:type="installRootDialogVisible ? 'warning' : 'success'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="installRootDetectMessage"
|
||||
/>
|
||||
<el-radio-group v-model="publishForm.mode">
|
||||
<el-radio-button label="directory">Release 目录</el-radio-button>
|
||||
<el-radio-button label="archive">压缩包</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div v-if="publishForm.mode === 'directory'" class="manifest-guide">
|
||||
<div class="manifest-guide-title">目录模式需要在所选目录根部放置 <strong>发布清单.txt</strong>,也兼容 <strong>release_manifest.txt</strong></div>
|
||||
<div class="manifest-guide-rules">
|
||||
例如你选择的是 test_file 文件夹,就在 test_file/发布清单.txt 里写下面这些规则:
|
||||
</div>
|
||||
<pre># 发布 test_file 文件夹下的所有内容
|
||||
test_file/*
|
||||
|
||||
# 不发布升级运行时目录
|
||||
!test_file/update/*
|
||||
|
||||
# 不发布调试符号文件
|
||||
!test_file/*.pdb</pre>
|
||||
<div class="manifest-guide-rules">
|
||||
普通路径表示要发布;前缀 ! 表示排除;空行和 # 注释会忽略;后面的规则优先。也可以省略根目录名,例如写 *、!update/*、!*.pdb。
|
||||
</div>
|
||||
<div class="field-help">安全兜底仍会过滤 .git、build、debug、update、update_temp、调试文件和客户端本地配置文件。</div>
|
||||
</div>
|
||||
<div class="upload-box">
|
||||
<input v-if="publishForm.mode === 'directory'" type="file" webkitdirectory directory multiple @change="onDirectoryFiles" />
|
||||
<input v-else type="file" accept=".zip,.tar.gz,.tgz,.tar.bz2,.tbz2,.rar" @change="onArchiveFile" />
|
||||
@@ -944,14 +1411,20 @@ onMounted(loadAll);
|
||||
<el-card shadow="never" class="mt">
|
||||
<template #header>客户端配置生成</template>
|
||||
<el-form label-position="top" :model="configForm" class="form-grid">
|
||||
<el-form-item label="服务端 API 地址"><el-input v-model="configForm.api_base_url" placeholder="例如 http://192.168.229.128:8000" /></el-form-item>
|
||||
<el-form-item label="服务端 API 地址">
|
||||
<el-input v-model="configForm.api_base_url" placeholder="例如 http://192.168.229.128:8000" />
|
||||
<div class="field-help">这个地址会生成到 qrc 编译配置,不会写入客户端 app_config.json。</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="主程序文件名"><el-input v-model="configForm.main_executable" placeholder="例如 SimCAE.exe" /></el-form-item>
|
||||
<el-form-item label="License"><el-input v-model="configForm.license_key" placeholder="可从上方 License 列表填入;也可留空让客户端首次启动时输入" /></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="licenseConfigWarning" class="mb" type="warning" :closable="false" show-icon :title="licenseConfigWarning" />
|
||||
<div class="action-row"><el-button type="primary" @click="generateClientConfig">生成配套配置</el-button><el-button :disabled="!generatedClientConfig" @click="copyGeneratedConfig">复制配置</el-button></div>
|
||||
<div class="action-row"><el-button type="primary" @click="generateClientConfig">生成配套配置</el-button><el-button :disabled="!generatedClientConfig" @click="copyGeneratedConfig">复制 app_config</el-button></div>
|
||||
<div class="config-section-title">客户端 app_config.json</div>
|
||||
<pre class="code-block">{{ generatedClientConfig || "尚未生成客户端配置" }}</pre>
|
||||
<el-alert class="mt-sm" type="info" :closable="false" show-icon title="下面的 server_config.json 用于编译进客户端 qrc。请在编译 Launcher/Updater 前写入 update-client/config/server_config.json;它不会复制进 app_config.json。" />
|
||||
<div class="action-row mt-sm"><span class="config-section-title">qrc 服务端配置 server_config.json</span><el-button :disabled="!generatedServerConfig" @click="copyGeneratedServerConfig">复制 qrc 配置</el-button></div>
|
||||
<pre class="code-block small-code-block">{{ generatedServerConfig || "生成配套配置后显示 qrc 服务端配置" }}</pre>
|
||||
<el-alert class="mt-sm" type="warning" :closable="false" show-icon title="下面的崩溃报告联调信息只给管理员或测试人员查看,不会复制进客户端 app_config.json,也不要放进客户端配置文件。" />
|
||||
<div class="action-row mt-sm"><span class="config-section-title">崩溃报告接口联调信息</span><el-button :disabled="!generatedCrashTestInfo" @click="copyGeneratedCrashTestInfo">复制联调信息</el-button></div>
|
||||
<pre class="code-block">{{ generatedCrashTestInfo || "生成配套配置后显示崩溃报告接口、Token 和大小限制" }}</pre>
|
||||
@@ -1012,7 +1485,10 @@ onMounted(loadAll);
|
||||
<el-tab-pane label="审计日志">
|
||||
<el-button type="danger" plain @click="clearLog('/admin/audit-log/clear', loadAuditLogs)">清空</el-button>
|
||||
<el-table :data="auditLogs" stripe empty-text="暂无审计日志">
|
||||
<el-table-column prop="actor_hash" label="管理员指纹" />
|
||||
<el-table-column prop="actor_username" label="管理员" min-width="130" />
|
||||
<el-table-column prop="actor_roles" label="角色" min-width="150" />
|
||||
<el-table-column prop="auth_type" label="认证方式" min-width="110" />
|
||||
<el-table-column prop="actor_hash" label="指纹" min-width="130" />
|
||||
<el-table-column label="操作" min-width="220"><template #default="{ row }">{{ row.method }} {{ row.path }}</template></el-table-column>
|
||||
<el-table-column prop="result" label="结果" />
|
||||
<el-table-column prop="status_code" label="状态码" />
|
||||
@@ -1026,14 +1502,155 @@ onMounted(loadAll);
|
||||
|
||||
<el-tab-pane label="安全设置" name="security">
|
||||
<el-card shadow="never">
|
||||
<template #header>修改当前账号密码</template>
|
||||
<el-form label-position="top" :model="securityForm" class="form-grid">
|
||||
<el-form-item label="新管理员令牌"><el-input v-model="securityForm.next" show-password /></el-form-item>
|
||||
<el-form-item label="确认新令牌"><el-input v-model="securityForm.confirm" show-password /></el-form-item>
|
||||
<el-form-item label="当前密码"><el-input v-model="securityForm.current" show-password /></el-form-item>
|
||||
<el-form-item label="新密码"><el-input v-model="securityForm.next" show-password placeholder="至少 8 个字符" /></el-form-item>
|
||||
<el-form-item label="确认新密码"><el-input v-model="securityForm.confirm" show-password /></el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" @click="changeAdminToken">更改管理员令牌</el-button>
|
||||
<el-button type="primary" @click="changeAdminPassword">修改密码</el-button>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<span>管理员用户</span>
|
||||
<el-button @click="loadAdminUsers">刷新用户</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert
|
||||
class="mb"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="只有具备管理员管理权限的账号可以创建、禁用、分配角色和重置密码。系统会阻止禁用最后一个超级管理员。"
|
||||
/>
|
||||
<el-form label-position="top" :model="adminUserForm" class="form-grid">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="adminUserForm.username" placeholder="例如 release_admin" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示名称">
|
||||
<el-input v-model="adminUserForm.display_name" placeholder="例如 发布管理员" />
|
||||
</el-form-item>
|
||||
<el-form-item label="初始密码">
|
||||
<el-input v-model="adminUserForm.password" show-password placeholder="至少 8 个字符" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="adminUserForm.roles" multiple collapse-tags collapse-tags-tooltip>
|
||||
<el-option
|
||||
v-for="role in adminRoleOptions"
|
||||
:key="role.value"
|
||||
:label="role.label"
|
||||
:value="role.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" @click="createAdminUser">创建管理员</el-button>
|
||||
|
||||
<el-table class="mt" :data="adminUsers" stripe empty-text="暂无管理员用户">
|
||||
<el-table-column prop="username" label="用户名" min-width="150" />
|
||||
<el-table-column prop="display_name" label="显示名称" min-width="150" />
|
||||
<el-table-column label="角色" min-width="230">
|
||||
<template #default="{ row }">
|
||||
<div class="role-tags">
|
||||
<el-tag v-for="role in row.roles" :key="role" size="small">
|
||||
{{ roleLabel(role) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'">
|
||||
{{ row.status === "active" ? "启用" : "禁用" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_login_at" label="最近登录" min-width="170" />
|
||||
<el-table-column label="操作" width="260">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEditAdminUser(row)">编辑</el-button>
|
||||
<el-button link type="primary" @click="openResetAdminPassword(row)">重置密码</el-button>
|
||||
<el-button
|
||||
link
|
||||
:type="row.status === 'active' ? 'danger' : 'success'"
|
||||
:disabled="row.username === currentAdminUsername && row.status === 'active'"
|
||||
@click="toggleAdminUser(row)"
|
||||
>
|
||||
{{ row.status === "active" ? "禁用" : "启用" }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog
|
||||
v-model="installRootDialogVisible"
|
||||
title="确认安装结构"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-alert
|
||||
class="mb"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="installRootDialogReason || '页面无法从发布清单自动判断安装结构,请手动确认。'"
|
||||
/>
|
||||
<el-radio-group v-model="configForm.install_root" class="install-root-options">
|
||||
<el-radio-button label="..">{{ launcherDisplayName() }} 在发布根目录/bin/</el-radio-button>
|
||||
<el-radio-button label=".">{{ launcherDisplayName() }} 在发布根目录/</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="field-help">
|
||||
当前选择:{{ installRootDescription() }};发布包必须包含 {{ expectedReleaseMainExecutable }}。确认后会写入生成的 app_config.json。
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="confirmInstallRootDialog">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="adminUserEditVisible" title="编辑管理员" width="520px">
|
||||
<el-form label-position="top" :model="adminUserEditForm">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="adminUserEditForm.username" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示名称">
|
||||
<el-input v-model="adminUserEditForm.display_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="adminUserEditForm.roles" multiple collapse-tags collapse-tags-tooltip class="full-width">
|
||||
<el-option
|
||||
v-for="role in adminRoleOptions"
|
||||
:key="role.value"
|
||||
:label="role.label"
|
||||
:value="role.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="adminUserEditVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveAdminUser">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="adminUserPasswordVisible" title="重置管理员密码" width="520px">
|
||||
<el-form label-position="top" :model="adminUserPasswordForm">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="adminUserPasswordForm.username" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="adminUserPasswordForm.new_password" show-password placeholder="至少 8 个字符" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="adminUserPasswordVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="resetAdminPassword">重置密码</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1137,6 +1754,23 @@ onMounted(loadAll);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.role-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
:deep(.el-table__row.app-selected-row > td.el-table__cell) {
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
}
|
||||
@@ -1165,6 +1799,43 @@ onMounted(loadAll);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.install-root-options {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.manifest-guide {
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
background: var(--el-fill-color-lighter);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.manifest-guide-title {
|
||||
margin-bottom: 8px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.manifest-guide-rules {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.manifest-guide pre {
|
||||
margin: 10px 0 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
color: #d1e9ff;
|
||||
background: #101828;
|
||||
border-radius: 6px;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.mt {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -1209,6 +1880,10 @@ onMounted(loadAll);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.small-code-block {
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.metric-grid,
|
||||
.form-grid {
|
||||
|
||||
Reference in New Issue
Block a user