11
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -8,3 +8,4 @@ pub mod fs_util;
|
||||
pub mod nodejs;
|
||||
pub mod publish;
|
||||
pub mod quickjs;
|
||||
pub mod software_info;
|
||||
|
||||
71
src-tauri/src/commands/software_info.rs
Normal file
71
src-tauri/src/commands/software_info.rs
Normal 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))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
6
src-tauri/src/oem_context.rs
Normal file
6
src-tauri/src/oem_context.rs
Normal 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
67
src-tauri/src/oem_id.rs
Normal 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
113
src/api/adminAppBranding.js
Normal 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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载默认 OEM(id=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 } };
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存默认 OEM(id=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 },
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
27
src/api/oemSoftwareInfo.js
Normal file
27
src/api/oemSoftwareInfo.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
101
src/components/admin/AdminImageUpload.vue
Normal file
101
src/components/admin/AdminImageUpload.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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user