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

View File

@@ -7,6 +7,10 @@
"core:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-is-maximized",
"core:event:default",
"core:event:allow-listen",
"core:event:allow-unlisten",

View File

@@ -8,3 +8,4 @@ pub mod fs_util;
pub mod nodejs;
pub mod publish;
pub mod quickjs;
pub mod software_info;

View File

@@ -0,0 +1,71 @@
//! 按本地 oem 文件中的 ID 拉取公开品牌信息。
use std::collections::HashMap;
use serde::Serialize;
use serde_json::Value;
use tauri::State;
use crate::api_client;
use crate::oem_context::OemContext;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SoftwareInfo {
pub oem_id: i64,
pub software_name: String,
pub logo_url: Option<String>,
pub wechat: Option<String>,
}
fn empty_info(oem_id: i64) -> SoftwareInfo {
SoftwareInfo {
oem_id,
software_name: String::new(),
logo_url: None,
wechat: None,
}
}
fn parse_software_info(oem_id: i64, value: &Value) -> SoftwareInfo {
let data = value.get("data").unwrap_or(value);
SoftwareInfo {
oem_id: data
.get("oem_id")
.and_then(|v| v.as_i64())
.unwrap_or(oem_id),
software_name: data
.get("software_name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
logo_url: data
.get("logo_url")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string),
wechat: data
.get("wechat")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string),
}
}
#[tauri::command]
pub async fn get_software_info(ctx: State<'_, OemContext>) -> Result<SoftwareInfo, String> {
let oem_id = ctx.oem_id;
let path = format!("/public/oem-branding/{oem_id}");
let (status, value) =
api_client::request_json("GET", &path, None, HashMap::new()).await?;
if status >= 400 {
return Ok(empty_info(oem_id));
}
if value.get("ok").and_then(|v| v.as_bool()) == Some(false) {
return Ok(empty_info(oem_id));
}
Ok(parse_software_info(oem_id, &value))
}

View File

