11
This commit is contained in:
@@ -1,24 +1,46 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useAuthStore } from "../stores/auth.js";
|
||||
import { fetchSoftwareInfo } from "../api/oemSoftwareInfo.js";
|
||||
import { applyWindowBranding } from "../services/windowBranding.js";
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const software = ref({
|
||||
softwareName: "",
|
||||
logoUrl: null,
|
||||
wechat: null,
|
||||
wechatQrcode: null,
|
||||
});
|
||||
|
||||
const loading = ref(true);
|
||||
const isMaximized = ref(false);
|
||||
const wechatQrVisible = ref(false);
|
||||
const loggingOut = ref(false);
|
||||
|
||||
let unlistenResize = null;
|
||||
|
||||
const displayUsername = computed(
|
||||
() => auth.currentUser?.username?.trim() || "—",
|
||||
);
|
||||
|
||||
const vipEndLabel = computed(() => formatVipEnd(auth.currentUser?.vip_end_time));
|
||||
|
||||
function isTauri() {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
|
||||
function formatVipEnd(value) {
|
||||
if (!value) return "VIP:未开通";
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return "VIP:—";
|
||||
const text = d.toLocaleString("zh-CN", { hour12: false });
|
||||
return `VIP 至 ${text}`;
|
||||
}
|
||||
|
||||
async function syncMaximizedState() {
|
||||
if (!isTauri()) return;
|
||||
try {
|
||||
@@ -37,6 +59,11 @@ async function onMinimize() {
|
||||
}
|
||||
}
|
||||
|
||||
function openWechatQr() {
|
||||
if (!software.value.wechatQrcode) return;
|
||||
wechatQrVisible.value = true;
|
||||
}
|
||||
|
||||
async function onToggleMaximize() {
|
||||
if (!isTauri()) return;
|
||||
try {
|
||||
@@ -52,12 +79,24 @@ async function onToggleMaximize() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
if (loggingOut.value) return;
|
||||
loggingOut.value = true;
|
||||
await auth.logout();
|
||||
loggingOut.value = false;
|
||||
await router.push({ name: "login" });
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
software.value = await fetchSoftwareInfo();
|
||||
loading.value = false;
|
||||
await applyWindowBranding(software.value);
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
await auth.refreshProfile();
|
||||
}
|
||||
|
||||
await syncMaximizedState();
|
||||
if (isTauri()) {
|
||||
try {
|
||||
@@ -84,13 +123,18 @@ onUnmounted(() => {
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<template v-if="!loading">
|
||||
<img
|
||||
<div
|
||||
v-if="software.logoUrl"
|
||||
:src="software.logoUrl"
|
||||
alt="软件 logo"
|
||||
class="app-titlebar__logo w-14 shrink-0 object-contain"
|
||||
class="app-titlebar__logo-wrap"
|
||||
data-tauri-drag-region
|
||||
/>
|
||||
>
|
||||
<img
|
||||
:src="software.logoUrl"
|
||||
alt="软件 logo"
|
||||
class="app-titlebar__logo"
|
||||
data-tauri-drag-region
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-if="software.softwareName"
|
||||
class="app-titlebar__name truncate text-xs font-medium text-slate-200"
|
||||
@@ -105,14 +149,16 @@ onUnmounted(() => {
|
||||
>
|
||||
aiclient
|
||||
</span>
|
||||
<span class="min-w-2 flex-1" data-tauri-drag-region />
|
||||
<span
|
||||
<button
|
||||
v-if="software.wechat"
|
||||
type="button"
|
||||
class="app-titlebar__wechat shrink-0 truncate text-xs text-text-muted"
|
||||
data-tauri-drag-region
|
||||
:class="{ 'app-titlebar__wechat--clickable': software.wechatQrcode }"
|
||||
:title="software.wechatQrcode ? '点击查看微信二维码' : undefined"
|
||||
@click.stop="openWechatQr"
|
||||
>
|
||||
客服微信:{{ software.wechat }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
@@ -121,6 +167,28 @@ onUnmounted(() => {
|
||||
>
|
||||
aiclient
|
||||
</span>
|
||||
<span class="min-w-2 flex-1" data-tauri-drag-region />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="auth.isAuthenticated"
|
||||
class="app-titlebar__user flex shrink-0 items-center gap-2 border-l border-cyan-500/10 px-3"
|
||||
>
|
||||
<span class="app-titlebar__username truncate text-xs text-slate-200">
|
||||
{{ displayUsername }}
|
||||
</span>
|
||||
<span class="app-titlebar__vip shrink-0 text-xs text-text-muted">
|
||||
{{ vipEndLabel }}
|
||||
</span>
|
||||
<Button
|
||||
label="退出"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
class="app-titlebar__logout"
|
||||
:loading="loggingOut"
|
||||
@click="onLogout"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="app-titlebar__controls flex shrink-0 items-stretch">
|
||||
@@ -147,4 +215,24 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="wechatQrVisible"
|
||||
header="客服微信"
|
||||
modal
|
||||
:style="{ width: '18rem' }"
|
||||
:draggable="false"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<img
|
||||
v-if="software.wechatQrcode"
|
||||
:src="software.wechatQrcode"
|
||||
alt="微信二维码"
|
||||
class="max-h-56 w-full object-contain"
|
||||
/>
|
||||
<p v-if="software.wechat" class="text-center text-sm text-slate-300">
|
||||
{{ software.wechat }}
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
101
src/components/oem/OemImageUpload.vue
Normal file
101
src/components/oem/OemImageUpload.vue
Normal 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>
|
||||
@@ -6,10 +6,10 @@ const route = useRoute();
|
||||
|
||||
const menuItems = [
|
||||
|
||||
{ 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" },
|
||||
{ name: "oem-users", label: "用户管理", to: "/oem/users" },
|
||||
{ name: "oem-card-keys", label: "卡密管理", to: "/oem/card-keys" },
|
||||
{ name: "oem-desktop-config", label: "桌面配置", to: "/oem/desktop-config" },
|
||||
{ name: "oem-overview", label: "软件配置", to: "/oem" },
|
||||
];
|
||||
|
||||
const activeName = computed(() => route.name);
|
||||
|
||||
Reference in New Issue
Block a user