This commit is contained in:
fengchuanhn@gmail.com
2026-05-21 01:47:16 +08:00
parent 3b0a8d777c
commit 54f080fbc1
22 changed files with 1106 additions and 36 deletions

View File

@@ -1,10 +1,148 @@
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { fetchSoftwareInfo } from "../api/oemSoftwareInfo.js";
const software = ref({
softwareName: "",
logoUrl: null,
wechat: null,
});
const loading = ref(true);
const isMaximized = ref(false);
let unlistenResize = null;
function isTauri() {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
async function syncMaximizedState() {
if (!isTauri()) return;
try {
isMaximized.value = await getCurrentWindow().isMaximized();
} catch {
/* ignore */
}
}
async function onMinimize() {
if (!isTauri()) return;
try {
await getCurrentWindow().minimize();
} catch {
/* ignore */
}
}
async function onToggleMaximize() {
if (!isTauri()) return;
try {
const win = getCurrentWindow();
if (await win.isMaximized()) {
await win.unmaximize();
} else {
await win.maximize();
}
await syncMaximizedState();
} catch {
/* ignore */
}
}
onMounted(async () => {
loading.value = true;
software.value = await fetchSoftwareInfo();
loading.value = false;
await syncMaximizedState();
if (isTauri()) {
try {
unlistenResize = await getCurrentWindow().onResized(() => {
syncMaximizedState();
});
} catch {
/* ignore */
}
}
});
onUnmounted(() => {
if (typeof unlistenResize === "function") {
unlistenResize();
}
});
</script>
<template>
<header
class="flex h-9 shrink-0 items-center border-b border-cyan-500/10 bg-bg-base/80 px-3 backdrop-blur-sm select-none"
data-tauri-drag-region
>
<span class="font-mono text-xs tracking-widest text-text-muted uppercase">
aiclient
</span>
<header class="app-titlebar flex h-12 shrink-0 items-stretch border-b border-cyan-500/10 bg-bg-base/80 backdrop-blur-sm select-none">
<div
class="app-titlebar__main flex min-w-0 flex-1 items-center gap-2 px-3"
data-tauri-drag-region
>
<template v-if="!loading">
<img
v-if="software.logoUrl"
:src="software.logoUrl"
alt="软件 logo"
class="app-titlebar__logo w-14 shrink-0 object-contain"
data-tauri-drag-region
/>
<span
v-if="software.softwareName"
class="app-titlebar__name truncate text-xs font-medium text-slate-200"
data-tauri-drag-region
>
{{ software.softwareName }}
</span>
<span
v-else
class="font-mono text-xs tracking-widest text-text-muted uppercase"
data-tauri-drag-region
>
aiclient
</span>
<span class="min-w-2 flex-1" data-tauri-drag-region />
<span
v-if="software.wechat"
class="app-titlebar__wechat shrink-0 truncate text-xs text-text-muted"
data-tauri-drag-region
>
客服微信{{ software.wechat }}
</span>
</template>
<span
v-else
class="font-mono text-xs tracking-widest text-text-muted uppercase"
data-tauri-drag-region
>
aiclient
</span>
</div>
<div class="app-titlebar__controls flex shrink-0 items-stretch">
<button
type="button"
class="app-titlebar__btn"
title="最小化"
aria-label="最小化"
@click="onMinimize"
>
<i class="pi pi-minus text-xs" />
</button>
<button
type="button"
class="app-titlebar__btn"
:title="isMaximized ? '还原' : '最大化'"
:aria-label="isMaximized ? '还原' : '最大化'"
@click="onToggleMaximize"
>
<i
class="pi text-xs"
:class="isMaximized ? 'pi-window-minimize' : 'pi-window-maximize'"
/>
</button>
</div>
</header>
</template>

View File

@@ -0,0 +1,101 @@
<script setup>
import { ref } from "vue";
const props = defineProps({
modelValue: { type: String, default: "" },
label: { type: String, required: true },
accept: { type: String, default: "image/png,image/jpeg,image/webp,image/gif" },
maxSizeMb: { type: Number, default: 2 },
});
const emit = defineEmits(["update:modelValue", "error"]);
const fileInputRef = ref(null);
const reading = ref(false);
function openPicker() {
fileInputRef.value?.click();
}
function clearImage() {
emit("update:modelValue", "");
if (fileInputRef.value) fileInputRef.value.value = "";
}
async function onFileChange(event) {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
emit("error", "请选择图片文件");
event.target.value = "";
return;
}
const maxBytes = props.maxSizeMb * 1024 * 1024;
if (file.size > maxBytes) {
emit("error", `图片不能超过 ${props.maxSizeMb}MB`);
event.target.value = "";
return;
}
reading.value = true;
try {
const dataUrl = await readAsDataUrl(file);
emit("update:modelValue", dataUrl);
} catch {
emit("error", "读取图片失败");
} finally {
reading.value = false;
event.target.value = "";
}
}
function readAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
</script>
<template>
<div class="admin-image-upload">
<label class="admin-image-upload__label">{{ label }}</label>
<div class="admin-image-upload__body">
<div
class="admin-image-upload__preview"
:class="{ 'admin-image-upload__preview--empty': !modelValue }"
>
<img v-if="modelValue" :src="modelValue" alt="" />
<span v-else class="text-xs text-text-muted">未上传</span>
</div>
<div class="admin-image-upload__actions">
<input
ref="fileInputRef"
type="file"
class="admin-image-upload__input"
:accept="accept"
@change="onFileChange"
/>
<Button
label="选择图片"
size="small"
severity="secondary"
:loading="reading"
@click="openPicker"
/>
<Button
v-if="modelValue"
label="清除"
size="small"
severity="danger"
text
@click="clearImage"
/>
</div>
</div>
</div>
</template>

View File

@@ -5,10 +5,11 @@ import { useRoute } from "vue-router";
const route = useRoute();
const menuItems = [
{ name: "admin-overview", label: "概览", to: "/admin" },
{ name: "admin-users", label: "用户管理", to: "/admin/users" },
{ name: "admin-card-keys", label: "卡密管理", to: "/admin/card-keys" },
{ name: "admin-desktop-config", label: "桌面配置", to: "/admin/desktop-config" },
{ name: "admin-overview", label: "软件配置", to: "/admin" },
];
const activeName = computed(() => route.name);
@@ -16,7 +17,7 @@ const activeName = computed(() => route.name);
<template>
<nav class="admin-sub-nav" aria-label="管理后台导航">
<p class="admin-sub-nav__title">管理后台</p>
<p class="admin-sub-nav__title"></p>
<RouterLink
v-for="item in menuItems"
:key="item.name"

View File

@@ -5,10 +5,11 @@ import { useRoute } from "vue-router";
const route = useRoute();
const menuItems = [
{ name: "admin-overview", label: "概览", to: "/admin" },
{ name: "admin-users", label: "用户管理", to: "/admin/users" },
{ name: "admin-card-keys", label: "卡密管理", to: "/admin/card-keys" },
{ name: "admin-desktop-config", label: "桌面配置", to: "/admin/desktop-config" },
{ name: "admin-overview", label: "软件配置", to: "/admin" },
];
const activeName = computed(() => route.name);
@@ -16,7 +17,7 @@ const activeName = computed(() => route.name);
<template>
<nav class="admin-sub-nav" aria-label="管理后台导航">
<p class="admin-sub-nav__title">管理后台</p>
<p class="admin-sub-nav__title"></p>
<RouterLink
v-for="item in menuItems"
:key="item.name"