11
This commit is contained in:
@@ -19,6 +19,7 @@ pub struct SoftwareInfo {
|
||||
pub software_name: String,
|
||||
pub logo_url: Option<String>,
|
||||
pub wechat: Option<String>,
|
||||
pub wechat_qrcode_url: Option<String>,
|
||||
}
|
||||
|
||||
fn empty_info(oem_id: i64) -> SoftwareInfo {
|
||||
@@ -27,6 +28,7 @@ fn empty_info(oem_id: i64) -> SoftwareInfo {
|
||||
software_name: String::new(),
|
||||
logo_url: 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())
|
||||
.filter(|s| !s.is_empty())
|
||||
.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 */
|
||||
export function logoutApi(token) {
|
||||
return apiRequest("/auth/logout", {
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
/**
|
||||
* 经 Rust 读取本地 oem 文件中的 ID,并请求公开品牌 API。
|
||||
* @returns {Promise<{
|
||||
* oemId: number,
|
||||
* softwareName: string,
|
||||
* logoUrl: string | null,
|
||||
* wechat: string | null,
|
||||
* wechatQrcode: string | null,
|
||||
* }>}
|
||||
*/
|
||||
export async function fetchSoftwareInfo() {
|
||||
const empty = {
|
||||
oemId: 1,
|
||||
softwareName: "",
|
||||
logoUrl: null,
|
||||
wechat: null,
|
||||
wechatQrcode: null,
|
||||
};
|
||||
try {
|
||||
const data = await invoke("get_software_info");
|
||||
if (!data || typeof data !== "object") {
|
||||
return { oemId: 1, softwareName: "", logoUrl: null, wechat: null };
|
||||
return empty;
|
||||
}
|
||||
return {
|
||||
oemId: data.oemId ?? 1,
|
||||
softwareName: data.softwareName ?? "",
|
||||
logoUrl: data.logoUrl || null,
|
||||
wechat: data.wechat || null,
|
||||
wechatQrcode: data.wechatQrcodeUrl || null,
|
||||
};
|
||||
} 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>
|
||||
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);
|
||||
|
||||
@@ -376,7 +376,8 @@ router.beforeEach((to) => {
|
||||
.find((role) => role != null);
|
||||
|
||||
|
||||
|
||||
console.log('requiredRole',requiredRole);
|
||||
console.log('auth.roleId',auth.roleId);
|
||||
if (requiredRole != null && auth.roleId !== requiredRole) {
|
||||
|
||||
return { name: "home" };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from "pinia";
|
||||
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 USER_KEY = "aiclient_current_user";
|
||||
@@ -37,6 +37,7 @@ function applyTokenResponse(store, res, fallbackMessage) {
|
||||
id: data.user.id,
|
||||
username: data.user.username,
|
||||
role_id: data.user.role_id,
|
||||
vip_end_time: data.user.vip_end_time ?? null,
|
||||
});
|
||||
|
||||
return { ok: true, message: res.message || fallbackMessage };
|
||||
@@ -120,6 +121,24 @@ export const useAuthStore = defineStore("auth", {
|
||||
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() {
|
||||
const token = this.token;
|
||||
if (token) {
|
||||
|
||||
@@ -334,7 +334,23 @@ body {
|
||||
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 {
|
||||
display: block;
|
||||
max-height: 2rem;
|
||||
max-width: 3.5rem;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -344,6 +360,36 @@ body {
|
||||
|
||||
.app-titlebar__wechat {
|
||||
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 {
|
||||
|
||||
@@ -4,7 +4,7 @@ import OemSubNav from "../components/oem/OemSubNav.vue";
|
||||
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<AdminSubNav />
|
||||
<OemSubNav />
|
||||
<div class="admin-layout__content custom-scrollbar">
|
||||
<RouterView />
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useAuthStore } from "../../stores/auth.js";
|
||||
import { useAuthStore, ROLE_AGENT, ROLE_OEM } from "../../stores/auth.js";
|
||||
import {
|
||||
listAdminCardKeysApi,
|
||||
createAdminCardKeysApi,
|
||||
updateAdminCardKeyApi,
|
||||
deleteAdminCardKeyApi,
|
||||
} from "../../api/adminCardKeys.js";
|
||||
import { listAdminUsersApi } from "../../api/adminUsers.js";
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
@@ -16,11 +17,24 @@ const STATUS_OPTIONS = [
|
||||
{ label: "已激活", value: "used" },
|
||||
];
|
||||
|
||||
const ASSIGN_OPTIONS = [
|
||||
{ label: "不指定", value: "none" },
|
||||
{ label: "分配给 OEM", value: "oem" },
|
||||
{ label: "分配给代理", value: "agent" },
|
||||
];
|
||||
|
||||
const cards = ref([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
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 saving = ref(false);
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
@@ -34,6 +48,8 @@ const emptyForm = () => ({
|
||||
duration_days: 30,
|
||||
count: 1,
|
||||
remark: "",
|
||||
assign_type: "none",
|
||||
assign_id: null,
|
||||
});
|
||||
|
||||
const form = ref(emptyForm());
|
||||
@@ -45,6 +61,24 @@ const dialogTitle = computed(() =>
|
||||
const isActivated = computed(() => dialogMode.value === "edit" && !!editingActivatedAt.value);
|
||||
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) {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
@@ -56,6 +90,19 @@ function showMessage(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() {
|
||||
loading.value = true;
|
||||
feedback.value = { severity: "", message: "" };
|
||||
@@ -63,6 +110,9 @@ async function loadCards() {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
status: statusFilter.value,
|
||||
username: filters.value.username,
|
||||
oemId: filters.value.oem_id,
|
||||
agentId: filters.value.agent_id,
|
||||
});
|
||||
loading.value = false;
|
||||
|
||||
@@ -89,6 +139,17 @@ function onStatusChange() {
|
||||
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() {
|
||||
dialogMode.value = "create";
|
||||
editingId.value = null;
|
||||
@@ -106,6 +167,8 @@ function openEditDialog(row) {
|
||||
duration_days: row.duration_days,
|
||||
count: 1,
|
||||
remark: row.remark || "",
|
||||
assign_type: "none",
|
||||
assign_id: null,
|
||||
};
|
||||
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() {
|
||||
if (form.value.duration_days < 1) {
|
||||
showMessage("warn", "时长至少 1 天");
|
||||
@@ -130,10 +203,16 @@ async function saveCard() {
|
||||
|
||||
let res;
|
||||
if (dialogMode.value === "create") {
|
||||
if (form.value.assign_type !== "none" && form.value.assign_id == null) {
|
||||
saving.value = false;
|
||||
showMessage("warn", "请选择分配对象");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
duration_days: form.value.duration_days,
|
||||
count: form.value.count,
|
||||
remark: form.value.remark.trim() || null,
|
||||
...buildAssignPayload(),
|
||||
};
|
||||
const serial = form.value.serial_number.trim();
|
||||
if (serial) {
|
||||
@@ -188,8 +267,16 @@ async function removeCard(row) {
|
||||
|
||||
watch(statusFilter, onStatusChange);
|
||||
|
||||
onMounted(() => {
|
||||
loadCards();
|
||||
watch(
|
||||
() => form.value.assign_type,
|
||||
() => {
|
||||
form.value.assign_id = null;
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFilterOptions();
|
||||
await loadCards();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -198,7 +285,6 @@ onMounted(() => {
|
||||
<div class="admin-users__header">
|
||||
<div>
|
||||
<h1 class="admin-page__title gradient-text">卡密管理</h1>
|
||||
<p class="text-sm text-text-muted">生成、查看与管理 VIP 激活卡密</p>
|
||||
</div>
|
||||
<Button label="生成卡密" @click="openCreateDialog" />
|
||||
</div>
|
||||
@@ -213,8 +299,34 @@ onMounted(() => {
|
||||
{{ feedback.message }}
|
||||
</Message>
|
||||
|
||||
<div class="admin-card-keys__toolbar">
|
||||
<label class="text-sm text-text-muted">状态筛选</label>
|
||||
<div class="admin-users__filters">
|
||||
<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
|
||||
v-model="statusFilter"
|
||||
:options="STATUS_OPTIONS"
|
||||
@@ -222,6 +334,8 @@ onMounted(() => {
|
||||
option-value="value"
|
||||
class="admin-card-keys__status-select"
|
||||
/>
|
||||
<Button label="搜索" @click="applyFilters" />
|
||||
<Button label="重置" severity="secondary" text @click="resetFilters" />
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
@@ -256,6 +370,16 @@ onMounted(() => {
|
||||
</template>
|
||||
</Column>
|
||||
<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="创建时间">
|
||||
<template #body="{ data }">
|
||||
{{ formatDateTime(data.created_at) }}
|
||||
@@ -332,6 +456,30 @@ onMounted(() => {
|
||||
:disabled="form.count > 1"
|
||||
/>
|
||||
</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 v-else>
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
@@ -1,14 +1,142 @@
|
||||
<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 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>
|
||||
|
||||
<template>
|
||||
<div class="admin-page">
|
||||
<h1 class="admin-page__title gradient-text">软件配置</h1>
|
||||
<p class="text-sm text-text-muted">
|
||||
欢迎,{{ auth.currentUser?.username }}
|
||||
</p>
|
||||
<div class="admin-page admin-overview">
|
||||
<div class="admin-users__header">
|
||||
<div>
|
||||
<h1 class="admin-page__title gradient-text">软件配置</h1>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup>
|
||||
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 {
|
||||
listAdminUsersApi,
|
||||
createAdminUserApi,
|
||||
updateAdminUserApi,
|
||||
deleteAdminUserApi,
|
||||
} from "../../api/adminUsers.js";
|
||||
listOemUsersApi,
|
||||
createOemUserApi,
|
||||
updateOemUserApi,
|
||||
deleteOemUserApi,
|
||||
} from "../../api/oemUsers.js";
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
@@ -14,12 +14,14 @@ const ROLE_OPTIONS = [
|
||||
{ label: "普通用户", value: 0 },
|
||||
{ label: "管理员", value: 1 },
|
||||
{ label: "代理", value: 2 },
|
||||
{ label: "OEM", value: 3 },
|
||||
];
|
||||
|
||||
const ROLE_LABELS = {
|
||||
0: "普通用户",
|
||||
[ROLE_ADMIN]: "管理员",
|
||||
[ROLE_AGENT]: "代理",
|
||||
[ROLE_OEM]: "OEM",
|
||||
};
|
||||
|
||||
const users = ref([]);
|
||||
@@ -27,6 +29,14 @@ const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
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 feedback = ref({ severity: "", message: "" });
|
||||
|
||||
@@ -71,19 +81,48 @@ function roleLabel(roleId) {
|
||||
function roleSeverity(roleId) {
|
||||
if (roleId === ROLE_ADMIN) return "info";
|
||||
if (roleId === ROLE_AGENT) return "warn";
|
||||
if (roleId === ROLE_OEM) return "success";
|
||||
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) {
|
||||
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() {
|
||||
loading.value = true;
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const res = await listAdminUsersApi(auth.token, {
|
||||
const res = await listOemUsersApi(auth.token, {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
username: filters.value.username,
|
||||
oemId: filters.value.oem_id,
|
||||
agentId: filters.value.agent_id,
|
||||
});
|
||||
loading.value = false;
|
||||
|
||||
@@ -105,6 +144,17 @@ async function onPage(event) {
|
||||
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() {
|
||||
dialogMode.value = "create";
|
||||
editingId.value = null;
|
||||
@@ -168,9 +218,9 @@ async function saveUser() {
|
||||
showMessage("warn", "请设置初始密码");
|
||||
return;
|
||||
}
|
||||
res = await createAdminUserApi(auth.token, payload);
|
||||
res = await createOemUserApi(auth.token, payload);
|
||||
} else {
|
||||
res = await updateAdminUserApi(auth.token, editingId.value, payload);
|
||||
res = await updateOemUserApi(auth.token, editingId.value, payload);
|
||||
}
|
||||
|
||||
saving.value = false;
|
||||
@@ -194,7 +244,7 @@ async function removeUser(row) {
|
||||
const ok = window.confirm(`确定删除用户「${row.username}」?此操作不可恢复。`);
|
||||
if (!ok) return;
|
||||
|
||||
const res = await deleteAdminUserApi(auth.token, row.id);
|
||||
const res = await deleteOemUserApi(auth.token, row.id);
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "删除失败");
|
||||
return;
|
||||
@@ -207,8 +257,9 @@ async function removeUser(row) {
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers();
|
||||
onMounted(async () => {
|
||||
await loadFilterOptions();
|
||||
await loadUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -217,11 +268,32 @@ onMounted(() => {
|
||||
<div class="admin-users__header">
|
||||
<div>
|
||||
<h1 class="admin-page__title gradient-text">用户管理</h1>
|
||||
|
||||
</div>
|
||||
<Button label="新建用户" @click="openCreateDialog" />
|
||||
</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
|
||||
v-if="feedback.message"
|
||||
:severity="feedback.severity"
|
||||
@@ -261,6 +333,16 @@ onMounted(() => {
|
||||
<Tag :value="roleLabel(data.role_id)" :severity="roleSeverity(data.role_id)" />
|
||||
</template>
|
||||
</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 到期">
|
||||
<template #body="{ data }">
|
||||
{{ formatDateTime(data.vip_end_time) }}
|
||||
|
||||
Reference in New Issue
Block a user