This commit is contained in:
fengchuanhn@gmail.com
2026-05-21 02:40:08 +08:00
parent 14cf9b7bf5
commit 426a69cd69
14 changed files with 754 additions and 45 deletions

View File

@@ -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),
} }
} }

View File

@@ -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", {

View File

@@ -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
View 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",
});
}

View File

@@ -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"
class="app-titlebar__logo-wrap"
data-tauri-drag-region
>
<img
:src="software.logoUrl" :src="software.logoUrl"
alt="软件 logo" alt="软件 logo"
class="app-titlebar__logo w-14 shrink-0 object-contain" class="app-titlebar__logo"
data-tauri-drag-region 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>

View File

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

View File

@@ -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);

View File

@@ -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" };

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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">

View File

@@ -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">
<div class="admin-users__header">
<div>
<h1 class="admin-page__title gradient-text">软件配置</h1> <h1 class="admin-page__title gradient-text">软件配置</h1>
<p class="text-sm text-text-muted"> <p class="text-sm text-text-muted">
欢迎{{ auth.currentUser?.username }} 欢迎{{ auth.currentUser?.username }}
</p> </p>
</div> </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> </template>

View File

@@ -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) }}