@@ -17,6 +17,8 @@ pub mod ffmpeg;
pub mod http;
pub mod js_runtime;
pub mod nodejs;
pub mod oem_context;
pub mod oem_id;
pub mod oss;
pub mod publish_db;
pub mod publish_store;
@@ -36,6 +38,13 @@ pub fn run() {
.manage(publish_store::PublishStore::new())
.setup(|app| {
let handle = app.handle().clone();
let oem_id = handle
.path()
.app_data_dir()
.map(|dir| oem_id::read_oem_id_file(&dir.join(oem_id::OEM_FILE_NAME)))
.unwrap_or(oem_id::DEFAULT_OEM_ID);
app.manage(oem_context::OemContext { oem_id });
tauri::async_runtime::block_on(async move {
let app_config = handle.state::<app_config::AppConfig>();
let avatar_store = handle.state::<avatar_store::AvatarStore>();
@@ -68,6 +77,7 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
commands::api::api_request,
commands::software_info::get_software_info,
commands::auth::sync_auth_session,
commands::app_config::get_app_config,
commands::app_config::get_app_config_last_message,

View File

@@ -0,0 +1,6 @@
//! 启动时解析的 OEM 上下文。
#[derive(Debug, Clone)]
pub struct OemContext {
pub oem_id: i64,
}

67
src-tauri/src/oem_id.rs Normal file
View File

@@ -0,0 +1,67 @@
//! 从应用数据目录的 `oem` 文件读取 OEM 编号。
use std::path::Path;
use serde_json::Value;
pub const DEFAULT_OEM_ID: i64 = 1;
/// 应用数据目录下的 OEM 配置文件名。
pub const OEM_FILE_NAME: &str = "oem";
pub fn read_oem_id_file(path: &Path) -> i64 {
let content = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(_) => return DEFAULT_OEM_ID,
};
parse_oem_id_content(&content).unwrap_or(DEFAULT_OEM_ID)
}
fn parse_oem_id_content(content: &str) -> Option<i64> {
let trimmed = content.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
if let Some(id) = value.get("oem_id").and_then(|v| v.as_i64()) {
if id >= 1 {
return Some(id);
}
}
if let Some(id) = value.as_i64() {
if id >= 1 {
return Some(id);
}
}
}
let first_line = trimmed.lines().next()?.trim();
let id: i64 = first_line.parse().ok()?;
if id >= 1 {
Some(id)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_integer_line() {
assert_eq!(parse_oem_id_content("2\n"), Some(2));
}
#[test]
fn parse_json_object() {
assert_eq!(parse_oem_id_content(r#"{"oem_id":3}"#), Some(3));
}
#[test]
fn invalid_defaults_none() {
assert_eq!(parse_oem_id_content("abc"), None);
assert_eq!(parse_oem_id_content(""), None);
}
}

113
src/api/adminAppBranding.js Normal file
View File

@@ -0,0 +1,113 @@
import { apiRequest } from "./client.js";
/** 品牌静态资源基址(与 pythonbackend 根目录 images 挂载的 /images 一致) */
const MEDIA_BASE =
import.meta.env.VITE_MEDIA_BASE_URL || "http://127.0.0.1:8001";
/**
* @param {string} token
* @param {RequestInit} [options]
*/
function adminRequest(token, path, options = {}) {
return apiRequest(path, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...options.headers,
},
});
}
function emptyBrandingForm() {
return {
softwareName: "",
logo: "",
contactWechat: "",
contactQq: "",
wechatQr: "",
qqQr: "",
};
}
/**
* 库内路径 images/xxx → 可访问 URL预览用
* @param {string | null | undefined} path
*/
export function brandingImageUrl(path) {
if (!path?.trim()) return "";
const value = path.trim();
if (value.startsWith("data:image/") || value.startsWith("http")) return value;
const normalized = value.replace(/\\/g, "/").replace(/^\//, "");
if (normalized.startsWith("images/")) {
return `${MEDIA_BASE}/${normalized}`;
}
return `${MEDIA_BASE}/images/${normalized}`;
}
/** 提交前:新上传保留 data URL已有文件只提交 images/ 相对路径 */
function toStoredImageValue(value) {
if (!value?.trim()) return null;
const v = value.trim();
if (v.startsWith("data:image/")) return v;
const match = v.match(/\/images\/(.+?)(?:\?.*)?$/);
if (match) return `images/${match[1]}`;
if (v.startsWith("images/")) return v;
return v;
}
/** API 载荷 → 表单 */
function toForm(data) {
if (!data) return emptyBrandingForm();
return {
softwareName: data.software_name ?? "",
logo: brandingImageUrl(data.logo_path),
contactWechat: data.wechat ?? "",
contactQq: data.qq ?? "",
wechatQr: brandingImageUrl(data.wechat_qrcode),
qqQr: brandingImageUrl(data.qq_qrcode),
};
}
/** 表单 → API 载荷 */
function toPayload(form) {
return {
software_name: String(form.softwareName ?? "").trim(),
logo_path: toStoredImageValue(form.logo),
wechat: form.contactWechat?.trim() || null,
qq: form.contactQq?.trim() || null,
wechat_qrcode: toStoredImageValue(form.wechatQr),
qq_qrcode: toStoredImageValue(form.qqQr),
};
}
/**
* 加载默认 OEMid=1软件配置
* @param {string} token
*/
export async function loadAdminAppBrandingApi(token) {
const res = await adminRequest(token, "/admin/oem-branding");
if (!res.ok) {
return { ok: false, message: res.message || "加载软件配置失败" };
}
return { ok: true, data: { form: toForm(res.data), oemId: res.data?.id ?? 1 } };
}
/**
* 保存默认 OEMid=1软件配置
* @param {string} token
* @param {ReturnType<typeof emptyBrandingForm>} form
*/
export async function saveAdminAppBrandingApi(token, form) {
const res = await adminRequest(token, "/admin/oem-branding", {
method: "PATCH",
body: JSON.stringify(toPayload(form)),
});
if (!res.ok) {
return { ok: false, message: res.message || "保存软件配置失败" };
}
return {
ok: true,
message: res.message || "软件配置已保存",
data: { form: toForm(res.data), oemId: res.data?.id ?? 1 },
};
}

View File

@@ -16,14 +16,28 @@ function adminRequest(token, path, options = {}) {
/**
* @param {string} token
* @param {{ page?: number, pageSize?: number, status?: string }} [params]
* @param {{
* page?: number,
* pageSize?: number,
* status?: string,
* username?: string,
* oemId?: number | null,
* agentId?: number | null,
* }} [params]
*/
export function listAdminCardKeysApi(token, { page = 1, pageSize = 20, status = "all" } = {}) {
export function listAdminCardKeysApi(
token,
{ page = 1, pageSize = 20, status = "all", username, oemId, agentId } = {},
) {
const query = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
status,
});
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));
return adminRequest(token, `/admin/card-keys?${query}`);
}

View File

@@ -16,13 +16,28 @@ function adminRequest(token, path, options = {}) {
/**
* @param {string} token
* @param {{ page?: number, pageSize?: number }} [params]
* @param {{
* page?: number,
* pageSize?: number,
* username?: string,
* oemId?: number | null,
* agentId?: number | null,
* roleId?: number | null,
* }} [params]
*/
export function listAdminUsersApi(token, { page = 1, pageSize = 20 } = {}) {
export function listAdminUsersApi(
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 adminRequest(token, `/admin/users?${query}`);
}

View File

@@ -0,0 +1,27 @@
import { invoke } from "@tauri-apps/api/core";
/**
* 经 Rust 读取本地 oem 文件中的 ID并请求公开品牌 API。
* @returns {Promise<{
* oemId: number,
* softwareName: string,
* logoUrl: string | null,
* wechat: string | null,
* }>}
*/
export async function fetchSoftwareInfo() {
try {
const data = await invoke("get_software_info");
if (!data || typeof data !== "object") {
return { oemId: 1, softwareName: "", logoUrl: null, wechat: null };
}
return {
oemId: data.oemId ?? 1,
softwareName: data.softwareName ?? "",
logoUrl: data.logoUrl || null,
wechat: data.wechat || null,
};
} catch {
return { oemId: 1, softwareName: "", logoUrl: null, wechat: null };
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,12 +12,14 @@ import Textarea from "primevue/textarea";
import InputNumber from "primevue/inputnumber";
import Slider from "primevue/slider";
import Checkbox from "primevue/checkbox";
import RadioButton from "primevue/radiobutton";
import SelectButton from "primevue/selectbutton";
import Tabs from "primevue/tabs";
import TabList from "primevue/tablist";
import Tab from "primevue/tab";
import TabPanels from "primevue/tabpanels";
import TabPanel from "primevue/tabpanel";
import DatePicker from "primevue/datepicker";
import Dialog from "primevue/dialog";
import DataTable from "primevue/datatable";
import Column from "primevue/column";
@@ -55,6 +57,7 @@ app.component("Textarea", Textarea);
app.component("InputNumber", InputNumber);
app.component("Slider", Slider);
app.component("Checkbox", Checkbox);
app.component("RadioButton", RadioButton);
app.component("SelectButton", SelectButton);
app.component("Tabs", Tabs);
app.component("TabList", TabList);
@@ -62,6 +65,7 @@ app.component("Tab", Tab);
app.component("TabPanels", TabPanels);
app.component("TabPanel", TabPanel);
app.component("DashboardCard", DashboardCard);
app.component("DatePicker", DatePicker);
app.component("Dialog", Dialog);
app.component("DataTable", DataTable);
app.component("Column", Column);

View File

@@ -136,7 +136,7 @@ const mainChildren = [
meta: { title: "管理", requiresRole: ROLE_ADMIN },
redirect: { name: "admin-overview" },
redirect: { name: "admin-users" },
children: [
@@ -199,7 +199,7 @@ const mainChildren = [
meta: { title: "管理", requiresRole: ROLE_OEM},
redirect: { name: "oem-overview" },
redirect: { name: "oem-users" },
children: [
@@ -231,7 +231,7 @@ const mainChildren = [
path: "desktop-config",
name: "admin-desktop-config",
name: "oem-desktop-config",
component: () => import("../views/oem/OemDesktopConfigView.vue"),
@@ -243,7 +243,7 @@ const mainChildren = [
path: "card-keys",
name: "admin-card-keys",
name: "oem-card-keys",
component: () => import("../views/oem/OemCardKeysView.vue"),

View File

@@ -242,6 +242,24 @@ body {
margin-bottom: 16px;
}
.admin-users__filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.admin-users__search {
width: 14rem;
min-width: 10rem;
}
.admin-users__filter-select {
width: 12rem;
min-width: 10rem;
}
.admin-users__message {
margin-bottom: 12px;
}
@@ -292,6 +310,116 @@ body {
word-break: break-all;
}
.admin-overview__form {
display: flex;
flex-direction: column;
gap: 20px;
max-width: 32rem;
}
.admin-overview__field {
display: flex;
flex-direction: column;
gap: 8px;
}
.admin-overview__label,
.admin-image-upload__label {
font-size: 13px;
font-weight: 500;
color: #e2e8f0;
}
.admin-overview__input {
width: 100%;
}
.app-titlebar__logo {
border-radius: 4px;
}
.app-titlebar__name {
max-width: 10rem;
}
.app-titlebar__wechat {
max-width: 12rem;
}
.app-titlebar__controls {
border-left: 1px solid rgba(255, 255, 255, 0.06);
}
.app-titlebar__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 100%;
border: none;
background: transparent;
color: #94a3b8;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.app-titlebar__btn:hover {
background: rgba(255, 255, 255, 0.08);
color: #e2e8f0;
}
.app-titlebar__btn:active {
background: rgba(255, 255, 255, 0.12);
}
.admin-image-upload {
display: flex;
flex-direction: column;
gap: 8px;
}
.admin-image-upload__body {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px;
}
.admin-image-upload__preview {
width: 120px;
height: 120px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.admin-image-upload__preview img {
width: 100%;
height: 100%;
object-fit: contain;
}
.admin-image-upload__preview--empty {
border-style: dashed;
}
.admin-image-upload__actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.admin-image-upload__input {
display: none;
}
/* 首页工作台 */
.custom-scrollbar {
scrollbar-width: thin;

View File

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

View File

@@ -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>
<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 }}在此查看系统运行概况
欢迎{{ 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>

View File

@@ -29,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: "" });
@@ -73,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([
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 loadUsers() {
loading.value = true;
feedback.value = { severity: "", message: "" };
const res = await listAdminUsersApi(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;
@@ -107,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;
@@ -209,8 +257,9 @@ async function removeUser(row) {
await loadUsers();
}
onMounted(() => {
loadUsers();
onMounted(async () => {
await loadFilterOptions();
await loadUsers();
});
</script>
@@ -219,11 +268,41 @@ 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>
<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"
/>
<Button label="搜索" @click="applyFilters" />
<Button label="重置" severity="secondary" text @click="resetFilters" />
</div>
<Message
v-if="feedback.message"
:severity="feedback.severity"
@@ -263,6 +342,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) }}

View File

@@ -6,9 +6,9 @@ const auth = useAuthStore();
<template>
<div class="admin-page">
<h1 class="admin-page__title gradient-text">概览</h1>
<h1 class="admin-page__title gradient-text">软件配置</h1>
<p class="text-sm text-text-muted">
欢迎{{ auth.currentUser?.username }}在此查看系统运行概况
欢迎{{ auth.currentUser?.username }}
</p>
</div>
</template>

View File

@@ -22,6 +22,10 @@ export default defineConfig({
target: "http://127.0.0.1:8001",
changeOrigin: true,
},
"/images": {
target: "http://127.0.0.1:8001",
changeOrigin: true,
},
},
hmr: host
? {