11
This commit is contained in:
@@ -7,6 +7,10 @@
|
|||||||
"core:default",
|
"core:default",
|
||||||
"core:window:default",
|
"core:window:default",
|
||||||
"core:window:allow-start-dragging",
|
"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:default",
|
||||||
"core:event:allow-listen",
|
"core:event:allow-listen",
|
||||||
"core:event:allow-unlisten",
|
"core:event:allow-unlisten",
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ pub mod fs_util;
|
|||||||
pub mod nodejs;
|
pub mod nodejs;
|
||||||
pub mod publish;
|
pub mod publish;
|
||||||
pub mod quickjs;
|
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 http;
|
||||||
pub mod js_runtime;
|
pub mod js_runtime;
|
||||||
pub mod nodejs;
|
pub mod nodejs;
|
||||||
|
pub mod oem_context;
|
||||||
|
pub mod oem_id;
|
||||||
pub mod oss;
|
pub mod oss;
|
||||||
pub mod publish_db;
|
pub mod publish_db;
|
||||||
pub mod publish_store;
|
pub mod publish_store;
|
||||||
@@ -36,6 +38,13 @@ pub fn run() {
|
|||||||
.manage(publish_store::PublishStore::new())
|
.manage(publish_store::PublishStore::new())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let handle = app.handle().clone();
|
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 {
|
tauri::async_runtime::block_on(async move {
|
||||||
let app_config = handle.state::<app_config::AppConfig>();
|
let app_config = handle.state::<app_config::AppConfig>();
|
||||||
let avatar_store = handle.state::<avatar_store::AvatarStore>();
|
let avatar_store = handle.state::<avatar_store::AvatarStore>();
|
||||||
@@ -68,6 +77,7 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::api::api_request,
|
commands::api::api_request,
|
||||||
|
commands::software_info::get_software_info,
|
||||||
commands::auth::sync_auth_session,
|
commands::auth::sync_auth_session,
|
||||||
commands::app_config::get_app_config,
|
commands::app_config::get_app_config,
|
||||||
commands::app_config::get_app_config_last_message,
|
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 {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({
|
const query = new URLSearchParams({
|
||||||
page: String(page),
|
page: String(page),
|
||||||
page_size: String(pageSize),
|
page_size: String(pageSize),
|
||||||
status,
|
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}`);
|
return adminRequest(token, `/admin/card-keys?${query}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,28 @@ function adminRequest(token, path, options = {}) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} token
|
* @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({
|
const query = new URLSearchParams({
|
||||||
page: String(page),
|
page: String(page),
|
||||||
page_size: String(pageSize),
|
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}`);
|
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>
|
<template>
|
||||||
<header
|
<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">
|
||||||
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"
|
<div
|
||||||
data-tauri-drag-region
|
class="app-titlebar__main flex min-w-0 flex-1 items-center gap-2 px-3"
|
||||||
>
|
data-tauri-drag-region
|
||||||
<span class="font-mono text-xs tracking-widest text-text-muted uppercase">
|
>
|
||||||
aiclient
|
<template v-if="!loading">
|
||||||
</span>
|
<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
|
||||||
|
>
|
||||||
|
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>
|
</header>
|
||||||
</template>
|
</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 route = useRoute();
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ name: "admin-overview", label: "概览", to: "/admin" },
|
|
||||||
{ name: "admin-users", label: "用户管理", to: "/admin/users" },
|
{ name: "admin-users", label: "用户管理", to: "/admin/users" },
|
||||||
{ name: "admin-card-keys", label: "卡密管理", to: "/admin/card-keys" },
|
{ name: "admin-card-keys", label: "卡密管理", to: "/admin/card-keys" },
|
||||||
{ name: "admin-desktop-config", label: "桌面配置", to: "/admin/desktop-config" },
|
{ name: "admin-desktop-config", label: "桌面配置", to: "/admin/desktop-config" },
|
||||||
|
{ name: "admin-overview", label: "软件配置", to: "/admin" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeName = computed(() => route.name);
|
const activeName = computed(() => route.name);
|
||||||
@@ -16,7 +17,7 @@ const activeName = computed(() => route.name);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="admin-sub-nav" aria-label="管理后台导航">
|
<nav class="admin-sub-nav" aria-label="管理后台导航">
|
||||||
<p class="admin-sub-nav__title">管理后台</p>
|
<p class="admin-sub-nav__title"></p>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="item in menuItems"
|
v-for="item in menuItems"
|
||||||
:key="item.name"
|
:key="item.name"
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { useRoute } from "vue-router";
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ name: "admin-overview", label: "概览", to: "/admin" },
|
|
||||||
{ name: "admin-users", label: "用户管理", to: "/admin/users" },
|
{ name: "admin-users", label: "用户管理", to: "/admin/users" },
|
||||||
{ name: "admin-card-keys", label: "卡密管理", to: "/admin/card-keys" },
|
{ name: "admin-card-keys", label: "卡密管理", to: "/admin/card-keys" },
|
||||||
{ name: "admin-desktop-config", label: "桌面配置", to: "/admin/desktop-config" },
|
{ name: "admin-desktop-config", label: "桌面配置", to: "/admin/desktop-config" },
|
||||||
|
{ name: "admin-overview", label: "软件配置", to: "/admin" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeName = computed(() => route.name);
|
const activeName = computed(() => route.name);
|
||||||
@@ -16,7 +17,7 @@ const activeName = computed(() => route.name);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="admin-sub-nav" aria-label="管理后台导航">
|
<nav class="admin-sub-nav" aria-label="管理后台导航">
|
||||||
<p class="admin-sub-nav__title">管理后台</p>
|
<p class="admin-sub-nav__title"></p>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="item in menuItems"
|
v-for="item in menuItems"
|
||||||
:key="item.name"
|
:key="item.name"
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import Textarea from "primevue/textarea";
|
|||||||
import InputNumber from "primevue/inputnumber";
|
import InputNumber from "primevue/inputnumber";
|
||||||
import Slider from "primevue/slider";
|
import Slider from "primevue/slider";
|
||||||
import Checkbox from "primevue/checkbox";
|
import Checkbox from "primevue/checkbox";
|
||||||
|
import RadioButton from "primevue/radiobutton";
|
||||||
import SelectButton from "primevue/selectbutton";
|
import SelectButton from "primevue/selectbutton";
|
||||||
import Tabs from "primevue/tabs";
|
import Tabs from "primevue/tabs";
|
||||||
import TabList from "primevue/tablist";
|
import TabList from "primevue/tablist";
|
||||||
import Tab from "primevue/tab";
|
import Tab from "primevue/tab";
|
||||||
import TabPanels from "primevue/tabpanels";
|
import TabPanels from "primevue/tabpanels";
|
||||||
import TabPanel from "primevue/tabpanel";
|
import TabPanel from "primevue/tabpanel";
|
||||||
|
import DatePicker from "primevue/datepicker";
|
||||||
import Dialog from "primevue/dialog";
|
import Dialog from "primevue/dialog";
|
||||||
import DataTable from "primevue/datatable";
|
import DataTable from "primevue/datatable";
|
||||||
import Column from "primevue/column";
|
import Column from "primevue/column";
|
||||||
@@ -55,6 +57,7 @@ app.component("Textarea", Textarea);
|
|||||||
app.component("InputNumber", InputNumber);
|
app.component("InputNumber", InputNumber);
|
||||||
app.component("Slider", Slider);
|
app.component("Slider", Slider);
|
||||||
app.component("Checkbox", Checkbox);
|
app.component("Checkbox", Checkbox);
|
||||||
|
app.component("RadioButton", RadioButton);
|
||||||
app.component("SelectButton", SelectButton);
|
app.component("SelectButton", SelectButton);
|
||||||
app.component("Tabs", Tabs);
|
app.component("Tabs", Tabs);
|
||||||
app.component("TabList", TabList);
|
app.component("TabList", TabList);
|
||||||
@@ -62,6 +65,7 @@ app.component("Tab", Tab);
|
|||||||
app.component("TabPanels", TabPanels);
|
app.component("TabPanels", TabPanels);
|
||||||
app.component("TabPanel", TabPanel);
|
app.component("TabPanel", TabPanel);
|
||||||
app.component("DashboardCard", DashboardCard);
|
app.component("DashboardCard", DashboardCard);
|
||||||
|
app.component("DatePicker", DatePicker);
|
||||||
app.component("Dialog", Dialog);
|
app.component("Dialog", Dialog);
|
||||||
app.component("DataTable", DataTable);
|
app.component("DataTable", DataTable);
|
||||||
app.component("Column", Column);
|
app.component("Column", Column);
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ const mainChildren = [
|
|||||||
|
|
||||||
meta: { title: "管理", requiresRole: ROLE_ADMIN },
|
meta: { title: "管理", requiresRole: ROLE_ADMIN },
|
||||||
|
|
||||||
redirect: { name: "admin-overview" },
|
redirect: { name: "admin-users" },
|
||||||
|
|
||||||
children: [
|
children: [
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ const mainChildren = [
|
|||||||
|
|
||||||
meta: { title: "管理", requiresRole: ROLE_OEM},
|
meta: { title: "管理", requiresRole: ROLE_OEM},
|
||||||
|
|
||||||
redirect: { name: "oem-overview" },
|
redirect: { name: "oem-users" },
|
||||||
|
|
||||||
children: [
|
children: [
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ const mainChildren = [
|
|||||||
|
|
||||||
path: "desktop-config",
|
path: "desktop-config",
|
||||||
|
|
||||||
name: "admin-desktop-config",
|
name: "oem-desktop-config",
|
||||||
|
|
||||||
component: () => import("../views/oem/OemDesktopConfigView.vue"),
|
component: () => import("../views/oem/OemDesktopConfigView.vue"),
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ const mainChildren = [
|
|||||||
|
|
||||||
path: "card-keys",
|
path: "card-keys",
|
||||||
|
|
||||||
name: "admin-card-keys",
|
name: "oem-card-keys",
|
||||||
|
|
||||||
component: () => import("../views/oem/OemCardKeysView.vue"),
|
component: () => import("../views/oem/OemCardKeysView.vue"),
|
||||||
|
|
||||||
|
|||||||
@@ -242,6 +242,24 @@ body {
|
|||||||
margin-bottom: 16px;
|
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 {
|
.admin-users__message {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
@@ -292,6 +310,116 @@ body {
|
|||||||
word-break: break-all;
|
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 {
|
.custom-scrollbar {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { useAuthStore } from "../../stores/auth.js";
|
import { useAuthStore, ROLE_AGENT, ROLE_OEM } from "../../stores/auth.js";
|
||||||
import {
|
import {
|
||||||
listAdminCardKeysApi,
|
listAdminCardKeysApi,
|
||||||
createAdminCardKeysApi,
|
createAdminCardKeysApi,
|
||||||
updateAdminCardKeyApi,
|
updateAdminCardKeyApi,
|
||||||
deleteAdminCardKeyApi,
|
deleteAdminCardKeyApi,
|
||||||
} from "../../api/adminCardKeys.js";
|
} from "../../api/adminCardKeys.js";
|
||||||
|
import { listAdminUsersApi } from "../../api/adminUsers.js";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
@@ -16,11 +17,24 @@ const STATUS_OPTIONS = [
|
|||||||
{ label: "已激活", value: "used" },
|
{ label: "已激活", value: "used" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ASSIGN_OPTIONS = [
|
||||||
|
{ label: "不指定", value: "none" },
|
||||||
|
{ label: "分配给 OEM", value: "oem" },
|
||||||
|
{ label: "分配给代理", value: "agent" },
|
||||||
|
];
|
||||||
|
|
||||||
const cards = ref([]);
|
const cards = ref([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(20);
|
const pageSize = ref(20);
|
||||||
const statusFilter = ref("all");
|
const statusFilter = ref("all");
|
||||||
|
const oemOptions = ref([]);
|
||||||
|
const agentOptions = ref([]);
|
||||||
|
const filters = ref({
|
||||||
|
username: "",
|
||||||
|
oem_id: null,
|
||||||
|
agent_id: null,
|
||||||
|
});
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const feedback = ref({ severity: "", message: "" });
|
const feedback = ref({ severity: "", message: "" });
|
||||||
@@ -34,6 +48,8 @@ const emptyForm = () => ({
|
|||||||
duration_days: 30,
|
duration_days: 30,
|
||||||
count: 1,
|
count: 1,
|
||||||
remark: "",
|
remark: "",
|
||||||
|
assign_type: "none",
|
||||||
|
assign_id: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = ref(emptyForm());
|
const form = ref(emptyForm());
|
||||||
@@ -45,6 +61,24 @@ const dialogTitle = computed(() =>
|
|||||||
const isActivated = computed(() => dialogMode.value === "edit" && !!editingActivatedAt.value);
|
const isActivated = computed(() => dialogMode.value === "edit" && !!editingActivatedAt.value);
|
||||||
const editingActivatedAt = ref(null);
|
const editingActivatedAt = ref(null);
|
||||||
|
|
||||||
|
const ownerNameById = computed(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const u of oemOptions.value) map.set(u.id, u.username);
|
||||||
|
for (const u of agentOptions.value) map.set(u.id, u.username);
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignSelectOptions = computed(() => {
|
||||||
|
if (form.value.assign_type === "oem") return oemOptions.value;
|
||||||
|
if (form.value.assign_type === "agent") return agentOptions.value;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
function ownerLabel(userId) {
|
||||||
|
if (userId == null) return "—";
|
||||||
|
return ownerNameById.value.get(userId) ?? `#${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value) {
|
function formatDateTime(value) {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
@@ -56,6 +90,19 @@ function showMessage(severity, message) {
|
|||||||
feedback.value = { severity, message };
|
feedback.value = { severity, message };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadFilterOptions() {
|
||||||
|
const [oemRes, agentRes] = await Promise.all([
|
||||||
|
listAdminUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_OEM }),
|
||||||
|
listAdminUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_AGENT }),
|
||||||
|
]);
|
||||||
|
if (oemRes.ok && Array.isArray(oemRes.data?.items)) {
|
||||||
|
oemOptions.value = oemRes.data.items;
|
||||||
|
}
|
||||||
|
if (agentRes.ok && Array.isArray(agentRes.data?.items)) {
|
||||||
|
agentOptions.value = agentRes.data.items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCards() {
|
async function loadCards() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
feedback.value = { severity: "", message: "" };
|
feedback.value = { severity: "", message: "" };
|
||||||
@@ -63,6 +110,9 @@ async function loadCards() {
|
|||||||
page: page.value,
|
page: page.value,
|
||||||
pageSize: pageSize.value,
|
pageSize: pageSize.value,
|
||||||
status: statusFilter.value,
|
status: statusFilter.value,
|
||||||
|
username: filters.value.username,
|
||||||
|
oemId: filters.value.oem_id,
|
||||||
|
agentId: filters.value.agent_id,
|
||||||
});
|
});
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|
||||||
@@ -89,6 +139,17 @@ function onStatusChange() {
|
|||||||
loadCards();
|
loadCards();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applyFilters() {
|
||||||
|
page.value = 1;
|
||||||
|
await loadCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetFilters() {
|
||||||
|
filters.value = { username: "", oem_id: null, agent_id: null };
|
||||||
|
page.value = 1;
|
||||||
|
await loadCards();
|
||||||
|
}
|
||||||
|
|
||||||
function openCreateDialog() {
|
function openCreateDialog() {
|
||||||
dialogMode.value = "create";
|
dialogMode.value = "create";
|
||||||
editingId.value = null;
|
editingId.value = null;
|
||||||
@@ -106,6 +167,8 @@ function openEditDialog(row) {
|
|||||||
duration_days: row.duration_days,
|
duration_days: row.duration_days,
|
||||||
count: 1,
|
count: 1,
|
||||||
remark: row.remark || "",
|
remark: row.remark || "",
|
||||||
|
assign_type: "none",
|
||||||
|
assign_id: null,
|
||||||
};
|
};
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
}
|
}
|
||||||
@@ -119,6 +182,16 @@ async function copySerial(serial) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAssignPayload() {
|
||||||
|
if (form.value.assign_type === "oem") {
|
||||||
|
return { oem_id: form.value.assign_id, agent_id: null };
|
||||||
|
}
|
||||||
|
if (form.value.assign_type === "agent") {
|
||||||
|
return { oem_id: null, agent_id: form.value.assign_id };
|
||||||
|
}
|
||||||
|
return { oem_id: null, agent_id: null };
|
||||||
|
}
|
||||||
|
|
||||||
async function saveCard() {
|
async function saveCard() {
|
||||||
if (form.value.duration_days < 1) {
|
if (form.value.duration_days < 1) {
|
||||||
showMessage("warn", "时长至少 1 天");
|
showMessage("warn", "时长至少 1 天");
|
||||||
@@ -130,10 +203,16 @@ async function saveCard() {
|
|||||||
|
|
||||||
let res;
|
let res;
|
||||||
if (dialogMode.value === "create") {
|
if (dialogMode.value === "create") {
|
||||||
|
if (form.value.assign_type !== "none" && form.value.assign_id == null) {
|
||||||
|
saving.value = false;
|
||||||
|
showMessage("warn", "请选择分配对象");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
duration_days: form.value.duration_days,
|
duration_days: form.value.duration_days,
|
||||||
count: form.value.count,
|
count: form.value.count,
|
||||||
remark: form.value.remark.trim() || null,
|
remark: form.value.remark.trim() || null,
|
||||||
|
...buildAssignPayload(),
|
||||||
};
|
};
|
||||||
const serial = form.value.serial_number.trim();
|
const serial = form.value.serial_number.trim();
|
||||||
if (serial) {
|
if (serial) {
|
||||||
@@ -188,8 +267,16 @@ async function removeCard(row) {
|
|||||||
|
|
||||||
watch(statusFilter, onStatusChange);
|
watch(statusFilter, onStatusChange);
|
||||||
|
|
||||||
onMounted(() => {
|
watch(
|
||||||
loadCards();
|
() => form.value.assign_type,
|
||||||
|
() => {
|
||||||
|
form.value.assign_id = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadFilterOptions();
|
||||||
|
await loadCards();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -198,7 +285,6 @@ onMounted(() => {
|
|||||||
<div class="admin-users__header">
|
<div class="admin-users__header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="admin-page__title gradient-text">卡密管理</h1>
|
<h1 class="admin-page__title gradient-text">卡密管理</h1>
|
||||||
<p class="text-sm text-text-muted">生成、查看与管理 VIP 激活卡密</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button label="生成卡密" @click="openCreateDialog" />
|
<Button label="生成卡密" @click="openCreateDialog" />
|
||||||
</div>
|
</div>
|
||||||
@@ -213,8 +299,34 @@ onMounted(() => {
|
|||||||
{{ feedback.message }}
|
{{ feedback.message }}
|
||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
<div class="admin-card-keys__toolbar">
|
<div class="admin-users__filters">
|
||||||
<label class="text-sm text-text-muted">状态筛选</label>
|
<InputText
|
||||||
|
v-model="filters.username"
|
||||||
|
placeholder="搜索使用者用户名"
|
||||||
|
class="admin-users__search"
|
||||||
|
@keyup.enter="applyFilters"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
v-model="filters.oem_id"
|
||||||
|
:options="oemOptions"
|
||||||
|
option-label="username"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="全部 OEM"
|
||||||
|
show-clear
|
||||||
|
filter
|
||||||
|
class="admin-users__filter-select"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
v-model="filters.agent_id"
|
||||||
|
:options="agentOptions"
|
||||||
|
option-label="username"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="全部代理"
|
||||||
|
show-clear
|
||||||
|
filter
|
||||||
|
class="admin-users__filter-select"
|
||||||
|
/>
|
||||||
|
<label class="text-sm text-text-muted">状态</label>
|
||||||
<Select
|
<Select
|
||||||
v-model="statusFilter"
|
v-model="statusFilter"
|
||||||
:options="STATUS_OPTIONS"
|
:options="STATUS_OPTIONS"
|
||||||
@@ -222,6 +334,8 @@ onMounted(() => {
|
|||||||
option-value="value"
|
option-value="value"
|
||||||
class="admin-card-keys__status-select"
|
class="admin-card-keys__status-select"
|
||||||
/>
|
/>
|
||||||
|
<Button label="搜索" @click="applyFilters" />
|
||||||
|
<Button label="重置" severity="secondary" text @click="resetFilters" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -256,6 +370,16 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="duration_days" header="时长(天)" style="width: 6rem" />
|
<Column field="duration_days" header="时长(天)" style="width: 6rem" />
|
||||||
|
<Column header="OEM" style="width: 7rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ ownerLabel(data.oem_id) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="代理" style="width: 7rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ ownerLabel(data.agent_id) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
<Column field="created_at" header="创建时间">
|
<Column field="created_at" header="创建时间">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ formatDateTime(data.created_at) }}
|
{{ formatDateTime(data.created_at) }}
|
||||||
@@ -332,6 +456,30 @@ onMounted(() => {
|
|||||||
:disabled="form.count > 1"
|
:disabled="form.count > 1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">预分配</label>
|
||||||
|
<Select
|
||||||
|
v-model="form.assign_type"
|
||||||
|
:options="ASSIGN_OPTIONS"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="form.assign_type !== 'none'" class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">
|
||||||
|
{{ form.assign_type === "oem" ? "选择 OEM" : "选择代理" }}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
v-model="form.assign_id"
|
||||||
|
:options="assignSelectOptions"
|
||||||
|
option-label="username"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="请选择"
|
||||||
|
filter
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -1,14 +1,142 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useAuthStore } from "../../stores/auth";
|
import { onMounted, ref } from "vue";
|
||||||
|
import { useAuthStore } from "../../stores/auth.js";
|
||||||
|
import AdminImageUpload from "../../components/admin/AdminImageUpload.vue";
|
||||||
|
import {
|
||||||
|
loadAdminAppBrandingApi,
|
||||||
|
saveAdminAppBrandingApi,
|
||||||
|
} from "../../api/adminAppBranding.js";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
softwareName: "",
|
||||||
|
logo: "",
|
||||||
|
contactWechat: "",
|
||||||
|
contactQq: "",
|
||||||
|
wechatQr: "",
|
||||||
|
qqQr: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const feedback = ref({ severity: "", message: "" });
|
||||||
|
|
||||||
|
function showMessage(severity, message) {
|
||||||
|
feedback.value = { severity, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageError(message) {
|
||||||
|
showMessage("warn", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
loading.value = true;
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
const res = await loadAdminAppBrandingApi(auth.token);
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage("error", res.message || "加载失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.value = { ...res.data.form };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
saving.value = true;
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
const res = await saveAdminAppBrandingApi(auth.token, form.value);
|
||||||
|
saving.value = false;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage("error", res.message || "保存失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showMessage("success", res.message || "已保存");
|
||||||
|
await loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSettings();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="admin-page">
|
<div class="admin-page admin-overview">
|
||||||
<h1 class="admin-page__title gradient-text">概览</h1>
|
<div class="admin-users__header">
|
||||||
<p class="text-sm text-text-muted">
|
<div>
|
||||||
欢迎,{{ auth.currentUser?.username }}。在此查看系统运行概况。
|
<h1 class="admin-page__title gradient-text">软件配置</h1>
|
||||||
</p>
|
<p class="text-sm text-text-muted">
|
||||||
|
欢迎,{{ auth.currentUser?.username }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button label="保存配置" :loading="saving" :disabled="loading" @click="saveSettings" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Message
|
||||||
|
v-if="feedback.message"
|
||||||
|
:severity="feedback.severity"
|
||||||
|
:closable="true"
|
||||||
|
class="admin-users__message"
|
||||||
|
@close="feedback.message = ''"
|
||||||
|
>
|
||||||
|
{{ feedback.message }}
|
||||||
|
</Message>
|
||||||
|
|
||||||
|
<div v-if="loading" class="py-12 text-center text-sm text-text-muted">加载中…</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-else
|
||||||
|
class="admin-overview__form"
|
||||||
|
@submit.prevent="saveSettings"
|
||||||
|
>
|
||||||
|
<div class="admin-overview__field">
|
||||||
|
<label class="admin-overview__label">软件名称</label>
|
||||||
|
<InputText
|
||||||
|
v-model="form.softwareName"
|
||||||
|
class="admin-overview__input"
|
||||||
|
placeholder="请输入软件名称"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminImageUpload
|
||||||
|
v-model="form.logo"
|
||||||
|
label="软件 logo"
|
||||||
|
@error="onImageError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="admin-overview__field">
|
||||||
|
<label class="admin-overview__label">联系微信</label>
|
||||||
|
<InputText
|
||||||
|
v-model="form.contactWechat"
|
||||||
|
class="admin-overview__input"
|
||||||
|
placeholder="微信号或联系方式"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-overview__field">
|
||||||
|
<label class="admin-overview__label">联系 QQ</label>
|
||||||
|
<InputText
|
||||||
|
v-model="form.contactQq"
|
||||||
|
class="admin-overview__input"
|
||||||
|
placeholder="QQ 号或联系方式"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminImageUpload
|
||||||
|
v-model="form.wechatQr"
|
||||||
|
label="微信二维码"
|
||||||
|
@error="onImageError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminImageUpload
|
||||||
|
v-model="form.qqQr"
|
||||||
|
label="QQ 二维码"
|
||||||
|
@error="onImageError"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -29,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: "" });
|
||||||
|
|
||||||
@@ -73,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([
|
||||||
|
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() {
|
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 listAdminUsersApi(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;
|
||||||
|
|
||||||
@@ -107,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;
|
||||||
@@ -209,8 +257,9 @@ async function removeUser(row) {
|
|||||||
await loadUsers();
|
await loadUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
loadUsers();
|
await loadFilterOptions();
|
||||||
|
await loadUsers();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -219,11 +268,41 @@ 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>
|
||||||
|
|
||||||
|
<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
|
<Message
|
||||||
v-if="feedback.message"
|
v-if="feedback.message"
|
||||||
:severity="feedback.severity"
|
:severity="feedback.severity"
|
||||||
@@ -263,6 +342,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) }}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ const auth = useAuthStore();
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="admin-page">
|
<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">
|
<p class="text-sm text-text-muted">
|
||||||
欢迎,{{ auth.currentUser?.username }}。在此查看系统运行概况。
|
欢迎,{{ auth.currentUser?.username }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export default defineConfig({
|
|||||||
target: "http://127.0.0.1:8001",
|
target: "http://127.0.0.1:8001",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
"/images": {
|
||||||
|
target: "http://127.0.0.1:8001",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
hmr: host
|
hmr: host
|
||||||
? {
|
? {
|
||||||
|
|||||||
Reference in New Issue
Block a user