11
This commit is contained in:
@@ -19,6 +19,7 @@ pub struct SoftwareInfo {
|
|||||||
pub software_name: String,
|
pub software_name: String,
|
||||||
pub logo_url: Option<String>,
|
pub logo_url: Option<String>,
|
||||||
pub wechat: Option<String>,
|
pub wechat: Option<String>,
|
||||||
|
pub wechat_qrcode_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn empty_info(oem_id: i64) -> SoftwareInfo {
|
fn empty_info(oem_id: i64) -> SoftwareInfo {
|
||||||
@@ -27,6 +28,7 @@ fn empty_info(oem_id: i64) -> SoftwareInfo {
|
|||||||
software_name: String::new(),
|
software_name: String::new(),
|
||||||
logo_url: None,
|
logo_url: None,
|
||||||
wechat: None,
|
wechat: None,
|
||||||
|
wechat_qrcode_url: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +54,11 @@ fn parse_software_info(oem_id: i64, value: &Value) -> SoftwareInfo {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(str::to_string),
|
.map(str::to_string),
|
||||||
|
wechat_qrcode_url: data
|
||||||
|
.get("wechat_qrcode_url")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ export function loginApi(payload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {string} token */
|
||||||
|
export function fetchMeApi(token) {
|
||||||
|
return apiRequest("/auth/me", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** @param {string} token */
|
/** @param {string} token */
|
||||||
export function logoutApi(token) {
|
export function logoutApi(token) {
|
||||||
return apiRequest("/auth/logout", {
|
return apiRequest("/auth/logout", {
|
||||||
|
|||||||
@@ -1,27 +1,35 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 经 Rust 读取本地 oem 文件中的 ID,并请求公开品牌 API。
|
|
||||||
* @returns {Promise<{
|
* @returns {Promise<{
|
||||||
* oemId: number,
|
* oemId: number,
|
||||||
* softwareName: string,
|
* softwareName: string,
|
||||||
* logoUrl: string | null,
|
* logoUrl: string | null,
|
||||||
* wechat: string | null,
|
* wechat: string | null,
|
||||||
|
* wechatQrcode: string | null,
|
||||||
* }>}
|
* }>}
|
||||||
*/
|
*/
|
||||||
export async function fetchSoftwareInfo() {
|
export async function fetchSoftwareInfo() {
|
||||||
|
const empty = {
|
||||||
|
oemId: 1,
|
||||||
|
softwareName: "",
|
||||||
|
logoUrl: null,
|
||||||
|
wechat: null,
|
||||||
|
wechatQrcode: null,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const data = await invoke("get_software_info");
|
const data = await invoke("get_software_info");
|
||||||
if (!data || typeof data !== "object") {
|
if (!data || typeof data !== "object") {
|
||||||
return { oemId: 1, softwareName: "", logoUrl: null, wechat: null };
|
return empty;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
oemId: data.oemId ?? 1,
|
oemId: data.oemId ?? 1,
|
||||||
softwareName: data.softwareName ?? "",
|
softwareName: data.softwareName ?? "",
|
||||||
logoUrl: data.logoUrl || null,
|
logoUrl: data.logoUrl || null,
|
||||||
wechat: data.wechat || null,
|
wechat: data.wechat || null,
|
||||||
|
wechatQrcode: data.wechatQrcodeUrl || null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { oemId: 1, softwareName: "", logoUrl: null, wechat: null };
|
return empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
src/api/oemUsers.js
Normal file
72
src/api/oemUsers.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { apiRequest } from "./client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {RequestInit} [options]
|
||||||
|
*/
|
||||||
|
function oemRequest(token, path, options = {}) {
|
||||||
|
return apiRequest(path, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {{
|
||||||
|
* page?: number,
|
||||||
|
* pageSize?: number,
|
||||||
|
* username?: string,
|
||||||
|
* oemId?: number | null,
|
||||||
|
* agentId?: number | null,
|
||||||
|
* roleId?: number | null,
|
||||||
|
* }} [params]
|
||||||
|
*/
|
||||||
|
export function listOemUsersApi(
|
||||||
|
token,
|
||||||
|
{ page = 1, pageSize = 20, username, oemId, agentId, roleId } = {},
|
||||||
|
) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
page_size: String(pageSize),
|
||||||
|
});
|
||||||
|
const trimmed = username?.trim();
|
||||||
|
if (trimmed) query.set("username", trimmed);
|
||||||
|
if (oemId != null) query.set("oem_id", String(oemId));
|
||||||
|
if (agentId != null) query.set("agent_id", String(agentId));
|
||||||
|
if (roleId != null) query.set("role_id", String(roleId));
|
||||||
|
return oemRequest(token, `/oem/users?${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {object} payload
|
||||||
|
*/
|
||||||
|
export function createOemUserApi(token, payload) {
|
||||||
|
return oemRequest(token, "/oem/users", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {number} userId
|
||||||
|
* @param {object} payload
|
||||||
|
*/
|
||||||
|
export function updateOemUserApi(token, userId, payload) {
|
||||||
|
return oemRequest(token, `/oem/users/${userId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} token @param {number} userId */
|
||||||
|
export function deleteOemUserApi(token, userId) {
|
||||||
|
return oemRequest(token, `/oem/users/${userId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,24 +1,46 @@
|
|||||||
<script setup>
|
<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 { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { useAuthStore } from "../stores/auth.js";
|
||||||
import { fetchSoftwareInfo } from "../api/oemSoftwareInfo.js";
|
import { fetchSoftwareInfo } from "../api/oemSoftwareInfo.js";
|
||||||
import { applyWindowBranding } from "../services/windowBranding.js";
|
import { applyWindowBranding } from "../services/windowBranding.js";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
const software = ref({
|
const software = ref({
|
||||||
softwareName: "",
|
softwareName: "",
|
||||||
logoUrl: null,
|
logoUrl: null,
|
||||||
wechat: null,
|
wechat: null,
|
||||||
|
wechatQrcode: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const isMaximized = ref(false);
|
const isMaximized = ref(false);
|
||||||
|
const wechatQrVisible = ref(false);
|
||||||
|
const loggingOut = ref(false);
|
||||||
|
|
||||||
let unlistenResize = null;
|
let unlistenResize = null;
|
||||||
|
|
||||||
|
const displayUsername = computed(
|
||||||
|
() => auth.currentUser?.username?.trim() || "—",
|
||||||
|
);
|
||||||
|
|
||||||
|
const vipEndLabel = computed(() => formatVipEnd(auth.currentUser?.vip_end_time));
|
||||||
|
|
||||||
function isTauri() {
|
function isTauri() {
|
||||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
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() {
|
async function syncMaximizedState() {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) return;
|
||||||
try {
|
try {
|
||||||
@@ -37,6 +59,11 @@ async function onMinimize() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openWechatQr() {
|
||||||
|
if (!software.value.wechatQrcode) return;
|
||||||
|
wechatQrVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
async function onToggleMaximize() {
|
async function onToggleMaximize() {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) return;
|
||||||
try {
|
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 () => {
|
onMounted(async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
software.value = await fetchSoftwareInfo();
|
software.value = await fetchSoftwareInfo();
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
await applyWindowBranding(software.value);
|
await applyWindowBranding(software.value);
|
||||||
|
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
await auth.refreshProfile();
|
||||||
|
}
|
||||||
|
|
||||||
await syncMaximizedState();
|
await syncMaximizedState();
|
||||||
if (isTauri()) {
|
if (isTauri()) {
|
||||||
try {
|
try {
|
||||||
@@ -84,13 +123,18 @@ onUnmounted(() => {
|
|||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
>
|
>
|
||||||
<template v-if="!loading">
|
<template v-if="!loading">
|
||||||
<img
|
<div
|
||||||
v-if="software.logoUrl"
|
v-if="software.logoUrl"
|
||||||
:src="software.logoUrl"
|
class="app-titlebar__logo-wrap"
|
||||||
alt="软件 logo"
|
|
||||||
class="app-titlebar__logo w-14 shrink-0 object-contain"
|
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
/>
|
>
|
||||||
|
<img
|
||||||
|
:src="software.logoUrl"
|
||||||
|
alt="软件 logo"
|
||||||
|
class="app-titlebar__logo"
|
||||||
|
data-tauri-drag-region
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span
|
<span
|
||||||
v-if="software.softwareName"
|
v-if="software.softwareName"
|
||||||
class="app-titlebar__name truncate text-xs font-medium text-slate-200"
|
class="app-titlebar__name truncate text-xs font-medium text-slate-200"
|
||||||
@@ -105,14 +149,16 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
aiclient
|
aiclient
|
||||||
</span>
|
</span>
|
||||||
<span class="min-w-2 flex-1" data-tauri-drag-region />
|
<button
|
||||||
<span
|
|
||||||
v-if="software.wechat"
|
v-if="software.wechat"
|
||||||
|
type="button"
|
||||||
class="app-titlebar__wechat shrink-0 truncate text-xs text-text-muted"
|
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 }}
|
客服微信:{{ software.wechat }}
|
||||||
</span>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
@@ -121,6 +167,28 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
aiclient
|
aiclient
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<div class="app-titlebar__controls flex shrink-0 items-stretch">
|
<div class="app-titlebar__controls flex shrink-0 items-stretch">
|
||||||
@@ -147,4 +215,24 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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>
|
</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 = [
|
const menuItems = [
|
||||||
|
|
||||||
{ name: "admin-users", label: "用户管理", to: "/admin/users" },
|
{ name: "oem-users", label: "用户管理", to: "/oem/users" },
|
||||||
{ name: "admin-card-keys", label: "卡密管理", to: "/admin/card-keys" },
|
{ name: "oem-card-keys", label: "卡密管理", to: "/oem/card-keys" },
|
||||||
{ name: "admin-desktop-config", label: "桌面配置", to: "/admin/desktop-config" },
|
{ name: "oem-desktop-config", label: "桌面配置", to: "/oem/desktop-config" },
|
||||||
{ name: "admin-overview", label: "软件配置", to: "/admin" },
|
{ name: "oem-overview", label: "软件配置", to: "/oem" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeName = computed(() => route.name);
|
const activeName = computed(() => route.name);
|
||||||
|
|||||||
@@ -376,7 +376,8 @@ router.beforeEach((to) => {
|
|||||||
.find((role) => role != null);
|
.find((role) => role != null);
|
||||||
|
|
||||||
|
|
||||||
|
console.log('requiredRole',requiredRole);
|
||||||
|
console.log('auth.roleId',auth.roleId);
|
||||||
if (requiredRole != null && auth.roleId !== requiredRole) {
|
if (requiredRole != null && auth.roleId !== requiredRole) {
|
||||||
|
|
||||||
return { name: "home" };
|
return { name: "home" };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { loginApi, logoutApi, registerApi } from "../api/auth.js";
|
import { fetchMeApi, loginApi, logoutApi, registerApi } from "../api/auth.js";
|
||||||
|
|
||||||
const TOKEN_KEY = "aiclient_token";
|
const TOKEN_KEY = "aiclient_token";
|
||||||
const USER_KEY = "aiclient_current_user";
|
const USER_KEY = "aiclient_current_user";
|
||||||
@@ -37,6 +37,7 @@ function applyTokenResponse(store, res, fallbackMessage) {
|
|||||||
id: data.user.id,
|
id: data.user.id,
|
||||||
username: data.user.username,
|
username: data.user.username,
|
||||||
role_id: data.user.role_id,
|
role_id: data.user.role_id,
|
||||||
|
vip_end_time: data.user.vip_end_time ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ok: true, message: res.message || fallbackMessage };
|
return { ok: true, message: res.message || fallbackMessage };
|
||||||
@@ -120,6 +121,24 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
return applyTokenResponse(this, res, "登录成功");
|
return applyTokenResponse(this, res, "登录成功");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async refreshProfile() {
|
||||||
|
if (!this.token) return { ok: false, message: "未登录" };
|
||||||
|
const res = await fetchMeApi(this.token);
|
||||||
|
if (!res.ok || !res.data) {
|
||||||
|
return { ok: false, message: res.message || "获取用户信息失败" };
|
||||||
|
}
|
||||||
|
const u = res.data;
|
||||||
|
this.currentUser = {
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
role_id: u.role_id,
|
||||||
|
vip_end_time: u.vip_end_time ?? null,
|
||||||
|
};
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(this.currentUser));
|
||||||
|
syncAuthSessionToRust(this.token, this.currentUser);
|
||||||
|
return { ok: true, message: "" };
|
||||||
|
},
|
||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
const token = this.token;
|
const token = this.token;
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|||||||
@@ -334,7 +334,23 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-titlebar__logo-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
align-self: center;
|
||||||
|
height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.app-titlebar__logo {
|
.app-titlebar__logo {
|
||||||
|
display: block;
|
||||||
|
max-height: 2rem;
|
||||||
|
max-width: 3.5rem;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +360,36 @@ body {
|
|||||||
|
|
||||||
.app-titlebar__wechat {
|
.app-titlebar__wechat {
|
||||||
max-width: 12rem;
|
max-width: 12rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-titlebar__wechat--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-titlebar__wechat--clickable:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-titlebar__user {
|
||||||
|
max-width: 28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-titlebar__username {
|
||||||
|
max-width: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-titlebar__vip {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-titlebar__logout {
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-titlebar__controls {
|
.app-titlebar__controls {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import OemSubNav from "../components/oem/OemSubNav.vue";
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="admin-layout">
|
<div class="admin-layout">
|
||||||
<AdminSubNav />
|
<OemSubNav />
|
||||||
<div class="admin-layout__content custom-scrollbar">
|
<div class="admin-layout__content custom-scrollbar">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { useAuthStore } from "../../stores/auth.js";
|
import { useAuthStore, ROLE_AGENT, ROLE_OEM } from "../../stores/auth.js";
|
||||||
import {
|
import {
|
||||||
listAdminCardKeysApi,
|
listAdminCardKeysApi,
|
||||||
createAdminCardKeysApi,
|
createAdminCardKeysApi,
|
||||||
updateAdminCardKeyApi,
|
updateAdminCardKeyApi,
|
||||||
deleteAdminCardKeyApi,
|
deleteAdminCardKeyApi,
|
||||||
} from "../../api/adminCardKeys.js";
|
} from "../../api/adminCardKeys.js";
|
||||||
|
import { listAdminUsersApi } from "../../api/adminUsers.js";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
@@ -16,11 +17,24 @@ const STATUS_OPTIONS = [
|
|||||||
{ label: "已激活", value: "used" },
|
{ label: "已激活", value: "used" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ASSIGN_OPTIONS = [
|
||||||
|
{ label: "不指定", value: "none" },
|
||||||
|
{ label: "分配给 OEM", value: "oem" },
|
||||||
|
{ label: "分配给代理", value: "agent" },
|
||||||
|
];
|
||||||
|
|
||||||
const cards = ref([]);
|
const cards = ref([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(20);
|
const pageSize = ref(20);
|
||||||
const statusFilter = ref("all");
|
const statusFilter = ref("all");
|
||||||
|
const oemOptions = ref([]);
|
||||||
|
const agentOptions = ref([]);
|
||||||
|
const filters = ref({
|
||||||
|
username: "",
|
||||||
|
oem_id: null,
|
||||||
|
agent_id: null,
|
||||||
|
});
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const feedback = ref({ severity: "", message: "" });
|
const feedback = ref({ severity: "", message: "" });
|
||||||
@@ -34,6 +48,8 @@ const emptyForm = () => ({
|
|||||||
duration_days: 30,
|
duration_days: 30,
|
||||||
count: 1,
|
count: 1,
|
||||||
remark: "",
|
remark: "",
|
||||||
|
assign_type: "none",
|
||||||
|
assign_id: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = ref(emptyForm());
|
const form = ref(emptyForm());
|
||||||
@@ -45,6 +61,24 @@ const dialogTitle = computed(() =>
|
|||||||
const isActivated = computed(() => dialogMode.value === "edit" && !!editingActivatedAt.value);
|
const isActivated = computed(() => dialogMode.value === "edit" && !!editingActivatedAt.value);
|
||||||
const editingActivatedAt = ref(null);
|
const editingActivatedAt = ref(null);
|
||||||
|
|
||||||
|
const ownerNameById = computed(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const u of oemOptions.value) map.set(u.id, u.username);
|
||||||
|
for (const u of agentOptions.value) map.set(u.id, u.username);
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignSelectOptions = computed(() => {
|
||||||
|
if (form.value.assign_type === "oem") return oemOptions.value;
|
||||||
|
if (form.value.assign_type === "agent") return agentOptions.value;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
function ownerLabel(userId) {
|
||||||
|
if (userId == null) return "—";
|
||||||
|
return ownerNameById.value.get(userId) ?? `#${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value) {
|
function formatDateTime(value) {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
@@ -56,6 +90,19 @@ function showMessage(severity, message) {
|
|||||||
feedback.value = { severity, message };
|
feedback.value = { severity, message };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadFilterOptions() {
|
||||||
|
const [oemRes, agentRes] = await Promise.all([
|
||||||
|
listAdminUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_OEM }),
|
||||||
|
listAdminUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_AGENT }),
|
||||||
|
]);
|
||||||
|
if (oemRes.ok && Array.isArray(oemRes.data?.items)) {
|
||||||
|
oemOptions.value = oemRes.data.items;
|
||||||
|
}
|
||||||
|
if (agentRes.ok && Array.isArray(agentRes.data?.items)) {
|
||||||
|
agentOptions.value = agentRes.data.items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCards() {
|
async function loadCards() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
feedback.value = { severity: "", message: "" };
|
feedback.value = { severity: "", message: "" };
|
||||||
@@ -63,6 +110,9 @@ async function loadCards() {
|
|||||||
page: page.value,
|
page: page.value,
|
||||||
pageSize: pageSize.value,
|
pageSize: pageSize.value,
|
||||||
status: statusFilter.value,
|
status: statusFilter.value,
|
||||||
|
username: filters.value.username,
|
||||||
|
oemId: filters.value.oem_id,
|
||||||
|
agentId: filters.value.agent_id,
|
||||||
});
|
});
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|
||||||
@@ -89,6 +139,17 @@ function onStatusChange() {
|
|||||||
loadCards();
|
loadCards();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applyFilters() {
|
||||||
|
page.value = 1;
|
||||||
|
await loadCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetFilters() {
|
||||||
|
filters.value = { username: "", oem_id: null, agent_id: null };
|
||||||
|
page.value = 1;
|
||||||
|
await loadCards();
|
||||||
|
}
|
||||||
|
|
||||||
function openCreateDialog() {
|
function openCreateDialog() {
|
||||||
dialogMode.value = "create";
|
dialogMode.value = "create";
|
||||||
editingId.value = null;
|
editingId.value = null;
|
||||||
@@ -106,6 +167,8 @@ function openEditDialog(row) {
|
|||||||
duration_days: row.duration_days,
|
duration_days: row.duration_days,
|
||||||
count: 1,
|
count: 1,
|
||||||
remark: row.remark || "",
|
remark: row.remark || "",
|
||||||
|
assign_type: "none",
|
||||||
|
assign_id: null,
|
||||||
};
|
};
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
}
|
}
|
||||||
@@ -119,6 +182,16 @@ async function copySerial(serial) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAssignPayload() {
|
||||||
|
if (form.value.assign_type === "oem") {
|
||||||
|
return { oem_id: form.value.assign_id, agent_id: null };
|
||||||
|
}
|
||||||
|
if (form.value.assign_type === "agent") {
|
||||||
|
return { oem_id: null, agent_id: form.value.assign_id };
|
||||||
|
}
|
||||||
|
return { oem_id: null, agent_id: null };
|
||||||
|
}
|
||||||
|
|
||||||
async function saveCard() {
|
async function saveCard() {
|
||||||
if (form.value.duration_days < 1) {
|
if (form.value.duration_days < 1) {
|
||||||
showMessage("warn", "时长至少 1 天");
|
showMessage("warn", "时长至少 1 天");
|
||||||
@@ -130,10 +203,16 @@ async function saveCard() {
|
|||||||
|
|
||||||
let res;
|
let res;
|
||||||
if (dialogMode.value === "create") {
|
if (dialogMode.value === "create") {
|
||||||
|
if (form.value.assign_type !== "none" && form.value.assign_id == null) {
|
||||||
|
saving.value = false;
|
||||||
|
showMessage("warn", "请选择分配对象");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
duration_days: form.value.duration_days,
|
duration_days: form.value.duration_days,
|
||||||
count: form.value.count,
|
count: form.value.count,
|
||||||
remark: form.value.remark.trim() || null,
|
remark: form.value.remark.trim() || null,
|
||||||
|
...buildAssignPayload(),
|
||||||
};
|
};
|
||||||
const serial = form.value.serial_number.trim();
|
const serial = form.value.serial_number.trim();
|
||||||
if (serial) {
|
if (serial) {
|
||||||
@@ -188,8 +267,16 @@ async function removeCard(row) {
|
|||||||
|
|
||||||
watch(statusFilter, onStatusChange);
|
watch(statusFilter, onStatusChange);
|
||||||
|
|
||||||
onMounted(() => {
|
watch(
|
||||||
loadCards();
|
() => form.value.assign_type,
|
||||||
|
() => {
|
||||||
|
form.value.assign_id = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadFilterOptions();
|
||||||
|
await loadCards();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -198,7 +285,6 @@ onMounted(() => {
|
|||||||
<div class="admin-users__header">
|
<div class="admin-users__header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="admin-page__title gradient-text">卡密管理</h1>
|
<h1 class="admin-page__title gradient-text">卡密管理</h1>
|
||||||
<p class="text-sm text-text-muted">生成、查看与管理 VIP 激活卡密</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button label="生成卡密" @click="openCreateDialog" />
|
<Button label="生成卡密" @click="openCreateDialog" />
|
||||||
</div>
|
</div>
|
||||||
@@ -213,8 +299,34 @@ onMounted(() => {
|
|||||||
{{ feedback.message }}
|
{{ feedback.message }}
|
||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
<div class="admin-card-keys__toolbar">
|
<div class="admin-users__filters">
|
||||||
<label class="text-sm text-text-muted">状态筛选</label>
|
<InputText
|
||||||
|
v-model="filters.username"
|
||||||
|
placeholder="搜索使用者用户名"
|
||||||
|
class="admin-users__search"
|
||||||
|
@keyup.enter="applyFilters"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
v-model="filters.oem_id"
|
||||||
|
:options="oemOptions"
|
||||||
|
option-label="username"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="全部 OEM"
|
||||||
|
show-clear
|
||||||
|
filter
|
||||||
|
class="admin-users__filter-select"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
v-model="filters.agent_id"
|
||||||
|
:options="agentOptions"
|
||||||
|
option-label="username"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="全部代理"
|
||||||
|
show-clear
|
||||||
|
filter
|
||||||
|
class="admin-users__filter-select"
|
||||||
|
/>
|
||||||
|
<label class="text-sm text-text-muted">状态</label>
|
||||||
<Select
|
<Select
|
||||||
v-model="statusFilter"
|
v-model="statusFilter"
|
||||||
:options="STATUS_OPTIONS"
|
:options="STATUS_OPTIONS"
|
||||||
@@ -222,6 +334,8 @@ onMounted(() => {
|
|||||||
option-value="value"
|
option-value="value"
|
||||||
class="admin-card-keys__status-select"
|
class="admin-card-keys__status-select"
|
||||||
/>
|
/>
|
||||||
|
<Button label="搜索" @click="applyFilters" />
|
||||||
|
<Button label="重置" severity="secondary" text @click="resetFilters" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -256,6 +370,16 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="duration_days" header="时长(天)" style="width: 6rem" />
|
<Column field="duration_days" header="时长(天)" style="width: 6rem" />
|
||||||
|
<Column header="OEM" style="width: 7rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ ownerLabel(data.oem_id) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="代理" style="width: 7rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ ownerLabel(data.agent_id) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
<Column field="created_at" header="创建时间">
|
<Column field="created_at" header="创建时间">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ formatDateTime(data.created_at) }}
|
{{ formatDateTime(data.created_at) }}
|
||||||
@@ -332,6 +456,30 @@ onMounted(() => {
|
|||||||
:disabled="form.count > 1"
|
:disabled="form.count > 1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">预分配</label>
|
||||||
|
<Select
|
||||||
|
v-model="form.assign_type"
|
||||||
|
:options="ASSIGN_OPTIONS"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="form.assign_type !== 'none'" class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">
|
||||||
|
{{ form.assign_type === "oem" ? "选择 OEM" : "选择代理" }}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
v-model="form.assign_id"
|
||||||
|
:options="assignSelectOptions"
|
||||||
|
option-label="username"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="请选择"
|
||||||
|
filter
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -1,14 +1,142 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useAuthStore } from "../../stores/auth";
|
import { onMounted, ref } from "vue";
|
||||||
|
import { useAuthStore } from "../../stores/auth.js";
|
||||||
|
import AdminImageUpload from "../../components/admin/AdminImageUpload.vue";
|
||||||
|
import {
|
||||||
|
loadAdminAppBrandingApi,
|
||||||
|
saveAdminAppBrandingApi,
|
||||||
|
} from "../../api/adminAppBranding.js";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
softwareName: "",
|
||||||
|
logo: "",
|
||||||
|
contactWechat: "",
|
||||||
|
contactQq: "",
|
||||||
|
wechatQr: "",
|
||||||
|
qqQr: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const feedback = ref({ severity: "", message: "" });
|
||||||
|
|
||||||
|
function showMessage(severity, message) {
|
||||||
|
feedback.value = { severity, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageError(message) {
|
||||||
|
showMessage("warn", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
loading.value = true;
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
const res = await loadAdminAppBrandingApi(auth.token);
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage("error", res.message || "加载失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.value = { ...res.data.form };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
saving.value = true;
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
const res = await saveAdminAppBrandingApi(auth.token, form.value);
|
||||||
|
saving.value = false;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage("error", res.message || "保存失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showMessage("success", res.message || "已保存");
|
||||||
|
await loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSettings();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="admin-page">
|
<div class="admin-page admin-overview">
|
||||||
<h1 class="admin-page__title gradient-text">软件配置</h1>
|
<div class="admin-users__header">
|
||||||
<p class="text-sm text-text-muted">
|
<div>
|
||||||
欢迎,{{ auth.currentUser?.username }}
|
<h1 class="admin-page__title gradient-text">软件配置</h1>
|
||||||
</p>
|
<p class="text-sm text-text-muted">
|
||||||
|
欢迎,{{ auth.currentUser?.username }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button label="保存配置" :loading="saving" :disabled="loading" @click="saveSettings" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Message
|
||||||
|
v-if="feedback.message"
|
||||||
|
:severity="feedback.severity"
|
||||||
|
:closable="true"
|
||||||
|
class="admin-users__message"
|
||||||
|
@close="feedback.message = ''"
|
||||||
|
>
|
||||||
|
{{ feedback.message }}
|
||||||
|
</Message>
|
||||||
|
|
||||||
|
<div v-if="loading" class="py-12 text-center text-sm text-text-muted">加载中…</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-else
|
||||||
|
class="admin-overview__form"
|
||||||
|
@submit.prevent="saveSettings"
|
||||||
|
>
|
||||||
|
<div class="admin-overview__field">
|
||||||
|
<label class="admin-overview__label">软件名称</label>
|
||||||
|
<InputText
|
||||||
|
v-model="form.softwareName"
|
||||||
|
class="admin-overview__input"
|
||||||
|
placeholder="请输入软件名称"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminImageUpload
|
||||||
|
v-model="form.logo"
|
||||||
|
label="软件 logo"
|
||||||
|
@error="onImageError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="admin-overview__field">
|
||||||
|
<label class="admin-overview__label">联系微信</label>
|
||||||
|
<InputText
|
||||||
|
v-model="form.contactWechat"
|
||||||
|
class="admin-overview__input"
|
||||||
|
placeholder="微信号或联系方式"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-overview__field">
|
||||||
|
<label class="admin-overview__label">联系 QQ</label>
|
||||||
|
<InputText
|
||||||
|
v-model="form.contactQq"
|
||||||
|
class="admin-overview__input"
|
||||||
|
placeholder="QQ 号或联系方式"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminImageUpload
|
||||||
|
v-model="form.wechatQr"
|
||||||
|
label="微信二维码"
|
||||||
|
@error="onImageError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminImageUpload
|
||||||
|
v-model="form.qqQr"
|
||||||
|
label="QQ 二维码"
|
||||||
|
@error="onImageError"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT } from "../../stores/auth.js";
|
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT,ROLE_OEM } from "../../stores/auth.js";
|
||||||
import {
|
import {
|
||||||
listAdminUsersApi,
|
listOemUsersApi,
|
||||||
createAdminUserApi,
|
createOemUserApi,
|
||||||
updateAdminUserApi,
|
updateOemUserApi,
|
||||||
deleteAdminUserApi,
|
deleteOemUserApi,
|
||||||
} from "../../api/adminUsers.js";
|
} from "../../api/oemUsers.js";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
@@ -14,12 +14,14 @@ const ROLE_OPTIONS = [
|
|||||||
{ label: "普通用户", value: 0 },
|
{ label: "普通用户", value: 0 },
|
||||||
{ label: "管理员", value: 1 },
|
{ label: "管理员", value: 1 },
|
||||||
{ label: "代理", value: 2 },
|
{ label: "代理", value: 2 },
|
||||||
|
{ label: "OEM", value: 3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ROLE_LABELS = {
|
const ROLE_LABELS = {
|
||||||
0: "普通用户",
|
0: "普通用户",
|
||||||
[ROLE_ADMIN]: "管理员",
|
[ROLE_ADMIN]: "管理员",
|
||||||
[ROLE_AGENT]: "代理",
|
[ROLE_AGENT]: "代理",
|
||||||
|
[ROLE_OEM]: "OEM",
|
||||||
};
|
};
|
||||||
|
|
||||||
const users = ref([]);
|
const users = ref([]);
|
||||||
@@ -27,6 +29,14 @@ const total = ref(0);
|
|||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(20);
|
const pageSize = ref(20);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const oemOptions = ref([]);
|
||||||
|
const agentOptions = ref([]);
|
||||||
|
|
||||||
|
const filters = ref({
|
||||||
|
username: "",
|
||||||
|
oem_id: null,
|
||||||
|
agent_id: null,
|
||||||
|
});
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const feedback = ref({ severity: "", message: "" });
|
const feedback = ref({ severity: "", message: "" });
|
||||||
|
|
||||||
@@ -71,19 +81,48 @@ function roleLabel(roleId) {
|
|||||||
function roleSeverity(roleId) {
|
function roleSeverity(roleId) {
|
||||||
if (roleId === ROLE_ADMIN) return "info";
|
if (roleId === ROLE_ADMIN) return "info";
|
||||||
if (roleId === ROLE_AGENT) return "warn";
|
if (roleId === ROLE_AGENT) return "warn";
|
||||||
|
if (roleId === ROLE_OEM) return "success";
|
||||||
return "secondary";
|
return "secondary";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ownerNameById = computed(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const u of oemOptions.value) map.set(u.id, u.username);
|
||||||
|
for (const u of agentOptions.value) map.set(u.id, u.username);
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
function ownerLabel(userId) {
|
||||||
|
if (userId == null) return "—";
|
||||||
|
return ownerNameById.value.get(userId) ?? `#${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
function showMessage(severity, message) {
|
function showMessage(severity, message) {
|
||||||
feedback.value = { severity, message };
|
feedback.value = { severity, message };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadFilterOptions() {
|
||||||
|
const [oemRes, agentRes] = await Promise.all([
|
||||||
|
listOemUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_OEM }),
|
||||||
|
listOemUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_AGENT }),
|
||||||
|
]);
|
||||||
|
if (oemRes.ok && Array.isArray(oemRes.data?.items)) {
|
||||||
|
oemOptions.value = oemRes.data.items;
|
||||||
|
}
|
||||||
|
if (agentRes.ok && Array.isArray(agentRes.data?.items)) {
|
||||||
|
agentOptions.value = agentRes.data.items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
feedback.value = { severity: "", message: "" };
|
feedback.value = { severity: "", message: "" };
|
||||||
const res = await listAdminUsersApi(auth.token, {
|
const res = await listOemUsersApi(auth.token, {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
pageSize: pageSize.value,
|
pageSize: pageSize.value,
|
||||||
|
username: filters.value.username,
|
||||||
|
oemId: filters.value.oem_id,
|
||||||
|
agentId: filters.value.agent_id,
|
||||||
});
|
});
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|
||||||
@@ -105,6 +144,17 @@ async function onPage(event) {
|
|||||||
await loadUsers();
|
await loadUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applyFilters() {
|
||||||
|
page.value = 1;
|
||||||
|
await loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetFilters() {
|
||||||
|
filters.value = { username: "", oem_id: null, agent_id: null };
|
||||||
|
page.value = 1;
|
||||||
|
await loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
function openCreateDialog() {
|
function openCreateDialog() {
|
||||||
dialogMode.value = "create";
|
dialogMode.value = "create";
|
||||||
editingId.value = null;
|
editingId.value = null;
|
||||||
@@ -168,9 +218,9 @@ async function saveUser() {
|
|||||||
showMessage("warn", "请设置初始密码");
|
showMessage("warn", "请设置初始密码");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res = await createAdminUserApi(auth.token, payload);
|
res = await createOemUserApi(auth.token, payload);
|
||||||
} else {
|
} else {
|
||||||
res = await updateAdminUserApi(auth.token, editingId.value, payload);
|
res = await updateOemUserApi(auth.token, editingId.value, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
@@ -194,7 +244,7 @@ async function removeUser(row) {
|
|||||||
const ok = window.confirm(`确定删除用户「${row.username}」?此操作不可恢复。`);
|
const ok = window.confirm(`确定删除用户「${row.username}」?此操作不可恢复。`);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
const res = await deleteAdminUserApi(auth.token, row.id);
|
const res = await deleteOemUserApi(auth.token, row.id);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
showMessage("error", res.message || "删除失败");
|
showMessage("error", res.message || "删除失败");
|
||||||
return;
|
return;
|
||||||
@@ -207,8 +257,9 @@ async function removeUser(row) {
|
|||||||
await loadUsers();
|
await loadUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
loadUsers();
|
await loadFilterOptions();
|
||||||
|
await loadUsers();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -217,11 +268,32 @@ onMounted(() => {
|
|||||||
<div class="admin-users__header">
|
<div class="admin-users__header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="admin-page__title gradient-text">用户管理</h1>
|
<h1 class="admin-page__title gradient-text">用户管理</h1>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<Button label="新建用户" @click="openCreateDialog" />
|
<Button label="新建用户" @click="openCreateDialog" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-users__filters">
|
||||||
|
<InputText
|
||||||
|
v-model="filters.username"
|
||||||
|
placeholder="搜索用户名"
|
||||||
|
class="admin-users__search"
|
||||||
|
@keyup.enter="applyFilters"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
v-model="filters.agent_id"
|
||||||
|
:options="agentOptions"
|
||||||
|
option-label="username"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="全部代理"
|
||||||
|
show-clear
|
||||||
|
filter
|
||||||
|
class="admin-users__filter-select"
|
||||||
|
/>
|
||||||
|
<Button label="搜索" @click="applyFilters" />
|
||||||
|
<Button label="重置" severity="secondary" text @click="resetFilters" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Message
|
<Message
|
||||||
v-if="feedback.message"
|
v-if="feedback.message"
|
||||||
:severity="feedback.severity"
|
:severity="feedback.severity"
|
||||||
@@ -261,6 +333,16 @@ onMounted(() => {
|
|||||||
<Tag :value="roleLabel(data.role_id)" :severity="roleSeverity(data.role_id)" />
|
<Tag :value="roleLabel(data.role_id)" :severity="roleSeverity(data.role_id)" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
<Column field="oem_id" header="OEM" style="width: 8rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ ownerLabel(data.oem_id) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="agent_id" header="代理" style="width: 8rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ ownerLabel(data.agent_id) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
<Column field="vip_end_time" header="VIP 到期">
|
<Column field="vip_end_time" header="VIP 到期">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ formatDateTime(data.vip_end_time) }}
|
{{ formatDateTime(data.vip_end_time) }}
|
||||||
|
|||||||
Reference in New Issue
Block a user