初始化
This commit is contained in:
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/assets/logo.png?v=1" />
|
||||
<title>剧游AI-后台管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2912
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "juhe-factory-admin",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"format": "prettier --write src",
|
||||
"format:check": "prettier --check src",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.ts,.tsx --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.18.1",
|
||||
"element-plus": "^2.5.6",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"prettier": "3.9.6",
|
||||
"sass": "^1.71.1",
|
||||
"sass-embedded": "^1.100.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.1.5",
|
||||
"vue-tsc": "^2.2.12"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from "vue-router";
|
||||
import ToastContainer from "@/components/ToastContainer.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<ToastContainer />
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
import axios, { AxiosError, type InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
const http = axios.create({ baseURL: "/api/admin", timeout: 20000 });
|
||||
let refreshPromise: Promise<string> | null = null;
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("admin_access_token");
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const request = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined;
|
||||
const canRefresh = !request?.url?.includes("/auth/");
|
||||
if (error.response?.status === 401 && request && !request._retried && canRefresh) {
|
||||
request._retried = true;
|
||||
try {
|
||||
const token = await refreshAccessToken();
|
||||
request.headers.Authorization = `Bearer ${token}`;
|
||||
return http(request);
|
||||
} catch {
|
||||
clearAuth();
|
||||
if (!location.pathname.endsWith("/login")) location.href = "/admin/login";
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = axios
|
||||
.post("/api/admin/auth/refresh", { refresh_token: localStorage.getItem("admin_refresh_token") })
|
||||
.then(({ data }) => {
|
||||
saveTokens(data.data);
|
||||
return data.data.access_token as string;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
export function saveTokens(data: { access_token: string; refresh_token: string; admin?: unknown }) {
|
||||
localStorage.setItem("admin_access_token", data.access_token);
|
||||
localStorage.setItem("admin_refresh_token", data.refresh_token);
|
||||
if (data.admin) localStorage.setItem("admin_profile", JSON.stringify(data.admin));
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem("admin_access_token");
|
||||
localStorage.removeItem("admin_refresh_token");
|
||||
localStorage.removeItem("admin_profile");
|
||||
}
|
||||
|
||||
export function apiError(error: unknown) {
|
||||
const axiosError = error as AxiosError<{ code?: string; message?: string; trace_id?: string }>;
|
||||
const body = axiosError.response?.data;
|
||||
const message = body?.message || axiosError.message || "请求失败";
|
||||
const copyText = `错误码:${body?.code || "request_failed"}\n错误描述:${message}\n请求追踪 ID:${body?.trace_id || axiosError.response?.headers?.["x-request-id"] || "无"}`;
|
||||
return { message, copyText };
|
||||
}
|
||||
|
||||
export default http;
|
||||
@@ -0,0 +1,3 @@
|
||||
import logoUrl from "../../../WEB/src/assets/logo.png";
|
||||
|
||||
export default logoUrl;
|
||||
@@ -0,0 +1,837 @@
|
||||
@use "./variables.scss";
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
line-height: 1.5;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
min-height: 100dvh;
|
||||
padding-left: var(--sidebar-width);
|
||||
}
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 40;
|
||||
width: var(--sidebar-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 10px;
|
||||
background: #111827;
|
||||
color: var(--color-sidebar-text);
|
||||
border-right: 1px solid #202c3e;
|
||||
box-shadow: 4px 0 16px rgba(15, 23, 42, 0.08);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
.brand {
|
||||
height: 54px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 0 8px 10px;
|
||||
color: #f8fafc;
|
||||
font-size: 17px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand strong {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid #314058;
|
||||
border-radius: 9px;
|
||||
background: #182235;
|
||||
}
|
||||
.brand-mark img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.menu {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-height: 0;
|
||||
margin-top: 8px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #344258 transparent;
|
||||
}
|
||||
.menu-item {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
background: transparent;
|
||||
color: #9cabc1;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
.menu-item .el-icon {
|
||||
color: #8495ad;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.menu-item:hover {
|
||||
background: #182335;
|
||||
color: #dce5f2;
|
||||
}
|
||||
.menu-item:hover .el-icon {
|
||||
color: #b7c6da;
|
||||
}
|
||||
.menu-item.active {
|
||||
background: #24344d;
|
||||
color: #fff;
|
||||
box-shadow: inset 2px 0 #60a5fa;
|
||||
}
|
||||
.menu-item.active .el-icon {
|
||||
color: #e5edf8;
|
||||
}
|
||||
.menu-item.logout {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 10px;
|
||||
background: transparent;
|
||||
color: #8493aa;
|
||||
}
|
||||
.menu-item.logout:hover {
|
||||
background: #182335;
|
||||
color: #dce5f2;
|
||||
}
|
||||
.is-collapsed {
|
||||
padding-left: var(--sidebar-collapsed-width);
|
||||
}
|
||||
.is-collapsed .sidebar {
|
||||
width: var(--sidebar-collapsed-width);
|
||||
padding-inline: 12px;
|
||||
}
|
||||
.is-collapsed .brand {
|
||||
justify-content: center;
|
||||
padding-inline: 0;
|
||||
}
|
||||
.is-collapsed .menu-item {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
.shell-main {
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
height: var(--topbar-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 28px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
.topbar h1 {
|
||||
font-size: 18px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.topbar p {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.topbar-menu {
|
||||
flex: 0 0 44px;
|
||||
}
|
||||
.admin-profile {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.page-content {
|
||||
width: 100%;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 28px 40px;
|
||||
}
|
||||
.page-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.management-page {
|
||||
height: max(814px, calc(100dvh - 64px));
|
||||
min-height: 814px;
|
||||
}
|
||||
.management-page > .table-panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
.management-page > .table-panel > .el-pagination {
|
||||
margin-top: auto;
|
||||
padding-top: 18px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
.page-header h2 {
|
||||
font-size: var(--font-size-xl);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.page-header p {
|
||||
margin-top: 5px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.page-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.panel {
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.table-panel {
|
||||
overflow: auto;
|
||||
}
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-bar > .el-input {
|
||||
width: min(280px, 100%);
|
||||
}
|
||||
.filter-bar > .el-select {
|
||||
width: 180px;
|
||||
}
|
||||
.filter-bar > .el-date-editor {
|
||||
max-width: 420px;
|
||||
}
|
||||
.batch-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
.batch-toolbar strong {
|
||||
margin-right: auto;
|
||||
}
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.section-heading h3 {
|
||||
font-size: 17px;
|
||||
}
|
||||
.section-heading span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.channel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.balance-card {
|
||||
padding: 18px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: #fff;
|
||||
}
|
||||
.balance-card.warning {
|
||||
border-color: #f59e0b;
|
||||
background: #fffbeb;
|
||||
}
|
||||
.balance-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.balance-card__header strong,
|
||||
.balance-card__header span {
|
||||
display: block;
|
||||
}
|
||||
.balance-card__header span,
|
||||
.balance-card p {
|
||||
margin-top: 3px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.balance-value {
|
||||
margin: 22px 0 14px;
|
||||
font-size: 28px;
|
||||
font-weight: 650;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.balance-value small {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.line-chart {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
}
|
||||
.icon-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-button:hover {
|
||||
background: rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.el-button .el-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.field-full {
|
||||
width: 100% !important;
|
||||
}
|
||||
.field-helper {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.form-grid,
|
||||
.dialog-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 18px;
|
||||
}
|
||||
.dialog-field--full {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
.style-thumb {
|
||||
width: 64px;
|
||||
height: 48px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.upload-preview {
|
||||
display: block;
|
||||
width: 120px;
|
||||
height: 84px;
|
||||
margin-top: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.code-output {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.el-pagination {
|
||||
justify-content: flex-end;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.el-table {
|
||||
--el-table-header-bg-color: #f8fafc;
|
||||
--el-table-row-hover-bg-color: #f8fafc;
|
||||
}
|
||||
.el-table th.el-table__cell {
|
||||
color: #39465a;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mobile-card-list {
|
||||
display: none;
|
||||
}
|
||||
.data-card {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.data-card dl {
|
||||
display: grid;
|
||||
grid-template-columns: 110px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
.data-card dt {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.data-card dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.el-overlay {
|
||||
background: rgba(15, 23, 42, 0.58);
|
||||
}
|
||||
.el-overlay-dialog {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.el-dialog {
|
||||
margin: 0;
|
||||
border: 1px solid #d3dbe7;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.24);
|
||||
overflow: hidden;
|
||||
}
|
||||
.el-dialog__header {
|
||||
margin: 0;
|
||||
padding: 19px 24px 16px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
background: #f8fafc;
|
||||
}
|
||||
.el-dialog__title {
|
||||
color: var(--color-text);
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.el-dialog__headerbtn {
|
||||
top: 12px;
|
||||
right: 14px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.el-dialog__headerbtn:hover {
|
||||
background: #e9eef5;
|
||||
}
|
||||
.el-dialog__headerbtn .el-dialog__close {
|
||||
color: #718096;
|
||||
font-size: 18px;
|
||||
}
|
||||
.el-dialog__body {
|
||||
max-height: calc(100dvh - 180px);
|
||||
padding: 22px 24px 6px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.el-dialog.managed-scroll-dialog {
|
||||
margin: 0;
|
||||
height: min(760px, calc(100dvh - 40px));
|
||||
max-height: calc(100dvh - 40px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.el-dialog.managed-scroll-dialog .el-dialog__header,
|
||||
.el-dialog.managed-scroll-dialog .el-dialog__footer {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.el-dialog.managed-scroll-dialog .el-dialog__body {
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.managed-scroll-overlay .el-overlay-dialog {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
.el-dialog .el-dialog__footer {
|
||||
padding: 14px 24px 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
border-top: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.el-dialog__footer .el-button {
|
||||
min-width: 82px;
|
||||
height: 38px;
|
||||
margin-left: 0;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.el-dialog .el-form-item {
|
||||
margin-bottom: 19px;
|
||||
}
|
||||
.el-dialog .el-form-item__label {
|
||||
padding-bottom: 7px;
|
||||
color: #39465a;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.el-dialog .el-input__wrapper,
|
||||
.el-dialog .el-select__wrapper {
|
||||
min-height: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #d5dce6 inset;
|
||||
}
|
||||
.el-dialog .el-input__wrapper:hover,
|
||||
.el-dialog .el-select__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px #9bb8e7 inset;
|
||||
}
|
||||
.el-dialog .el-input__wrapper.is-focus,
|
||||
.el-dialog .el-select__wrapper.is-focused {
|
||||
box-shadow: 0 0 0 1px var(--color-primary) inset;
|
||||
}
|
||||
.el-dialog .el-textarea__inner {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #d5dce6 inset;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.el-dialog .el-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
.el-dialog .el-upload .el-button {
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.el-dialog .el-switch {
|
||||
--el-switch-on-color: var(--color-primary);
|
||||
}
|
||||
.toast-region {
|
||||
position: fixed;
|
||||
z-index: 3000;
|
||||
top: 18px;
|
||||
left: 50%;
|
||||
width: min(440px, calc(100vw - 32px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.app-toast {
|
||||
position: relative;
|
||||
min-height: 68px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 14px 12px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 4px solid var(--color-info);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.app-toast--success {
|
||||
border-left-color: var(--color-success);
|
||||
}
|
||||
.app-toast--warning {
|
||||
border-left-color: var(--color-warning);
|
||||
}
|
||||
.app-toast--error {
|
||||
border-left-color: var(--color-danger);
|
||||
}
|
||||
.app-toast__icon {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.app-toast__content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.app-toast__content span {
|
||||
margin-top: 2px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.app-toast .icon-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
.app-toast__progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
background: var(--color-primary);
|
||||
transition: width 0.05s linear;
|
||||
}
|
||||
.toast-fade-enter-active,
|
||||
.toast-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.toast-fade-enter-from,
|
||||
.toast-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.login-page {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: #eef2f7;
|
||||
}
|
||||
.login-panel {
|
||||
width: min(440px, 100%);
|
||||
padding: 34px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.login-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.login-heading {
|
||||
margin: 32px 0 24px;
|
||||
}
|
||||
.login-heading h1 {
|
||||
font-size: 26px;
|
||||
}
|
||||
.login-heading p {
|
||||
margin-top: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.admin-shell {
|
||||
padding-left: 0;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18px 14px 32px;
|
||||
}
|
||||
.topbar {
|
||||
height: 64px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
.admin-profile span {
|
||||
display: none;
|
||||
}
|
||||
.management-page {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.page-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.page-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.page-actions .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
.panel {
|
||||
padding: 14px;
|
||||
}
|
||||
.filter-bar {
|
||||
align-items: stretch;
|
||||
}
|
||||
.filter-bar > * {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
.form-grid,
|
||||
.dialog-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dialog-field--full {
|
||||
grid-column: auto;
|
||||
}
|
||||
.desktop-table {
|
||||
display: none;
|
||||
}
|
||||
.mobile-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.line-chart {
|
||||
height: 300px;
|
||||
}
|
||||
.channel-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.login-panel {
|
||||
padding: 26px 20px;
|
||||
}
|
||||
.el-overlay-dialog {
|
||||
padding: 12px;
|
||||
}
|
||||
.el-dialog {
|
||||
margin: 0 !important;
|
||||
}
|
||||
.el-dialog__header {
|
||||
padding: 17px 18px 14px;
|
||||
}
|
||||
.el-dialog__body {
|
||||
max-height: calc(100dvh - 150px);
|
||||
padding: 18px 18px 4px;
|
||||
}
|
||||
.el-dialog .el-dialog__footer {
|
||||
padding: 18px 18px 12px;
|
||||
}
|
||||
.el-pagination {
|
||||
justify-content: center;
|
||||
}
|
||||
.menu {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.mobile-drawer .el-drawer__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 12px;
|
||||
background: var(--color-sidebar);
|
||||
color: var(--color-sidebar-text);
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.desktop-user-table {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.el-dialog .el-dialog__header {
|
||||
padding: 8px 24px 2px;
|
||||
border-bottom: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.el-dialog .el-dialog__headerbtn {
|
||||
top: 7px;
|
||||
right: 11px;
|
||||
}
|
||||
.el-dialog__body {
|
||||
padding-top: 16px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.el-dialog .el-dialog__header {
|
||||
padding: 6px 18px 2px;
|
||||
}
|
||||
.el-dialog__body {
|
||||
padding-top: 14px;
|
||||
}
|
||||
}
|
||||
.el-dialog[style*="720px"] .el-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 18px;
|
||||
}
|
||||
.el-dialog[style*="720px"] .el-form > .form-grid {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
.el-dialog[style*="720px"] .el-form > .el-form-item:has(.el-textarea__inner),
|
||||
.el-dialog[style*="720px"] .el-form > .el-form-item:has(.el-upload),
|
||||
.el-dialog[style*="720px"] .el-form > .el-form-item:has(.el-input__password) {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
.el-dialog[style*="720px"] .el-form > .el-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.el-dialog[style*="720px"] .el-form {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.el-dialog .el-dialog__headerbtn {
|
||||
top: 4px;
|
||||
right: 10px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 0;
|
||||
}
|
||||
.el-dialog .el-dialog__headerbtn .el-dialog__close {
|
||||
color: #718096;
|
||||
font-size: 15px;
|
||||
transition: color 0.16s ease;
|
||||
}
|
||||
.el-dialog .el-dialog__headerbtn:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.el-dialog .el-dialog__headerbtn:hover .el-dialog__close {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
:root {
|
||||
--color-primary: #4f46e5;
|
||||
--color-primary-hover: #4338ca;
|
||||
--color-primary-soft: #eef2ff;
|
||||
--color-success: #15803d;
|
||||
--color-warning: #b45309;
|
||||
--color-danger: #b91c1c;
|
||||
--color-info: #64748b;
|
||||
--color-text: #172033;
|
||||
--color-text-secondary: #64748b;
|
||||
--color-text-placeholder: #94a3b8;
|
||||
--color-border: #e2e8f0;
|
||||
--color-border-light: #edf1f6;
|
||||
--color-bg: #f4f6fb;
|
||||
--color-surface: #ffffff;
|
||||
--color-sidebar: #111827;
|
||||
--color-sidebar-text: #cbd5e1;
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
--font-size-xs: 12px;
|
||||
--font-size-sm: 14px;
|
||||
--font-size-md: 16px;
|
||||
--font-size-lg: 20px;
|
||||
--font-size-xl: 26px;
|
||||
--radius-sm: 5px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||
--shadow-md: 0 10px 30px rgba(15, 23, 42, 0.12);
|
||||
--sidebar-width: 224px;
|
||||
--sidebar-collapsed-width: 72px;
|
||||
--topbar-height: 72px;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<!-- 管理后台左侧菜单组件,统一维护菜单入口、图标和展示顺序。 -->
|
||||
<script setup lang="ts">
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import { adminMenuItems } from "@/router/adminNavigation";
|
||||
|
||||
defineProps<{ activePath: string; collapsed?: boolean }>();
|
||||
const emit = defineEmits<{ navigate: [path: string] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="menu admin-menu"
|
||||
:class="{ 'admin-menu--collapsed': collapsed }"
|
||||
>
|
||||
<button
|
||||
v-for="item in adminMenuItems"
|
||||
:key="item.path"
|
||||
class="menu-item"
|
||||
:class="{ active: activePath === item.path }"
|
||||
type="button"
|
||||
:title="collapsed ? item.label : undefined"
|
||||
@click="emit('navigate', item.path)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="item.icon"
|
||||
:size="20"
|
||||
/>
|
||||
<span v-if="!collapsed">{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-menu {
|
||||
gap: 5px;
|
||||
padding: 12px 1px 8px;
|
||||
}
|
||||
.admin-menu--collapsed {
|
||||
padding-inline: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
interface Props {
|
||||
modelValue?: number | string | null;
|
||||
min?: number;
|
||||
max?: number;
|
||||
precision?: 0 | 2;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: null,
|
||||
precision: 2,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
placeholder: "",
|
||||
});
|
||||
const emit = defineEmits<{ "update:modelValue": [value: number | null] }>();
|
||||
const text = ref(formatValue(props.modelValue));
|
||||
const focused = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (!focused.value) text.value = formatValue(value);
|
||||
},
|
||||
);
|
||||
|
||||
function formatValue(value: number | string | null | undefined) {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return "";
|
||||
const factor = 10 ** props.precision;
|
||||
return String(Math.round(numeric * factor) / factor);
|
||||
}
|
||||
|
||||
function sanitize(value: string) {
|
||||
const normalized = value.replace(/,/g, ".").replace(/[^\d.-]/g, "");
|
||||
const negative = normalized.startsWith("-");
|
||||
const unsigned = normalized.replace(/-/g, "");
|
||||
const [integer = "", ...decimalParts] = unsigned.split(".");
|
||||
const sign = negative ? "-" : "";
|
||||
if (props.precision === 0) return sign + integer;
|
||||
const hasDecimalPoint = unsigned.includes(".");
|
||||
const decimal = decimalParts.join("").slice(0, props.precision);
|
||||
return sign + integer + (hasDecimalPoint ? `.${decimal}` : "");
|
||||
}
|
||||
|
||||
function update(value: string) {
|
||||
text.value = sanitize(value);
|
||||
if (text.value === "" || text.value === "-" || text.value === "." || text.value === "-.") {
|
||||
emit("update:modelValue", null);
|
||||
return;
|
||||
}
|
||||
const numeric = Number(text.value);
|
||||
if (Number.isFinite(numeric)) emit("update:modelValue", numeric);
|
||||
}
|
||||
|
||||
function commit() {
|
||||
focused.value = false;
|
||||
if (text.value === "" || text.value === "-" || text.value === "." || text.value === "-.") {
|
||||
text.value = "";
|
||||
emit("update:modelValue", null);
|
||||
return;
|
||||
}
|
||||
let value = Number(text.value);
|
||||
if (!Number.isFinite(value)) {
|
||||
text.value = formatValue(props.modelValue);
|
||||
return;
|
||||
}
|
||||
if (props.min !== undefined) value = Math.max(props.min, value);
|
||||
if (props.max !== undefined) value = Math.min(props.max, value);
|
||||
const factor = 10 ** props.precision;
|
||||
value = Math.round(value * factor) / factor;
|
||||
text.value = String(value);
|
||||
emit("update:modelValue", value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-input
|
||||
class="admin-number-input"
|
||||
:class="{ 'is-readonly': readonly }"
|
||||
:model-value="text"
|
||||
type="text"
|
||||
:inputmode="precision === 0 ? 'numeric' : 'decimal'"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:placeholder="placeholder"
|
||||
@focus="focused = true"
|
||||
@input="update"
|
||||
@blur="commit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-number-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-number-input :deep(.el-input__wrapper) {
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 0 0 1px var(--color-border) inset;
|
||||
}
|
||||
|
||||
.admin-number-input :deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px var(--color-primary) inset;
|
||||
}
|
||||
|
||||
.admin-number-input :deep(.el-input__inner) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-number-input.is-readonly :deep(.el-input__wrapper) {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!-- 剧游AI管理后台图标组件,统一封装第三方图标库的使用入口 -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import * as Icons from "@element-plus/icons-vue";
|
||||
|
||||
type IconName = keyof typeof Icons;
|
||||
|
||||
interface Props {
|
||||
name: IconName;
|
||||
size?: number | string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 16,
|
||||
});
|
||||
|
||||
const iconComponent = computed(() => Icons[props.name]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-icon
|
||||
:size="size"
|
||||
:color="color"
|
||||
>
|
||||
<component :is="iconComponent" />
|
||||
</el-icon>
|
||||
</template>
|
||||
@@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import AdminNumberInput from "@/components/AdminNumberInput.vue";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
form: Record<string, any>;
|
||||
editing: boolean;
|
||||
}>();
|
||||
const replacingAPIKey = ref(false);
|
||||
|
||||
const normalizedRate = computed(() => {
|
||||
const cny = Number(props.form.exchange_cny_amount);
|
||||
const points = Number(props.form.exchange_point_amount);
|
||||
if (!Number.isFinite(cny) || cny <= 0 || !Number.isFinite(points) || points <= 0) return "--";
|
||||
return (points / cny).toFixed(2);
|
||||
});
|
||||
|
||||
function cancelAPIKeyReplacement() {
|
||||
props.form.api_key = "";
|
||||
replacingAPIKey.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="channel-editor">
|
||||
<section class="channel-section">
|
||||
<header class="channel-section__header">
|
||||
<span class="channel-section__icon"><AppIcon name="Connection" /></span>
|
||||
<div>
|
||||
<h3>基础连接</h3>
|
||||
<p>配置渠道标识、接口地址和访问凭证。</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="channel-grid">
|
||||
<el-form-item
|
||||
label="渠道名"
|
||||
prop="name"
|
||||
required
|
||||
>
|
||||
<el-input
|
||||
v-model.trim="form.name"
|
||||
placeholder="输入渠道名称"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
label="渠道 URL"
|
||||
prop="base_url"
|
||||
required
|
||||
>
|
||||
<el-input
|
||||
v-model.trim="form.base_url"
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
class="channel-field--full"
|
||||
label="API Key"
|
||||
prop="api_key"
|
||||
:required="!editing"
|
||||
>
|
||||
<template v-if="editing && !replacingAPIKey">
|
||||
<div class="masked-key-row">
|
||||
<el-input
|
||||
:model-value="form.api_key_masked || '未配置'"
|
||||
readonly
|
||||
/>
|
||||
<el-button @click="replacingAPIKey = true">更换 API Key</el-button>
|
||||
</div>
|
||||
<div class="field-helper">仅显示前后各 5 位,完整密钥不会返回管理端</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-input
|
||||
v-model="form.api_key"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="输入新 API Key"
|
||||
/>
|
||||
<div class="api-key-helper-row">
|
||||
<div class="field-helper">凭证将加密保存</div>
|
||||
<el-button
|
||||
v-if="editing"
|
||||
link
|
||||
@click="cancelAPIKeyReplacement"
|
||||
>取消更换</el-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="channel-section">
|
||||
<header class="channel-section__header">
|
||||
<span class="channel-section__icon"><AppIcon name="Coin" /></span>
|
||||
<div>
|
||||
<h3>渠道汇率</h3>
|
||||
<p>设置人民币与渠道积分的兑换关系,用于模型价格换算。</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="exchange-rate-row">
|
||||
<el-form-item
|
||||
label="人民币金额"
|
||||
required
|
||||
>
|
||||
<AdminNumberInput
|
||||
v-model="form.exchange_cny_amount"
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
placeholder="1.00"
|
||||
/>
|
||||
</el-form-item>
|
||||
<span class="exchange-rate-row__equals">=</span>
|
||||
<el-form-item
|
||||
label="渠道积分"
|
||||
required
|
||||
>
|
||||
<AdminNumberInput
|
||||
v-model="form.exchange_point_amount"
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
placeholder="1.00"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="exchange-rate-summary">
|
||||
<span>归一化汇率</span>
|
||||
<strong>1.00 元 = {{ normalizedRate }} 渠道积分</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="channel-section">
|
||||
<header class="channel-section__header">
|
||||
<span class="channel-section__icon"><AppIcon name="Setting" /></span>
|
||||
<div>
|
||||
<h3>运行限制</h3>
|
||||
<p>配置余额预警和任务并发上限。</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="channel-grid channel-grid--three">
|
||||
<el-form-item
|
||||
label="余额预警阈值"
|
||||
prop="warning_threshold"
|
||||
required
|
||||
>
|
||||
<AdminNumberInput
|
||||
v-model="form.warning_threshold"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
label="最大并发数"
|
||||
prop="max_concurrency"
|
||||
required
|
||||
>
|
||||
<AdminNumberInput
|
||||
v-model="form.max_concurrency"
|
||||
:min="1"
|
||||
:max="5000"
|
||||
:precision="0"
|
||||
/>
|
||||
<div class="field-helper">已提交且未进入终态的总任务数</div>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
label="单用户最高并发数"
|
||||
prop="max_user_concurrency"
|
||||
required
|
||||
>
|
||||
<AdminNumberInput
|
||||
v-model="form.max_user_concurrency"
|
||||
:min="1"
|
||||
:max="500"
|
||||
:precision="0"
|
||||
/>
|
||||
<div class="field-helper">超出后继续排队,不计入轮询请求</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.channel-editor {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.channel-section {
|
||||
padding: 18px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.channel-section__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.channel-section__icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.channel-section__header h3 {
|
||||
color: var(--color-text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.channel-section__header p {
|
||||
margin-top: 3px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.channel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 18px;
|
||||
}
|
||||
|
||||
.channel-grid--three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.channel-field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.masked-key-row {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.masked-key-row :deep(.el-input__inner) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.masked-key-row > .el-button {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.api-key-helper-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.exchange-rate-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 32px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.exchange-rate-row__equals {
|
||||
margin-top: 2px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exchange-rate-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.exchange-rate-summary strong {
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.channel-section {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.channel-grid,
|
||||
.channel-grid--three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.exchange-rate-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.exchange-rate-row__equals {
|
||||
margin: -4px 0 10px;
|
||||
}
|
||||
|
||||
.exchange-rate-summary {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.masked-key-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; description: string; emptyText: string; hasSelection: boolean }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack management-page">
|
||||
<header class="page-header master-detail-heading">
|
||||
<div>
|
||||
<h2>{{ title }}</h2>
|
||||
</div>
|
||||
<div class="page-actions"><slot name="header-actions" /></div>
|
||||
</header>
|
||||
<section class="master-detail-panel">
|
||||
<aside class="master-list">
|
||||
<div class="master-list-toolbar"><slot name="list-toolbar" /></div>
|
||||
<div class="master-list-items"><slot name="list" /></div>
|
||||
</aside>
|
||||
<section class="master-editor">
|
||||
<slot
|
||||
v-if="hasSelection"
|
||||
name="detail"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="master-empty"
|
||||
>
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.master-detail-heading {
|
||||
align-items: center;
|
||||
}
|
||||
.master-detail-panel {
|
||||
display: grid;
|
||||
flex: none;
|
||||
height: max(760px, calc(100dvh - 118px));
|
||||
min-height: 760px;
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.master-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--color-border-light);
|
||||
background: #f8fafc;
|
||||
}
|
||||
.master-list-toolbar {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
.master-list-items {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
.master-editor {
|
||||
min-width: 0;
|
||||
padding: 26px 30px;
|
||||
background: #fff;
|
||||
}
|
||||
.master-empty {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.master-detail-heading :deep(.page-actions) {
|
||||
align-items: center;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.master-detail-panel {
|
||||
display: grid;
|
||||
flex: none;
|
||||
height: auto;
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
.master-list {
|
||||
max-height: 280px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
.master-editor {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; description?: string }>();
|
||||
</script>
|
||||
<template>
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h2>{{ title }}</h2>
|
||||
</div>
|
||||
<div class="page-actions"><slot /></div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, watch } from "vue";
|
||||
import { Check, Close, CopyDocument, InfoFilled, WarningFilled } from "@element-plus/icons-vue";
|
||||
import { useToastStore, type ToastItem } from "@/stores/toast";
|
||||
|
||||
const store = useToastStore();
|
||||
const elapsed = reactive<Record<number, number>>({});
|
||||
const paused = reactive<Record<number, boolean>>({});
|
||||
let lastTick = performance.now();
|
||||
const timer = window.setInterval(() => {
|
||||
const now = performance.now();
|
||||
const delta = now - lastTick;
|
||||
lastTick = now;
|
||||
for (const item of store.visible) {
|
||||
if (!paused[item.id]) elapsed[item.id] = (elapsed[item.id] || 0) + delta;
|
||||
if (elapsed[item.id] >= item.duration) store.remove(item.id);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
watch(
|
||||
() => store.visible.map((item) => item.id),
|
||||
(ids) => {
|
||||
ids.forEach((id) => {
|
||||
if (elapsed[id] === undefined) elapsed[id] = 0;
|
||||
});
|
||||
},
|
||||
);
|
||||
onBeforeUnmount(() => window.clearInterval(timer));
|
||||
|
||||
const icons = { success: Check, warning: WarningFilled, error: WarningFilled, info: InfoFilled };
|
||||
const progress = (item: ToastItem) =>
|
||||
computed(() => Math.max(0, 100 - ((elapsed[item.id] || 0) / item.duration) * 100)).value;
|
||||
async function copy(item: ToastItem) {
|
||||
await navigator.clipboard.writeText(item.copyText || item.message);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-region">
|
||||
<TransitionGroup name="toast-fade">
|
||||
<section
|
||||
v-for="item in store.visible"
|
||||
:key="item.id"
|
||||
class="app-toast"
|
||||
:class="`app-toast--${item.type}`"
|
||||
@mouseenter="paused[item.id] = true"
|
||||
@mouseleave="paused[item.id] = false"
|
||||
>
|
||||
<el-icon class="app-toast__icon"><component :is="icons[item.type]" /></el-icon>
|
||||
<div class="app-toast__content">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.message }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="item.type === 'error'"
|
||||
class="icon-button"
|
||||
type="button"
|
||||
title="复制完整错误"
|
||||
@click="copy(item)"
|
||||
>
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
</button>
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
title="关闭提示"
|
||||
@click="store.remove(item.id)"
|
||||
>
|
||||
<el-icon><Close /></el-icon>
|
||||
</button>
|
||||
<div
|
||||
class="app-toast__progress"
|
||||
:style="{ width: `${progress(item)}%` }"
|
||||
/>
|
||||
</section>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { ElMessageBox } from "element-plus";
|
||||
import { RouterView, useRoute, useRouter } from "vue-router";
|
||||
import AdminMenu from "@/components/AdminMenu.vue";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import logoUrl from "@/assets/logo";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const width = ref(window.innerWidth);
|
||||
const drawerOpen = ref(false);
|
||||
const loggingOut = ref(false);
|
||||
const collapsed = computed(() => width.value < 1200);
|
||||
const mobile = computed(() => width.value < 768);
|
||||
const updateWidth = () => {
|
||||
width.value = window.innerWidth;
|
||||
if (!mobile.value) drawerOpen.value = false;
|
||||
};
|
||||
onMounted(() => window.addEventListener("resize", updateWidth, { passive: true }));
|
||||
onBeforeUnmount(() => window.removeEventListener("resize", updateWidth));
|
||||
|
||||
/** 确认管理员主动退出后注销当前会话,桌面侧栏与移动抽屉共用该流程。 */
|
||||
async function logout() {
|
||||
if (loggingOut.value) return;
|
||||
try {
|
||||
await ElMessageBox.confirm("退出后需要重新登录,确定退出管理后台吗?", "退出登录", {
|
||||
type: "warning",
|
||||
confirmButtonText: "退出登录",
|
||||
cancelButtonText: "取消",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error === "cancel" || error === "close") return;
|
||||
throw error;
|
||||
}
|
||||
loggingOut.value = true;
|
||||
try {
|
||||
await auth.logout();
|
||||
await router.replace("/login");
|
||||
} finally {
|
||||
loggingOut.value = false;
|
||||
}
|
||||
}
|
||||
function navigate(path: string) {
|
||||
router.push(path);
|
||||
drawerOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="admin-shell"
|
||||
:class="{ 'is-collapsed': collapsed && !mobile }"
|
||||
>
|
||||
<aside
|
||||
v-if="!mobile"
|
||||
class="sidebar"
|
||||
>
|
||||
<div
|
||||
class="brand"
|
||||
:title="collapsed ? '剧游AI' : undefined"
|
||||
>
|
||||
<span class="brand-mark"
|
||||
><img
|
||||
:src="logoUrl"
|
||||
alt="" /></span
|
||||
><strong v-if="!collapsed">剧游AI</strong>
|
||||
</div>
|
||||
<AdminMenu
|
||||
:active-path="route.path"
|
||||
:collapsed="collapsed"
|
||||
@navigate="navigate"
|
||||
/>
|
||||
<button
|
||||
class="menu-item logout"
|
||||
type="button"
|
||||
:title="collapsed ? '退出登录' : undefined"
|
||||
:disabled="loggingOut"
|
||||
@click="logout"
|
||||
>
|
||||
<AppIcon
|
||||
name="SwitchButton"
|
||||
:size="20"
|
||||
/><span v-if="!collapsed">退出登录</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<el-drawer
|
||||
v-model="drawerOpen"
|
||||
direction="ltr"
|
||||
size="240px"
|
||||
:with-header="false"
|
||||
class="mobile-drawer"
|
||||
>
|
||||
<div class="brand">
|
||||
<span class="brand-mark"
|
||||
><img
|
||||
:src="logoUrl"
|
||||
alt="" /></span
|
||||
><strong>剧游AI</strong>
|
||||
</div>
|
||||
<AdminMenu
|
||||
:active-path="route.path"
|
||||
@navigate="navigate"
|
||||
/>
|
||||
<button
|
||||
class="menu-item logout"
|
||||
type="button"
|
||||
:disabled="loggingOut"
|
||||
@click="logout"
|
||||
>
|
||||
<AppIcon
|
||||
name="SwitchButton"
|
||||
:size="20"
|
||||
/><span>退出登录</span>
|
||||
</button>
|
||||
</el-drawer>
|
||||
|
||||
<div class="shell-main">
|
||||
<main class="page-content">
|
||||
<button
|
||||
v-if="mobile"
|
||||
class="icon-button mobile-menu-trigger"
|
||||
type="button"
|
||||
title="打开菜单"
|
||||
@click="drawerOpen = true"
|
||||
>
|
||||
<AppIcon
|
||||
name="Menu"
|
||||
:size="22"
|
||||
/>
|
||||
</button>
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-menu-trigger {
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
// 剧游AI管理后台入口,负责挂载 Vue 应用、注册路由与状态管理
|
||||
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ElementPlus from "element-plus";
|
||||
import zhCn from "element-plus/es/locale/lang/zh-cn";
|
||||
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
|
||||
import "./assets/styles/main.scss";
|
||||
import "element-plus/dist/index.css";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.use(ElementPlus, { locale: zhCn });
|
||||
|
||||
app.mount("#app");
|
||||
@@ -0,0 +1,16 @@
|
||||
// 管理后台主导航配置,统一维护菜单顺序和默认入口。
|
||||
|
||||
/** 管理后台左侧菜单项,顺序同时决定默认打开的页面。 */
|
||||
export const adminMenuItems = [
|
||||
{ path: "/users", label: "用户管理", icon: "User" },
|
||||
{ path: "/redemption-codes", label: "兑换码管理", icon: "Key" },
|
||||
{ path: "/styles", label: "风格管理", icon: "Picture" },
|
||||
{ path: "/prompts", label: "提示词管理", icon: "Document" },
|
||||
{ path: "/channels", label: "渠道管理", icon: "Connection" },
|
||||
{ path: "/models", label: "模型管理", icon: "Cpu" },
|
||||
{ path: "/audit-logs", label: "审计日志", icon: "Tickets" },
|
||||
{ path: "/security", label: "账号安全管理", icon: "Lock" },
|
||||
] as const;
|
||||
|
||||
/** 管理后台未指定目标页面时打开的第一个菜单路径。 */
|
||||
export const DEFAULT_ADMIN_PATH = adminMenuItems[0].path;
|
||||
@@ -0,0 +1,76 @@
|
||||
// 管理后台路由配置,负责页面注册、默认入口和登录访问控制。
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { DEFAULT_ADMIN_PATH } from "@/router/adminNavigation";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{ path: "/login", name: "login", component: () => import("@/views/LoginView.vue"), meta: { public: true } },
|
||||
{
|
||||
path: "/",
|
||||
component: () => import("@/layouts/AdminLayout.vue"),
|
||||
children: [
|
||||
{ path: "", redirect: DEFAULT_ADMIN_PATH },
|
||||
{
|
||||
path: "redemption-codes",
|
||||
name: "redemption-codes",
|
||||
component: () => import("@/views/RedemptionsView.vue"),
|
||||
meta: { title: "兑换码管理" },
|
||||
},
|
||||
{
|
||||
path: "styles",
|
||||
name: "styles",
|
||||
component: () => import("@/views/StylesView.vue"),
|
||||
meta: { title: "风格管理" },
|
||||
},
|
||||
{
|
||||
path: "prompts",
|
||||
name: "prompts",
|
||||
component: () => import("@/views/PromptsView.vue"),
|
||||
meta: { title: "提示词管理" },
|
||||
},
|
||||
{
|
||||
path: "channels",
|
||||
name: "channels",
|
||||
component: () => import("@/views/ChannelsView.vue"),
|
||||
meta: { title: "渠道管理" },
|
||||
},
|
||||
{
|
||||
path: "models",
|
||||
name: "models",
|
||||
component: () => import("@/views/ModelsView.vue"),
|
||||
meta: { title: "模型管理" },
|
||||
},
|
||||
{
|
||||
path: "users",
|
||||
name: "users",
|
||||
component: () => import("@/views/UsersView.vue"),
|
||||
meta: { title: "用户管理" },
|
||||
},
|
||||
{
|
||||
path: "audit-logs",
|
||||
name: "audit-logs",
|
||||
component: () => import("@/views/AuditLogsView.vue"),
|
||||
meta: { title: "审计日志" },
|
||||
},
|
||||
{
|
||||
path: "security",
|
||||
name: "security",
|
||||
component: () => import("@/views/SecurityView.vue"),
|
||||
meta: { title: "账号安全" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: "/:pathMatch(.*)*", redirect: DEFAULT_ADMIN_PATH },
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const authenticated = Boolean(localStorage.getItem("admin_access_token"));
|
||||
if (!to.meta.public && !authenticated) return { name: "login", query: { redirect: to.fullPath } };
|
||||
if (to.name === "login" && authenticated) return { path: DEFAULT_ADMIN_PATH };
|
||||
document.title = "剧游AI-后台管理";
|
||||
return true;
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineStore } from "pinia";
|
||||
import http, { clearAuth, saveTokens } from "@/api/http";
|
||||
|
||||
export const useAuthStore = defineStore("auth", {
|
||||
state: () => ({
|
||||
profile: JSON.parse(localStorage.getItem("admin_profile") || "null") as { id: string; username: string } | null,
|
||||
loading: false,
|
||||
}),
|
||||
getters: { authenticated: () => Boolean(localStorage.getItem("admin_access_token")) },
|
||||
actions: {
|
||||
async login(username: string, password: string) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const { data } = await http.post("/auth/login", { username, password });
|
||||
saveTokens(data.data);
|
||||
this.profile = data.data.admin;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async logout() {
|
||||
const refreshToken = localStorage.getItem("admin_refresh_token");
|
||||
try {
|
||||
await http.post("/auth/logout", { refresh_token: refreshToken });
|
||||
} finally {
|
||||
clearAuth();
|
||||
this.profile = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
// 剧游AI管理后台计数器示例 Store,演示 Pinia 状态管理用法
|
||||
|
||||
import { ref, computed } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useCounterStore = defineStore("counter", () => {
|
||||
const count = ref(0);
|
||||
const doubleCount = computed(() => count.value * 2);
|
||||
|
||||
function increment() {
|
||||
count.value++;
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment };
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export type ToastType = "success" | "warning" | "error" | "info";
|
||||
export interface ToastItem {
|
||||
id: number;
|
||||
type: ToastType;
|
||||
title: string;
|
||||
message: string;
|
||||
copyText?: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
export const useToastStore = defineStore("toast", {
|
||||
state: () => ({ visible: [] as ToastItem[], queued: [] as ToastItem[] }),
|
||||
actions: {
|
||||
show(type: ToastType, message: string, options?: { title?: string; copyText?: string }) {
|
||||
const item: ToastItem = {
|
||||
id: nextId++,
|
||||
type,
|
||||
title: options?.title || typeTitle(type),
|
||||
message,
|
||||
copyText: options?.copyText,
|
||||
duration: 5000,
|
||||
};
|
||||
if (this.visible.length < 3) this.visible.push(item);
|
||||
else this.queued.push(item);
|
||||
return item.id;
|
||||
},
|
||||
success(message: string) {
|
||||
return this.show("success", message);
|
||||
},
|
||||
warning(message: string) {
|
||||
return this.show("warning", message);
|
||||
},
|
||||
error(message: string, copyText?: string) {
|
||||
return this.show("error", message, { copyText });
|
||||
},
|
||||
remove(id: number) {
|
||||
this.visible = this.visible.filter((item) => item.id !== id);
|
||||
const next = this.queued.shift();
|
||||
if (next) this.visible.push(next);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function typeTitle(type: ToastType) {
|
||||
return { success: "操作成功", warning: "请注意", error: "操作失败", info: "提示" }[type];
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
<!-- 管理后台审计日志页,负责只读查询和展示关键管理操作。 -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import http, { apiError } from "@/api/http";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const toast = useToastStore();
|
||||
const loading = ref(false);
|
||||
const items = ref<Record<string, any>[]>([]);
|
||||
const total = ref(0);
|
||||
const query = reactive({ keyword: "", page: 1, page_size: 15 });
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
create: "新建",
|
||||
batch_create: "批量新建",
|
||||
update: "修改",
|
||||
batch_update: "批量修改",
|
||||
toggle: "状态变更",
|
||||
delete: "删除",
|
||||
batch_delete: "批量删除",
|
||||
reorder: "调整顺序",
|
||||
generate: "生成",
|
||||
save: "保存",
|
||||
save_draft: "保存草稿",
|
||||
grant_points: "增加积分",
|
||||
password_changed: "修改密码",
|
||||
password_change_failed: "修改密码失败",
|
||||
};
|
||||
const resourceLabels: Record<string, string> = {
|
||||
users: "用户",
|
||||
"redemption-codes": "兑换码",
|
||||
styles: "风格",
|
||||
prompts: "提示词",
|
||||
channels: "渠道",
|
||||
models: "模型",
|
||||
"admin-auth": "管理员账号",
|
||||
};
|
||||
|
||||
/** 按当前筛选条件加载审计日志。 */
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await http.get("/audit-logs", { params: query });
|
||||
items.value = data.data.items;
|
||||
total.value = data.data.total;
|
||||
} catch (error) {
|
||||
const detail = apiError(error);
|
||||
toast.error(detail.message, detail.copyText);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空关键字并返回第一页。 */
|
||||
function resetQuery() {
|
||||
query.keyword = "";
|
||||
query.page = 1;
|
||||
void load();
|
||||
}
|
||||
|
||||
/** 将审计动作代码转换为中文业务名称。 */
|
||||
function actionText(value: string) {
|
||||
return actionLabels[value] || value || "未知操作";
|
||||
}
|
||||
|
||||
/** 将审计资源代码转换为中文业务名称。 */
|
||||
function resourceText(value: string) {
|
||||
return resourceLabels[value] || value || "未知资源";
|
||||
}
|
||||
|
||||
/** 使用中国时区格式化审计发生时间。 */
|
||||
function formatTime(value: unknown) {
|
||||
if (!value) return "--";
|
||||
return new Date(String(value)).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false });
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack management-page">
|
||||
<PageHeader title="审计日志" />
|
||||
<section class="panel filter-bar">
|
||||
<el-input
|
||||
v-model.trim="query.keyword"
|
||||
clearable
|
||||
placeholder="管理员、操作、资源、原因、对象 ID 或追踪 ID"
|
||||
@keyup.enter="
|
||||
query.page = 1;
|
||||
load();
|
||||
"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="
|
||||
query.page = 1;
|
||||
load();
|
||||
"
|
||||
>查询</el-button
|
||||
>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</section>
|
||||
<section
|
||||
class="panel table-panel"
|
||||
v-loading="loading"
|
||||
>
|
||||
<el-table
|
||||
class="desktop-table"
|
||||
:data="items"
|
||||
>
|
||||
<el-table-column
|
||||
prop="admin_username"
|
||||
label="管理员"
|
||||
min-width="120"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
min-width="120"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain">{{ actionText(row.action) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="资源"
|
||||
min-width="120"
|
||||
>
|
||||
<template #default="{ row }">{{ resourceText(row.resource_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="resource_id"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #header>
|
||||
<span class="column-label">
|
||||
对象 ID
|
||||
<el-tooltip
|
||||
content="被操作数据的唯一标识;即使对象改名或删除,仍可用它准确定位记录。"
|
||||
placement="top"
|
||||
>
|
||||
<span class="column-help"><AppIcon name="QuestionFilled" /></span>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="reason"
|
||||
label="操作原因"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">{{ row.reason || "--" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="trace_id"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #header>
|
||||
<span class="column-label">
|
||||
请求追踪 ID
|
||||
<el-tooltip
|
||||
content="本次请求的唯一追踪编号,用于在服务器日志中查找并关联同一次请求。"
|
||||
placement="top"
|
||||
>
|
||||
<span class="column-help"><AppIcon name="QuestionFilled" /></span>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="操作时间"
|
||||
min-width="180"
|
||||
>
|
||||
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div
|
||||
v-if="items.length"
|
||||
class="mobile-card-list"
|
||||
>
|
||||
<article
|
||||
v-for="row in items"
|
||||
:key="row.id"
|
||||
class="data-card"
|
||||
>
|
||||
<dl>
|
||||
<dt>管理员</dt>
|
||||
<dd>{{ row.admin_username }}</dd>
|
||||
<dt>操作</dt>
|
||||
<dd>{{ actionText(row.action) }}</dd>
|
||||
<dt>资源</dt>
|
||||
<dd>{{ resourceText(row.resource_type) }}</dd>
|
||||
<dt>对象 ID</dt>
|
||||
<dd>{{ row.resource_id || "--" }}</dd>
|
||||
<dt>操作原因</dt>
|
||||
<dd>{{ row.reason || "--" }}</dd>
|
||||
<dt>请求追踪 ID</dt>
|
||||
<dd>{{ row.trace_id || "--" }}</dd>
|
||||
<dt>操作时间</dt>
|
||||
<dd>{{ formatTime(row.created_at) }}</dd>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<el-empty
|
||||
v-if="!loading && !items.length"
|
||||
description="暂无审计日志"
|
||||
/>
|
||||
<el-pagination
|
||||
v-if="total > query.page_size"
|
||||
v-model:current-page="query.page"
|
||||
background
|
||||
layout="total,prev,pager,next"
|
||||
:total="total"
|
||||
:page-size="query.page_size"
|
||||
@current-change="load"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.column-label,
|
||||
.column-help {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.column-label {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.column-help {
|
||||
color: var(--color-text-secondary);
|
||||
cursor: help;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,345 @@
|
||||
<!-- 渠道管理页,负责渠道配置、余额状态和启停操作。 -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { ElMessageBox, type FormInstance } from "element-plus";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import ChannelFormFields from "@/components/ChannelFormFields.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import http, { apiError } from "@/api/http";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const toast = useToastStore();
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const dialogOpen = ref(false);
|
||||
const editingId = ref("");
|
||||
const formRef = ref<FormInstance>();
|
||||
const items = ref<Record<string, any>[]>([]);
|
||||
const total = ref(0);
|
||||
const query = reactive({ keyword: "", enabled: "", page: 1, page_size: 15 });
|
||||
const form = reactive<Record<string, any>>({});
|
||||
const rules = {
|
||||
name: [{ required: true, message: "请填写渠道名", trigger: "blur" }],
|
||||
base_url: [{ required: true, message: "请填写渠道 URL", trigger: "blur" }],
|
||||
api_key: [
|
||||
{
|
||||
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||
if (!editingId.value && !String(value || "").trim()) callback(new Error("请填写 API Key"));
|
||||
else callback();
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await http.get("/resources/channels", { params: query });
|
||||
items.value = data.data.items || [];
|
||||
total.value = Number(data.data.total || 0);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
function reset(row?: Record<string, any>) {
|
||||
Object.keys(form).forEach((key) => delete form[key]);
|
||||
Object.assign(form, {
|
||||
name: row?.name || "",
|
||||
channel_type: row?.channel_type || "relay",
|
||||
base_url: row?.base_url || "",
|
||||
api_key: "",
|
||||
api_key_masked: row?.api_key_masked || "",
|
||||
warning_threshold: Number(row?.warning_threshold ?? 500),
|
||||
exchange_cny_amount: 1,
|
||||
exchange_point_amount: Number(row?.channel_points_per_cny ?? 1),
|
||||
max_concurrency: Number(row?.max_concurrency ?? 500),
|
||||
max_user_concurrency: Number(row?.max_user_concurrency ?? 10),
|
||||
enabled: row?.enabled ?? true,
|
||||
});
|
||||
editingId.value = row?.id || "";
|
||||
}
|
||||
function openCreate() {
|
||||
reset();
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
function openEdit(row: Record<string, any>) {
|
||||
reset(row);
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
async function save() {
|
||||
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||
const cnyAmount = Number(form.exchange_cny_amount);
|
||||
const pointAmount = Number(form.exchange_point_amount);
|
||||
if (!Number.isFinite(cnyAmount) || cnyAmount <= 0 || !Number.isFinite(pointAmount) || pointAmount <= 0) {
|
||||
toast.warning("请填写大于 0 的人民币金额和渠道积分");
|
||||
return;
|
||||
}
|
||||
if (editingId.value && String(form.api_key || "").trim()) {
|
||||
try {
|
||||
await ElMessageBox.confirm("此次修改将覆盖已加密保存的 API Key,是否继续?", "确认覆盖敏感配置", {
|
||||
type: "warning",
|
||||
confirmButtonText: "继续覆盖",
|
||||
cancelButtonText: "取消",
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
channel_points_per_cny: Number((pointAmount / cnyAmount).toFixed(6)),
|
||||
};
|
||||
delete payload.exchange_cny_amount;
|
||||
delete payload.exchange_point_amount;
|
||||
delete payload.api_key_masked;
|
||||
if (!payload.api_key) delete payload.api_key;
|
||||
if (editingId.value) await http.put(`/resources/channels/${editingId.value}`, payload);
|
||||
else await http.post("/resources/channels", payload);
|
||||
dialogOpen.value = false;
|
||||
toast.success(editingId.value ? "渠道已更新" : "渠道已创建");
|
||||
await load();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
async function toggle(row: Record<string, any>, enabled: boolean) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`${enabled ? "启用" : "停用"}后将立即生效,是否继续?`, "确认状态变更", {
|
||||
type: "warning",
|
||||
confirmButtonText: "确认",
|
||||
cancelButtonText: "取消",
|
||||
});
|
||||
await http.patch(`/resources/channels/${row.id}/enabled`, { enabled });
|
||||
row.enabled = enabled;
|
||||
toast.success("渠道状态已更新");
|
||||
} catch (error) {
|
||||
row.enabled = !enabled;
|
||||
if (error !== "cancel" && error !== "close") showError(error);
|
||||
}
|
||||
}
|
||||
async function remove(row: Record<string, any>) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除渠道“${row.name}”吗?`, "删除渠道", {
|
||||
type: "warning",
|
||||
confirmButtonText: "删除",
|
||||
cancelButtonText: "取消",
|
||||
});
|
||||
await http.delete(`/resources/channels/${row.id}`);
|
||||
toast.success("渠道已删除");
|
||||
await load();
|
||||
} catch (error) {
|
||||
if (error !== "cancel" && error !== "close") showError(error);
|
||||
}
|
||||
}
|
||||
function search() {
|
||||
query.page = 1;
|
||||
void load();
|
||||
}
|
||||
function resetQuery() {
|
||||
query.keyword = "";
|
||||
query.enabled = "";
|
||||
search();
|
||||
}
|
||||
function balance(row: Record<string, any>) {
|
||||
return Number(row.balance) < 0 ? "无限" : `${Number(row.balance || 0).toFixed(2)} ${row.currency || ""}`;
|
||||
}
|
||||
function showError(error: unknown) {
|
||||
const detail = apiError(error);
|
||||
toast.error(detail.message, detail.copyText);
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack management-page">
|
||||
<PageHeader
|
||||
title="渠道管理"
|
||||
description="维护 API 地址、密钥、汇率、余额和并发配置"
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="openCreate"
|
||||
><AppIcon name="Plus" />新建渠道</el-button
|
||||
>
|
||||
</PageHeader>
|
||||
<section class="panel filter-bar">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="输入渠道名或地址"
|
||||
><template #prefix><AppIcon name="Search" /></template
|
||||
></el-input>
|
||||
<el-select
|
||||
v-model="query.enabled"
|
||||
clearable
|
||||
placeholder="启用状态"
|
||||
><el-option
|
||||
label="已启用"
|
||||
value="true" /><el-option
|
||||
label="已停用"
|
||||
value="false"
|
||||
/></el-select>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="search"
|
||||
>查询</el-button
|
||||
>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</section>
|
||||
<section
|
||||
v-loading="loading"
|
||||
class="panel table-panel"
|
||||
>
|
||||
<el-table
|
||||
v-if="items.length"
|
||||
:data="items"
|
||||
class="desktop-table"
|
||||
>
|
||||
<el-table-column
|
||||
prop="name"
|
||||
label="渠道名"
|
||||
min-width="130"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="base_url"
|
||||
label="渠道 URL"
|
||||
min-width="240"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="api_key_masked"
|
||||
label="API Key"
|
||||
min-width="190"
|
||||
/>
|
||||
<el-table-column
|
||||
label="渠道汇率"
|
||||
min-width="180"
|
||||
><template #default="{ row }"
|
||||
>1.00 元 = {{ Number(row.channel_points_per_cny || 0).toFixed(2) }} 积分</template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column
|
||||
prop="max_concurrency"
|
||||
label="最大并发"
|
||||
min-width="100"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="max_user_concurrency"
|
||||
label="单用户并发"
|
||||
min-width="120"
|
||||
/>
|
||||
<el-table-column
|
||||
label="当前余额"
|
||||
min-width="130"
|
||||
><template #default="{ row }">{{ balance(row) }}</template></el-table-column
|
||||
>
|
||||
<el-table-column
|
||||
label="启用"
|
||||
min-width="90"
|
||||
><template #default="{ row }"
|
||||
><el-switch
|
||||
:model-value="row.enabled"
|
||||
@change="toggle(row, Boolean($event))" /></template
|
||||
></el-table-column>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
width="140"
|
||||
fixed="right"
|
||||
><template #default="{ row }"
|
||||
><el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openEdit(row)"
|
||||
>编辑</el-button
|
||||
><el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="remove(row)"
|
||||
>删除</el-button
|
||||
></template
|
||||
></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
<div
|
||||
v-if="items.length"
|
||||
class="mobile-card-list"
|
||||
>
|
||||
<article
|
||||
v-for="row in items"
|
||||
:key="row.id"
|
||||
class="data-card"
|
||||
>
|
||||
<dl>
|
||||
<dt>渠道名</dt>
|
||||
<dd>{{ row.name }}</dd>
|
||||
<dt>地址</dt>
|
||||
<dd>{{ row.base_url }}</dd>
|
||||
<dt>渠道汇率</dt>
|
||||
<dd>{{ Number(row.channel_points_per_cny || 0).toFixed(2) }}</dd>
|
||||
<dt>当前余额</dt>
|
||||
<dd>{{ balance(row) }}</dd>
|
||||
<dt>最大并发</dt>
|
||||
<dd>{{ row.max_concurrency }} / 用户 {{ row.max_user_concurrency }}</dd>
|
||||
</dl>
|
||||
<div class="card-actions">
|
||||
<el-button @click="openEdit(row)">编辑</el-button
|
||||
><el-button
|
||||
type="danger"
|
||||
plain
|
||||
@click="remove(row)"
|
||||
>删除</el-button
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="!items.length"
|
||||
description="暂无渠道"
|
||||
/>
|
||||
<el-pagination
|
||||
v-if="total > query.page_size"
|
||||
v-model:current-page="query.page"
|
||||
:page-size="query.page_size"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="load"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogOpen"
|
||||
:title="editingId ? '编辑渠道' : '新建渠道'"
|
||||
width="min(820px, calc(100vw - 32px))"
|
||||
class="managed-scroll-dialog"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
><ChannelFormFields
|
||||
:form="form"
|
||||
:editing="Boolean(editingId)"
|
||||
/></el-form>
|
||||
<template #footer
|
||||
><el-button @click="dialogOpen = false">取消</el-button
|
||||
><el-button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
>保存</el-button
|
||||
></template
|
||||
>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<!-- 管理后台登录页,负责管理员凭据校验和登录后页面跳转。 -->
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import logoUrl from "@/assets/logo";
|
||||
import { apiError } from "@/api/http";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { DEFAULT_ADMIN_PATH } from "@/router/adminNavigation";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const toast = useToastStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const form = reactive({ username: "", password: "" });
|
||||
const formRef = ref<FormInstance>();
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: "请输入管理员账号", trigger: "blur" }],
|
||||
password: [{ required: true, message: "请输入密码", trigger: "blur" }],
|
||||
};
|
||||
async function submit() {
|
||||
if (auth.loading) return;
|
||||
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||
try {
|
||||
await auth.login(form.username, form.password);
|
||||
router.replace(String(route.query.redirect || DEFAULT_ADMIN_PATH));
|
||||
} catch (error) {
|
||||
const detail = apiError(error);
|
||||
toast.error(detail.message, detail.copyText);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<section class="login-panel">
|
||||
<div class="login-brand">
|
||||
<span class="brand-mark"
|
||||
><img
|
||||
:src="logoUrl"
|
||||
alt="" /></span
|
||||
><span>剧游AI</span>
|
||||
</div>
|
||||
<div class="login-heading"><h1 id="login-title">管理后台登录</h1></div>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
size="large"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<el-form-item
|
||||
label="管理员账号"
|
||||
prop="username"
|
||||
><el-input
|
||||
v-model.trim="form.username"
|
||||
autocomplete="username"
|
||||
placeholder="请输入管理员账号"
|
||||
><template #prefix><AppIcon name="User" /></template></el-input
|
||||
></el-form-item>
|
||||
<el-form-item
|
||||
label="密码"
|
||||
prop="password"
|
||||
><el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
show-password
|
||||
placeholder="请输入密码"
|
||||
><template #prefix><AppIcon name="Lock" /></template></el-input
|
||||
></el-form-item>
|
||||
<el-button
|
||||
class="login-submit"
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
:loading="auth.loading"
|
||||
>登录</el-button
|
||||
>
|
||||
</el-form>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,548 @@
|
||||
<!-- 模型管理页,负责模型能力、渠道归属和计费配置。 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { ElMessageBox } from "element-plus";
|
||||
import http, { apiError } from "@/api/http";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import AdminNumberInput from "@/components/AdminNumberInput.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
type PriceField = [key: string, unit: string, label: string];
|
||||
|
||||
const toast = useToastStore();
|
||||
const loading = ref(false);
|
||||
const dialog = ref(false);
|
||||
const items = ref<Record<string, any>[]>([]);
|
||||
const channels = ref<Record<string, any>[]>([]);
|
||||
const editing = ref("");
|
||||
const query = reactive({ keyword: "", model_type: "", channel_id: "" });
|
||||
const form = reactive<Record<string, any>>({});
|
||||
|
||||
const selectedChannel = computed(() => channels.value.find((channel) => channel.id === form.channel_id));
|
||||
|
||||
const priceFields = computed<PriceField[]>(() => {
|
||||
if (form.model_type === "text") {
|
||||
if (form.text_billing_mode === "per_token") {
|
||||
return [
|
||||
["input", "M Token", "输入 Token"],
|
||||
["output", "M Token", "输出 Token"],
|
||||
];
|
||||
}
|
||||
return [["default", "次", "每次价格"]];
|
||||
}
|
||||
if (form.model_type === "image")
|
||||
return [
|
||||
["1K", "次", "1K"],
|
||||
["2K", "次", "2K"],
|
||||
["4K", "次", "4K"],
|
||||
];
|
||||
return [
|
||||
["480p", "秒", "480p"],
|
||||
["720p", "秒", "720p"],
|
||||
["1080p", "秒", "1080p"],
|
||||
];
|
||||
});
|
||||
|
||||
function parsePrices(value: unknown): Record<string, any>[] {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value !== "string") return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function hasTokenPrices(value: unknown) {
|
||||
return parsePrices(value).some((price) => ["input", "output"].includes(String(price.price_key).toLowerCase()));
|
||||
}
|
||||
|
||||
function isTokenBillingRow(row: Record<string, any>) {
|
||||
return row.model_type === "text" && (row.text_billing_mode === "per_token" || hasTokenPrices(row.prices));
|
||||
}
|
||||
|
||||
function displayPrices(row: Record<string, any>) {
|
||||
const prices = parsePrices(row.prices);
|
||||
if (row.model_type !== "text") return prices;
|
||||
const keys = isTokenBillingRow(row) ? ["input", "output"] : ["default"];
|
||||
return prices.filter((price) => keys.includes(String(price.price_key).toLowerCase()));
|
||||
}
|
||||
|
||||
function reset(row?: Record<string, any>) {
|
||||
const rowPrices = parsePrices(row?.prices);
|
||||
const prices: Record<string, number> = {};
|
||||
for (const price of rowPrices) prices[price.price_key] = Number(price.price);
|
||||
const billingMode = hasTokenPrices(rowPrices) ? "per_token" : row?.text_billing_mode || "per_request";
|
||||
Object.assign(form, {
|
||||
channel_id: row?.channel_id || "",
|
||||
name: row?.name || "",
|
||||
model_type: row?.model_type || "text",
|
||||
multimodal: row?.multimodal || false,
|
||||
text_billing_mode: billingMode,
|
||||
enabled: row?.enabled ?? true,
|
||||
price_values: prices,
|
||||
});
|
||||
editing.value = row?.id || "";
|
||||
dialog.value = true;
|
||||
}
|
||||
|
||||
async function fetchAllModels() {
|
||||
const result: Record<string, any>[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const { data } = await http.get("/models", { params: { ...query, page, page_size: 100 } });
|
||||
const current = data.data.items || [];
|
||||
result.push(...current);
|
||||
if (!current.length || result.length >= Number(data.data.total || 0)) return result;
|
||||
page += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [models, { data: channelData }] = await Promise.all([
|
||||
fetchAllModels(),
|
||||
http.get("/resources/channels", { params: { page_size: 100 } }),
|
||||
]);
|
||||
items.value = models.map((row: Record<string, any>) => ({ ...row, prices: parsePrices(row.prices) }));
|
||||
channels.value = channelData.data.items;
|
||||
} catch (error) {
|
||||
show(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.channel_id || !form.name) {
|
||||
toast.warning("请填写模型名并选择渠道");
|
||||
return;
|
||||
}
|
||||
const prices = priceFields.value.map(([priceKey, unit]) => ({
|
||||
price_key: priceKey,
|
||||
unit,
|
||||
price: Number(Number(form.price_values[priceKey] || 0).toFixed(2)),
|
||||
}));
|
||||
try {
|
||||
const payload = { ...form, prices };
|
||||
delete payload.price_values;
|
||||
if (editing.value) await http.put(`/models/${editing.value}`, payload);
|
||||
else await http.post("/models", payload);
|
||||
toast.success(editing.value ? "模型已更新" : "模型已创建");
|
||||
dialog.value = false;
|
||||
load();
|
||||
} catch (error) {
|
||||
show(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(row: Record<string, any>, enabled: boolean) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${enabled ? "启用" : "停用"}模型 ${row.name}?`, "状态变更", { type: "warning" });
|
||||
await http.patch(`/resources/models/${row.id}/enabled`, { enabled });
|
||||
row.enabled = enabled;
|
||||
toast.success("模型状态已更新");
|
||||
} catch (error) {
|
||||
row.enabled = !enabled;
|
||||
if (error !== "cancel" && error !== "close") show(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: Record<string, any>) {
|
||||
try {
|
||||
await ElMessageBox.confirm("模型将被软删除;已有使用记录不会删除。", "确认删除", { type: "warning" });
|
||||
await http.delete(`/resources/models/${row.id}`);
|
||||
toast.success("模型已删除");
|
||||
load();
|
||||
} catch (error) {
|
||||
if (error !== "cancel" && error !== "close") show(error);
|
||||
}
|
||||
}
|
||||
|
||||
function show(error: unknown) {
|
||||
const detail = apiError(error);
|
||||
toast.error(detail.message, detail.copyText);
|
||||
}
|
||||
|
||||
const typeText = (value: string) => ({ text: "文本", image: "图片", video: "视频" })[value] || value;
|
||||
const priceLabel = (value: string) => ({ default: "基础", input: "输入", output: "输出" })[value] || value;
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack management-page">
|
||||
<PageHeader
|
||||
title="模型管理"
|
||||
description="模型实际可用状态由模型和所属渠道的启用状态共同决定"
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="reset()"
|
||||
><AppIcon name="Plus" />新建模型</el-button
|
||||
>
|
||||
</PageHeader>
|
||||
<section class="panel filter-bar">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="模型名"
|
||||
/>
|
||||
<el-select
|
||||
v-model="query.model_type"
|
||||
clearable
|
||||
placeholder="模型类型"
|
||||
>
|
||||
<el-option
|
||||
label="文本"
|
||||
value="text"
|
||||
/><el-option
|
||||
label="图片"
|
||||
value="image"
|
||||
/><el-option
|
||||
label="视频"
|
||||
value="video"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="query.channel_id"
|
||||
clearable
|
||||
placeholder="所属渠道"
|
||||
>
|
||||
<el-option
|
||||
v-for="channel in channels"
|
||||
:key="channel.id"
|
||||
:label="channel.name"
|
||||
:value="channel.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="load"
|
||||
>查询</el-button
|
||||
>
|
||||
</section>
|
||||
<section
|
||||
class="panel table-panel"
|
||||
v-loading="loading"
|
||||
>
|
||||
<el-table :data="items">
|
||||
<el-table-column
|
||||
prop="name"
|
||||
label="模型名"
|
||||
min-width="150"
|
||||
/>
|
||||
<el-table-column
|
||||
label="类型"
|
||||
width="90"
|
||||
><template #default="{ row }"
|
||||
><el-tag
|
||||
size="small"
|
||||
effect="plain"
|
||||
>{{ typeText(row.model_type) }}</el-tag
|
||||
></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column
|
||||
label="所属渠道"
|
||||
min-width="130"
|
||||
><template #default="{ row }"
|
||||
><el-tag
|
||||
size="small"
|
||||
type="info"
|
||||
effect="plain"
|
||||
>{{ row.channel_name }}</el-tag
|
||||
></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column
|
||||
label="能力"
|
||||
min-width="180"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.model_type === 'text'">{{ row.multimodal ? "支持多模态" : "仅文本" }}</span>
|
||||
<span v-else-if="row.model_type === 'video'">视频生成</span>
|
||||
<span v-else>图片生成</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="模型价格"
|
||||
min-width="240"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div
|
||||
v-if="displayPrices(row).length"
|
||||
class="price-list"
|
||||
>
|
||||
<span
|
||||
v-for="price in displayPrices(row)"
|
||||
:key="price.price_key"
|
||||
>{{ priceLabel(price.price_key) }}:{{ Number(price.price).toFixed(2) }} 积分/{{ price.unit }}</span
|
||||
>
|
||||
</div>
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="启用"
|
||||
width="130"
|
||||
><template #default="{ row }"
|
||||
><el-tag
|
||||
v-if="!row.channel_enabled"
|
||||
size="small"
|
||||
type="warning"
|
||||
effect="plain"
|
||||
>渠道已禁用</el-tag
|
||||
><el-switch
|
||||
v-else
|
||||
:model-value="row.enabled"
|
||||
@change="toggle(row, Boolean($event))" /></template
|
||||
></el-table-column>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
width="140"
|
||||
fixed="right"
|
||||
><template #default="{ row }"
|
||||
><el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="reset(row)"
|
||||
>编辑</el-button
|
||||
><el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="remove(row)"
|
||||
>删除</el-button
|
||||
></template
|
||||
></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
<el-empty
|
||||
v-if="!loading && !items.length"
|
||||
description="暂无模型"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialog"
|
||||
:title="editing ? '编辑模型' : '新建模型'"
|
||||
width="min(820px, calc(100vw - 32px))"
|
||||
class="managed-scroll-dialog model-dialog"
|
||||
modal-class="managed-scroll-overlay"
|
||||
:lock-scroll="true"
|
||||
>
|
||||
<el-form
|
||||
:model="form"
|
||||
label-position="top"
|
||||
>
|
||||
<div class="form-grid model-identity-grid">
|
||||
<el-form-item
|
||||
label="模型名"
|
||||
required
|
||||
><el-input v-model.trim="form.name"
|
||||
/></el-form-item>
|
||||
<el-form-item
|
||||
label="所属渠道"
|
||||
required
|
||||
><el-select
|
||||
v-model="form.channel_id"
|
||||
class="field-full"
|
||||
><el-option
|
||||
v-for="channel in channels"
|
||||
:key="channel.id"
|
||||
:label="channel.name"
|
||||
:value="channel.id" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item
|
||||
label="模型类型"
|
||||
required
|
||||
><el-select
|
||||
v-model="form.model_type"
|
||||
class="field-full"
|
||||
><el-option
|
||||
label="文本"
|
||||
value="text" /><el-option
|
||||
label="图片"
|
||||
value="image" /><el-option
|
||||
label="视频"
|
||||
value="video" /></el-select
|
||||
></el-form-item>
|
||||
</div>
|
||||
<el-form-item
|
||||
v-if="form.model_type === 'text'"
|
||||
label="支持多模态"
|
||||
><el-switch v-model="form.multimodal"
|
||||
/></el-form-item>
|
||||
<div
|
||||
v-if="form.model_type === 'text'"
|
||||
class="text-billing-settings"
|
||||
>
|
||||
<el-form-item
|
||||
label="计费方式"
|
||||
required
|
||||
>
|
||||
<el-radio-group v-model="form.text_billing_mode">
|
||||
<el-radio-button label="per_request">按次计费</el-radio-button>
|
||||
<el-radio-button label="per_token">按 Token 计费</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<section class="model-price-section">
|
||||
<header class="model-price-section__header">
|
||||
<div>
|
||||
<h3>模型价格</h3>
|
||||
<p>输入用户执行对应任务时实际扣除的积分。</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="model-price-grid">
|
||||
<div
|
||||
v-for="[key, unit, label] in priceFields"
|
||||
:key="key"
|
||||
class="model-price-card"
|
||||
>
|
||||
<el-form-item :label="`${label}计费(积分/${unit})`">
|
||||
<AdminNumberInput
|
||||
v-model="form.price_values[key]"
|
||||
class="price-input"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</el-form>
|
||||
<template #footer
|
||||
><el-button @click="dialog = false">取消</el-button
|
||||
><el-button
|
||||
type="primary"
|
||||
@click="save"
|
||||
>保存</el-button
|
||||
></template
|
||||
>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.el-dialog.managed-scroll-dialog.model-dialog) {
|
||||
height: auto;
|
||||
}
|
||||
:global(.el-dialog.managed-scroll-dialog.model-dialog .el-dialog__body) {
|
||||
max-height: calc(100dvh - 180px);
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.model-identity-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.price-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.model-price-section {
|
||||
margin-top: 2px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.model-price-section__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.model-price-section h3 {
|
||||
font-size: 14px;
|
||||
}
|
||||
.model-price-section__header p {
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.model-price-rates {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
.model-price-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.model-price-card {
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.model-price-card :deep(.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.price-conversion {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
.price-conversion > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.price-conversion dt {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.price-conversion dd {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
.text-billing-settings {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.7fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.field-hint {
|
||||
margin-top: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.model-identity-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.model-price-section {
|
||||
padding: 14px;
|
||||
}
|
||||
.model-price-section__header {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.model-price-rates {
|
||||
text-align: left;
|
||||
}
|
||||
.model-price-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.text-billing-settings {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,475 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { ElMessageBox } from "element-plus";
|
||||
import http, { apiError } from "@/api/http";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import MasterDetailLayout from "@/components/MasterDetailLayout.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
type Prompt = Record<string, any>;
|
||||
interface PromptBackupItem {
|
||||
title: string;
|
||||
type: string;
|
||||
content: string;
|
||||
}
|
||||
const promptTypes = [
|
||||
"剧本反推",
|
||||
"剧本分析",
|
||||
"角色生成",
|
||||
"场景生成",
|
||||
"道具生成",
|
||||
"分镜图生成",
|
||||
"首尾帧生成",
|
||||
"视频生成",
|
||||
];
|
||||
const toast = useToastStore();
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const exporting = ref(false);
|
||||
const restoring = ref(false);
|
||||
const restoreInput = ref<HTMLInputElement>();
|
||||
const items = ref<Prompt[]>([]);
|
||||
const selectedId = ref("");
|
||||
const creating = ref(false);
|
||||
const query = reactive({ keyword: "", page: 1, page_size: 100 });
|
||||
const form = reactive({ name: "", type: "", content: "" });
|
||||
const selected = computed(() => items.value.find((item) => item.id === selectedId.value));
|
||||
|
||||
function reset(row?: Prompt) {
|
||||
Object.assign(form, {
|
||||
name: row?.name || "",
|
||||
type: row?.type || row?.category || "",
|
||||
content: row?.content || row?.version_content || "",
|
||||
});
|
||||
selectedId.value = row?.id || "";
|
||||
creating.value = !row;
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await http.get("/prompts", { params: query });
|
||||
items.value = data.data?.items || [];
|
||||
if (selectedId.value) {
|
||||
const current = items.value.find((item) => item.id === selectedId.value);
|
||||
if (current) reset(current);
|
||||
else reset();
|
||||
} else if (items.value.length) reset(items.value[0]);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
function create() {
|
||||
reset();
|
||||
}
|
||||
function validate() {
|
||||
if (!form.name.trim()) return "请输入提示词名称";
|
||||
if (!form.type) return "请选择适用类型";
|
||||
if (!form.content.trim()) return "请输入提示词内容";
|
||||
return "";
|
||||
}
|
||||
async function save() {
|
||||
const message = validate();
|
||||
if (message) {
|
||||
toast.warning(message);
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = { name: form.name.trim(), type: form.type, content: form.content };
|
||||
if (selectedId.value) await http.put(`/prompts/${selectedId.value}`, payload);
|
||||
else {
|
||||
const { data } = await http.post("/prompts", payload);
|
||||
selectedId.value = data.data.id;
|
||||
}
|
||||
creating.value = false;
|
||||
toast.success("提示词已保存");
|
||||
await load();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
async function remove() {
|
||||
if (!selected.value) return;
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除提示词“${selected.value.name || "未命名提示词"}”吗?此操作不可恢复。`,
|
||||
"确认删除",
|
||||
{ type: "warning" },
|
||||
);
|
||||
await http.delete(`/prompts/${selectedId.value}`);
|
||||
toast.success("提示词已删除");
|
||||
selectedId.value = "";
|
||||
reset();
|
||||
await load();
|
||||
} catch (error) {
|
||||
if (error !== "cancel" && error !== "close") showError(error);
|
||||
}
|
||||
}
|
||||
async function fetchAllPrompts() {
|
||||
const result: Prompt[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const { data } = await http.get("/prompts", { params: { page, page_size: 100 } });
|
||||
const current = data.data?.items || [];
|
||||
result.push(...current);
|
||||
if (result.length >= Number(data.data?.total || 0) || !current.length) return result;
|
||||
page += 1;
|
||||
}
|
||||
}
|
||||
function backupFilename() {
|
||||
const value = new Date()
|
||||
.toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false })
|
||||
.replace(/[-:]/g, "")
|
||||
.replace(" ", "-");
|
||||
return `prompts-backup-${value}.json`;
|
||||
}
|
||||
async function exportPrompts() {
|
||||
exporting.value = true;
|
||||
try {
|
||||
const prompts = await fetchAllPrompts();
|
||||
const backup = {
|
||||
format: "juhe-factory-prompts",
|
||||
version: 1,
|
||||
exported_at: new Date().toISOString(),
|
||||
prompts: prompts.map((item) => ({
|
||||
title: String(item.name || ""),
|
||||
type: String(item.type || ""),
|
||||
content: String(item.content || ""),
|
||||
})),
|
||||
};
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([JSON.stringify(backup, null, 2)], { type: "application/json;charset=utf-8" }),
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = backupFilename();
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(`已导出 ${prompts.length} 条提示词`);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
function parseBackup(value: string): PromptBackupItem[] {
|
||||
const parsed = JSON.parse(value) as { prompts?: unknown } | unknown[];
|
||||
const source = Array.isArray(parsed) ? parsed : parsed?.prompts;
|
||||
if (!Array.isArray(source) || !source.length) throw new Error("JSON 中没有可恢复的提示词");
|
||||
const titles = new Set<string>();
|
||||
return source.map((raw, index) => {
|
||||
if (!raw || typeof raw !== "object") throw new Error(`第 ${index + 1} 条提示词格式无效`);
|
||||
const item = raw as Record<string, unknown>;
|
||||
const title = String(item.title || item.name || "").trim();
|
||||
const type = String(item.type || "").trim();
|
||||
const content = String(item.content || "").trim();
|
||||
if (!title || !type || !content) throw new Error(`第 ${index + 1} 条提示词缺少标题、类型或内容`);
|
||||
if (titles.has(title)) throw new Error(`JSON 中存在重复标题:${title}`);
|
||||
titles.add(title);
|
||||
return { title, type, content };
|
||||
});
|
||||
}
|
||||
function chooseRestore() {
|
||||
restoreInput.value?.click();
|
||||
}
|
||||
async function restorePrompts(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
restoring.value = true;
|
||||
try {
|
||||
const backup = parseBackup(await file.text());
|
||||
const existing = await fetchAllPrompts();
|
||||
const byTitle = new Map<string, Prompt>();
|
||||
for (const item of existing)
|
||||
if (!byTitle.has(String(item.name || "").trim())) byTitle.set(String(item.name || "").trim(), item);
|
||||
const overwriteCount = backup.filter((item) => byTitle.has(item.title)).length;
|
||||
await ElMessageBox.confirm(
|
||||
`文件包含 ${backup.length} 条提示词,将覆盖 ${overwriteCount} 条、新增 ${backup.length - overwriteCount} 条。是否继续恢复?`,
|
||||
"确认恢复提示词",
|
||||
{ type: "warning", confirmButtonText: "确认恢复", cancelButtonText: "取消" },
|
||||
);
|
||||
for (const item of backup) {
|
||||
const payload = { name: item.title, type: item.type, content: item.content };
|
||||
const current = byTitle.get(item.title);
|
||||
if (current) await http.put(`/prompts/${current.id}`, payload);
|
||||
else await http.post("/prompts", payload);
|
||||
}
|
||||
toast.success(`恢复完成:覆盖 ${overwriteCount} 条,新增 ${backup.length - overwriteCount} 条`);
|
||||
await load();
|
||||
} catch (error) {
|
||||
if (error !== "cancel" && error !== "close") {
|
||||
if (error instanceof SyntaxError) toast.error("JSON 文件格式错误");
|
||||
else if (error instanceof Error && !("response" in error)) toast.error(error.message);
|
||||
else showError(error);
|
||||
}
|
||||
} finally {
|
||||
restoring.value = false;
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
function showError(error: unknown) {
|
||||
const detail = apiError(error);
|
||||
toast.error(detail.message, detail.copyText);
|
||||
}
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MasterDetailLayout
|
||||
title="提示词管理"
|
||||
description="管理系统提示词,保存后立即生效"
|
||||
empty-text="请从左侧选择提示词,或点击新增提示词"
|
||||
:has-selection="Boolean(selectedId) || creating"
|
||||
>
|
||||
<template #header-actions>
|
||||
<div class="header-actions">
|
||||
<input
|
||||
ref="restoreInput"
|
||||
class="restore-input"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
@change="restorePrompts"
|
||||
/>
|
||||
<el-button
|
||||
:loading="exporting"
|
||||
:disabled="restoring"
|
||||
@click="exportPrompts"
|
||||
><AppIcon name="Upload" />导出</el-button
|
||||
>
|
||||
<el-button
|
||||
:loading="restoring"
|
||||
:disabled="exporting"
|
||||
@click="chooseRestore"
|
||||
><AppIcon name="Download" />恢复</el-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<template #list-toolbar
|
||||
><el-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="搜索提示词名称"
|
||||
><template #prefix><AppIcon name="Search" /></template></el-input
|
||||
></template>
|
||||
<template #list>
|
||||
<el-button
|
||||
class="create-prompt-button"
|
||||
type="primary"
|
||||
@click="create"
|
||||
><AppIcon name="Plus" />新建提示词</el-button
|
||||
>
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="master-item"
|
||||
:class="{ active: item.id === selectedId }"
|
||||
type="button"
|
||||
@click="reset(item)"
|
||||
>
|
||||
<strong>{{ item.name || "未命名提示词" }}</strong
|
||||
><span>{{ item.type || item.category || "未分类" }}</span>
|
||||
</button>
|
||||
<el-empty
|
||||
v-if="!loading && !items.length"
|
||||
description="暂无提示词"
|
||||
:image-size="70"
|
||||
/>
|
||||
</template>
|
||||
<template #detail>
|
||||
<header class="editor-heading">
|
||||
<div>
|
||||
<span>{{ selectedId ? "编辑提示词" : "新增提示词" }}</span>
|
||||
<h3>{{ form.name || "未命名提示词" }}</h3>
|
||||
</div>
|
||||
</header>
|
||||
<el-form
|
||||
class="editor-form"
|
||||
:model="form"
|
||||
label-position="top"
|
||||
>
|
||||
<div class="form-grid">
|
||||
<el-form-item
|
||||
label="提示词名称"
|
||||
required
|
||||
><el-input
|
||||
v-model.trim="form.name"
|
||||
placeholder="请输入提示词名称"
|
||||
/></el-form-item>
|
||||
<el-form-item
|
||||
label="适用类型"
|
||||
required
|
||||
><el-select
|
||||
v-model="form.type"
|
||||
placeholder="请选择适用类型"
|
||||
class="type-select"
|
||||
filterable
|
||||
><el-option
|
||||
v-for="type in promptTypes"
|
||||
:key="type"
|
||||
:label="type"
|
||||
:value="type" /></el-select
|
||||
></el-form-item>
|
||||
</div>
|
||||
<el-form-item
|
||||
class="prompt-content-field"
|
||||
label="提示词内容"
|
||||
required
|
||||
><el-input
|
||||
v-model="form.content"
|
||||
type="textarea"
|
||||
:rows="16"
|
||||
resize="none"
|
||||
placeholder="请输入提示词内容"
|
||||
/></el-form-item>
|
||||
</el-form>
|
||||
<footer class="editor-actions">
|
||||
<el-button
|
||||
v-if="selectedId"
|
||||
type="danger"
|
||||
plain
|
||||
@click="remove"
|
||||
><AppIcon name="Delete" />删除</el-button
|
||||
><el-button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
>保存</el-button
|
||||
>
|
||||
</footer>
|
||||
</template>
|
||||
</MasterDetailLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.header-actions :deep(.el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
.restore-input {
|
||||
display: none;
|
||||
}
|
||||
.master-item {
|
||||
width: 100%;
|
||||
padding: 12px 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.16s,
|
||||
border-color 0.16s;
|
||||
}
|
||||
.master-item:hover {
|
||||
background: #eef4ff;
|
||||
}
|
||||
.master-item.active {
|
||||
border-color: #b8cff5;
|
||||
background: #eaf2ff;
|
||||
}
|
||||
.master-item strong {
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.master-item span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.create-prompt-button {
|
||||
width: 100%;
|
||||
margin: 0 0 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
.editor-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
.editor-heading span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.editor-heading h3 {
|
||||
margin-top: 4px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.editor-form {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
padding-top: 22px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.type-select {
|
||||
width: 100%;
|
||||
}
|
||||
.prompt-content-field {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
.prompt-content-field :deep(.el-form-item__content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.prompt-content-field :deep(.el-textarea) {
|
||||
height: 100%;
|
||||
}
|
||||
.prompt-content-field :deep(.el-textarea__inner) {
|
||||
height: 100%;
|
||||
min-height: 420px !important;
|
||||
}
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
:deep(.master-editor) {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.prompt-content-field :deep(.el-textarea__inner) {
|
||||
min-height: 360px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,271 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import http, { apiError } from "@/api/http";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import AdminNumberInput from "@/components/AdminNumberInput.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
const toast = useToastStore(),
|
||||
loading = ref(false),
|
||||
generating = ref(false),
|
||||
dialog = ref(false),
|
||||
resultDialog = ref(false),
|
||||
items = ref<Record<string, any>[]>([]),
|
||||
codes = ref<string[]>([]),
|
||||
total = ref(0);
|
||||
async function copyCode(code: string) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
toast.success("兑换码已复制");
|
||||
}
|
||||
const query = reactive({ keyword: "", status: "", page: 1, page_size: 15 });
|
||||
const form = reactive({ name: "", points: 10, quantity: 1, expires_at: "" });
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await http.get("/redemption-codes", { params: query });
|
||||
items.value = data.data.items;
|
||||
total.value = data.data.total;
|
||||
} catch (e) {
|
||||
show(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
function open() {
|
||||
Object.assign(form, { name: "", points: 10, quantity: 1, expires_at: "" });
|
||||
dialog.value = true;
|
||||
}
|
||||
async function generate() {
|
||||
const points = Number(form.points);
|
||||
const quantity = Number(form.quantity);
|
||||
if (!Number.isFinite(points) || points < 1 || points > 100) {
|
||||
toast.warning("单码积分数量必须为 1 至 100");
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 10) {
|
||||
toast.warning("生成数量必须为 1 至 10");
|
||||
return;
|
||||
}
|
||||
generating.value = true;
|
||||
try {
|
||||
const { data } = await http.post("/redemption-batches", form);
|
||||
codes.value = data.data.codes;
|
||||
dialog.value = false;
|
||||
resultDialog.value = true;
|
||||
toast.success("兑换码批次生成成功");
|
||||
load();
|
||||
} catch (e) {
|
||||
show(e);
|
||||
} finally {
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
async function copyAll() {
|
||||
await navigator.clipboard.writeText(codes.value.join("\n"));
|
||||
toast.success("完整兑换码已复制");
|
||||
}
|
||||
function exportCodes() {
|
||||
const blob = new Blob([codes.value.join("\r\n")], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `redemption-codes-${Date.now()}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
function closeResult() {
|
||||
codes.value = [];
|
||||
resultDialog.value = false;
|
||||
}
|
||||
function show(e: unknown) {
|
||||
const d = apiError(e);
|
||||
toast.error(d.message, d.copyText);
|
||||
}
|
||||
const statusText = (v: string) => ({ unused: "未使用", used: "已使用", expired: "已过期" })[v] || v;
|
||||
const fmt = (v: any) =>
|
||||
v ? new Date(v).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false }) : "长期有效";
|
||||
onMounted(load);
|
||||
</script>
|
||||
<template>
|
||||
<div class="page-stack management-page">
|
||||
<PageHeader
|
||||
title="兑换码管理"
|
||||
description="完整兑换码可在列表中直接查看并复制"
|
||||
><el-button
|
||||
type="primary"
|
||||
@click="open"
|
||||
>
|
||||
<AppIcon name="Plus" />生成兑换码
|
||||
</el-button></PageHeader
|
||||
>
|
||||
<section class="panel filter-bar">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="批次、兑换码或用户 UID"
|
||||
/><el-select
|
||||
v-model="query.status"
|
||||
clearable
|
||||
placeholder="状态"
|
||||
><el-option
|
||||
label="未使用"
|
||||
value="unused" /><el-option
|
||||
label="已使用"
|
||||
value="used" /><el-option
|
||||
label="已过期"
|
||||
value="expired" /></el-select
|
||||
><el-button
|
||||
type="primary"
|
||||
@click="
|
||||
query.page = 1;
|
||||
load();
|
||||
"
|
||||
>查询</el-button
|
||||
>
|
||||
</section>
|
||||
<section
|
||||
class="panel table-panel"
|
||||
v-loading="loading"
|
||||
>
|
||||
<el-table :data="items"
|
||||
><el-table-column
|
||||
prop="code"
|
||||
label="兑换码"
|
||||
min-width="230"
|
||||
show-overflow-tooltip
|
||||
><template #default="{ row }"
|
||||
><span class="code-cell">{{ row.code || "--" }}</span></template
|
||||
></el-table-column
|
||||
><el-table-column
|
||||
label="操作"
|
||||
width="90"
|
||||
fixed="right"
|
||||
><template #default="{ row }"
|
||||
><el-button
|
||||
v-if="row.code"
|
||||
link
|
||||
type="primary"
|
||||
@click="copyCode(row.code)"
|
||||
><AppIcon name="CopyDocument" />复制</el-button
|
||||
></template
|
||||
></el-table-column
|
||||
><el-table-column
|
||||
prop="batch_name"
|
||||
label="所属批次"
|
||||
min-width="140" /><el-table-column
|
||||
label="积分数量"
|
||||
width="110"
|
||||
><template #default="{ row }">{{ Number(row.points).toFixed(2) }}</template></el-table-column
|
||||
><el-table-column
|
||||
label="状态"
|
||||
width="100"
|
||||
><template #default="{ row }"
|
||||
><el-tag effect="plain">{{ statusText(row.status) }}</el-tag></template
|
||||
></el-table-column
|
||||
><el-table-column
|
||||
prop="redeemed_uid"
|
||||
label="兑换用户 UID"
|
||||
width="130" /><el-table-column
|
||||
label="兑换时间"
|
||||
min-width="180"
|
||||
><template #default="{ row }">{{ row.redeemed_at ? fmt(row.redeemed_at) : "--" }}</template></el-table-column
|
||||
><el-table-column
|
||||
label="失效时间"
|
||||
min-width="180"
|
||||
><template #default="{ row }">{{
|
||||
row.status === "used" ? "--" : fmt(row.expires_at)
|
||||
}}</template></el-table-column
|
||||
><el-table-column
|
||||
prop="created_by"
|
||||
label="创建管理员"
|
||||
width="130" /></el-table
|
||||
><el-empty
|
||||
v-if="!loading && !items.length"
|
||||
description="暂无兑换码"
|
||||
/><el-pagination
|
||||
v-if="total > query.page_size"
|
||||
v-model:current-page="query.page"
|
||||
background
|
||||
layout="total,prev,pager,next"
|
||||
:total="total"
|
||||
@current-change="load"
|
||||
/>
|
||||
</section>
|
||||
<el-dialog
|
||||
v-model="dialog"
|
||||
title="按批次生成兑换码"
|
||||
width="min(560px, calc(100vw - 32px))"
|
||||
><el-form
|
||||
:model="form"
|
||||
label-position="top"
|
||||
><el-form-item
|
||||
label="批次名称"
|
||||
required
|
||||
><el-input v-model.trim="form.name"
|
||||
/></el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item
|
||||
label="单码积分数量"
|
||||
required
|
||||
><AdminNumberInput
|
||||
v-model="form.points"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:precision="2" /></el-form-item
|
||||
><el-form-item
|
||||
label="生成数量"
|
||||
required
|
||||
><AdminNumberInput
|
||||
v-model="form.quantity"
|
||||
:min="1"
|
||||
:max="10"
|
||||
:precision="0"
|
||||
/></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="有效期"
|
||||
><el-date-picker
|
||||
v-model="form.expires_at"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
class="field-full"
|
||||
/></el-form-item> </el-form
|
||||
><template #footer
|
||||
><el-button @click="dialog = false">取消</el-button
|
||||
><el-button
|
||||
type="primary"
|
||||
:loading="generating"
|
||||
:disabled="generating"
|
||||
@click="generate"
|
||||
>生成</el-button
|
||||
></template
|
||||
></el-dialog
|
||||
>
|
||||
<el-dialog
|
||||
:model-value="resultDialog"
|
||||
title="完整兑换码(仅展示一次)"
|
||||
width="min(720px, calc(100vw - 32px))"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
:show-close="false"
|
||||
><el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="关闭后系统无法再次查看完整兑换码,请立即复制或导出。"
|
||||
/><el-input
|
||||
class="code-output"
|
||||
:model-value="codes.join('\n')"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
readonly
|
||||
/><template #footer
|
||||
><el-button @click="copyAll"> <AppIcon name="CopyDocument" />复制全部 </el-button
|
||||
><el-button @click="exportCodes"> <AppIcon name="Download" />导出文件 </el-button
|
||||
><el-button
|
||||
type="primary"
|
||||
@click="closeResult"
|
||||
>我已保存,关闭</el-button
|
||||
></template
|
||||
></el-dialog
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import http, { apiError, clearAuth } from "@/api/http";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const toast = useToastStore();
|
||||
const formRef = ref<FormInstance>();
|
||||
const saving = ref(false);
|
||||
const form = reactive({ currentPassword: "", newPassword: "", confirmPassword: "" });
|
||||
|
||||
const rules: FormRules = {
|
||||
currentPassword: [{ required: true, message: "请输入当前密码", trigger: "blur" }],
|
||||
newPassword: [
|
||||
{ required: true, message: "请输入新密码", trigger: "blur" },
|
||||
{ min: 8, max: 20, message: "密码长度必须为 8~20 位", trigger: "blur" },
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: "请再次输入新密码", trigger: "blur" },
|
||||
{
|
||||
validator: (_rule, value, callback) =>
|
||||
value === form.newPassword ? callback() : callback(new Error("两次输入的新密码不一致")),
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function submit() {
|
||||
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await http.put("/auth/password", {
|
||||
current_password: form.currentPassword,
|
||||
new_password: form.newPassword,
|
||||
confirm_password: form.confirmPassword,
|
||||
});
|
||||
clearAuth();
|
||||
auth.profile = null;
|
||||
toast.success("密码已修改,请使用新密码重新登录");
|
||||
await router.replace("/login");
|
||||
} catch (error) {
|
||||
const detail = apiError(error);
|
||||
toast.error(detail.message, detail.copyText);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack security-page">
|
||||
<PageHeader title="账号安全" />
|
||||
|
||||
<section class="panel security-panel">
|
||||
<div class="security-intro">
|
||||
<span class="security-icon"
|
||||
><AppIcon
|
||||
name="Lock"
|
||||
:size="22"
|
||||
/></span>
|
||||
<div>
|
||||
<h3>修改管理员密码</h3>
|
||||
<p>需验证当前密码。修改成功后将退出当前账号,请使用新密码重新登录。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="password-form"
|
||||
>
|
||||
<el-form-item
|
||||
label="当前密码"
|
||||
prop="currentPassword"
|
||||
>
|
||||
<el-input
|
||||
v-model="form.currentPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
show-password
|
||||
placeholder="请输入当前密码"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
label="新密码"
|
||||
prop="newPassword"
|
||||
>
|
||||
<el-input
|
||||
v-model="form.newPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
show-password
|
||||
placeholder="请输入 8~20 位新密码"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
label="确认新密码"
|
||||
prop="confirmPassword"
|
||||
>
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
show-password
|
||||
placeholder="请再次输入新密码"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="form-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@click="submit"
|
||||
>确认修改</el-button
|
||||
>
|
||||
</div>
|
||||
</el-form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.security-page {
|
||||
max-width: 760px;
|
||||
}
|
||||
.security-panel {
|
||||
padding: 24px;
|
||||
}
|
||||
.security-intro {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
.security-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.security-intro h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.security-intro p {
|
||||
margin-top: 5px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.password-form {
|
||||
max-width: 480px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 4px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.security-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
.password-form {
|
||||
max-width: none;
|
||||
}
|
||||
.form-actions .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,437 @@
|
||||
<!-- 视觉风格管理页,负责风格图片、名称和排序维护。 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { ElMessageBox, type FormInstance, type UploadRequestOptions } from "element-plus";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import http, { apiError } from "@/api/http";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
type UploadError = Error & { status: number; method: string; url: string };
|
||||
const toast = useToastStore();
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const dialogOpen = ref(false);
|
||||
const editingId = ref("");
|
||||
const items = ref<Record<string, any>[]>([]);
|
||||
const formRef = ref<FormInstance>();
|
||||
const form = reactive<Record<string, any>>({ name: "", image: "", image_key: "" });
|
||||
const draggedId = ref("");
|
||||
const originalOrder = ref<string[]>([]);
|
||||
const reordering = ref(false);
|
||||
const canReorder = computed(() => items.value.length > 1 && !reordering.value);
|
||||
const rules = { name: [{ required: true, message: "请填写风格名", trigger: "blur" }] };
|
||||
|
||||
// 加载风格列表,并保留接口返回的排序结果。
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await http.get("/resources/styles", { params: { page: 1, page_size: 100 } });
|
||||
items.value = data.data.items || [];
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
// 重置弹窗表单;编辑时回填当前风格及其图片信息。
|
||||
function reset(row?: Record<string, any>) {
|
||||
editingId.value = row?.id || "";
|
||||
Object.assign(form, {
|
||||
name: row?.name || "",
|
||||
image: row?.image_url || "",
|
||||
image_key: row?.image_key || "",
|
||||
image_url: row?.image_url || "",
|
||||
image_mime: row?.image_mime || "",
|
||||
image_size: row?.image_size || "",
|
||||
image_width: row?.image_width || "",
|
||||
image_height: row?.image_height || "",
|
||||
});
|
||||
}
|
||||
// 打开新建风格弹窗。
|
||||
function openCreate() {
|
||||
reset();
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
// 打开编辑风格弹窗并回填目标风格。
|
||||
function openEdit(row: Record<string, any>) {
|
||||
reset(row);
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
// 上传风格图片,并将服务端返回的图片元数据写入表单。
|
||||
async function upload(options: UploadRequestOptions) {
|
||||
const data = new FormData();
|
||||
data.append("file", options.file);
|
||||
try {
|
||||
const response = await http.post("/uploads/style-images", data, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
Object.assign(form, response.data.data);
|
||||
form.image = response.data.data.image_url || response.data.data.image_key;
|
||||
options.onSuccess(response.data);
|
||||
} catch (error) {
|
||||
const uploadError = Object.assign(error instanceof Error ? error : new Error(String(error)), {
|
||||
status: 0,
|
||||
method: "POST",
|
||||
url: "/uploads/style-images",
|
||||
}) as UploadError;
|
||||
options.onError(uploadError);
|
||||
showError(error);
|
||||
}
|
||||
}
|
||||
// 校验并保存风格;图片上传成功后才允许提交。
|
||||
async function save() {
|
||||
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||
if (!form.image_key) {
|
||||
toast.warning("请先上传风格图片");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = { ...form };
|
||||
delete payload.image;
|
||||
if (editingId.value) await http.put(`/resources/styles/${editingId.value}`, payload);
|
||||
else await http.post("/resources/styles", payload);
|
||||
dialogOpen.value = false;
|
||||
toast.success(editingId.value ? "风格已更新" : "风格已创建");
|
||||
await load();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
// 删除指定风格,并在成功后刷新列表。
|
||||
async function remove(row: Record<string, any>) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除风格“${row.name}”吗?`, "删除风格", {
|
||||
type: "warning",
|
||||
confirmButtonText: "删除",
|
||||
cancelButtonText: "取消",
|
||||
});
|
||||
await http.delete(`/resources/styles/${row.id}`);
|
||||
toast.success("风格已删除");
|
||||
await load();
|
||||
} catch (error) {
|
||||
if (error !== "cancel" && error !== "close") showError(error);
|
||||
}
|
||||
}
|
||||
// 记录拖拽起点和原始顺序,用于提交排序或失败回滚。
|
||||
function startDrag(row: Record<string, any>, event: DragEvent) {
|
||||
if (!canReorder.value) return;
|
||||
draggedId.value = row.id;
|
||||
originalOrder.value = items.value.map((item) => item.id);
|
||||
event.dataTransfer?.setData("text/plain", row.id);
|
||||
}
|
||||
// 根据当前悬停位置实时调整风格卡片顺序。
|
||||
function moveOver(targetId: string) {
|
||||
const sourceIndex = items.value.findIndex((item) => item.id === draggedId.value);
|
||||
const targetIndex = items.value.findIndex((item) => item.id === targetId);
|
||||
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return;
|
||||
const [moved] = items.value.splice(sourceIndex, 1);
|
||||
items.value.splice(targetIndex, 0, moved);
|
||||
}
|
||||
// 提交拖拽后的顺序;失败时恢复原始顺序。
|
||||
async function finishDrag() {
|
||||
if (!draggedId.value) return;
|
||||
const nextOrder = items.value.map((item) => item.id);
|
||||
draggedId.value = "";
|
||||
if (nextOrder.every((id, index) => id === originalOrder.value[index])) return;
|
||||
reordering.value = true;
|
||||
try {
|
||||
await http.put("/resources/styles/reorder", { ids: nextOrder });
|
||||
toast.success("风格排序已保存");
|
||||
} catch (error) {
|
||||
const positions = new Map(originalOrder.value.map((id, index) => [id, index]));
|
||||
items.value.sort((a, b) => (positions.get(a.id) ?? 0) - (positions.get(b.id) ?? 0));
|
||||
showError(error);
|
||||
} finally {
|
||||
reordering.value = false;
|
||||
}
|
||||
}
|
||||
// 将接口异常转换为管理端统一提示。
|
||||
function showError(error: unknown) {
|
||||
const detail = apiError(error);
|
||||
toast.error(detail.message, detail.copyText);
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack management-page">
|
||||
<PageHeader
|
||||
title="风格管理"
|
||||
description="管理项目风格、展示图片与排序"
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="openCreate"
|
||||
><AppIcon name="Plus" />新建风格</el-button
|
||||
>
|
||||
</PageHeader>
|
||||
<section
|
||||
v-loading="loading"
|
||||
class="panel style-grid-panel"
|
||||
>
|
||||
<div
|
||||
v-if="items.length"
|
||||
class="style-card-grid"
|
||||
>
|
||||
<article
|
||||
v-for="row in items"
|
||||
:key="row.id"
|
||||
class="style-card"
|
||||
:class="{ 'is-dragging': draggedId === row.id }"
|
||||
@dragover.prevent="moveOver(row.id)"
|
||||
@drop.prevent="finishDrag"
|
||||
>
|
||||
<div class="style-card__visual">
|
||||
<img
|
||||
:src="row.image_url"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
<span
|
||||
class="style-card__drag"
|
||||
:draggable="canReorder"
|
||||
title="拖动排序"
|
||||
@dragstart="startDrag(row, $event)"
|
||||
@dragend="finishDrag"
|
||||
>
|
||||
<AppIcon name="Rank" />
|
||||
</span>
|
||||
<div class="style-card__actions">
|
||||
<el-button
|
||||
circle
|
||||
title="编辑风格"
|
||||
@click="openEdit(row)"
|
||||
><AppIcon name="EditPen"
|
||||
/></el-button>
|
||||
<el-button
|
||||
circle
|
||||
type="danger"
|
||||
plain
|
||||
title="删除风格"
|
||||
@click="remove(row)"
|
||||
><AppIcon name="Delete"
|
||||
/></el-button>
|
||||
</div>
|
||||
</div>
|
||||
<strong>{{ row.name }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
<el-empty
|
||||
v-else
|
||||
description="暂无风格"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogOpen"
|
||||
:title="editingId ? '编辑风格' : '新建风格'"
|
||||
class="style-dialog"
|
||||
width="min(720px, calc(100vw - 32px))"
|
||||
align-center
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="style-form"
|
||||
>
|
||||
<div class="style-form__preview-column">
|
||||
<span class="style-form__label">图片预览</span>
|
||||
<div class="upload-preview-frame">
|
||||
<img
|
||||
v-if="form.image"
|
||||
class="upload-preview"
|
||||
:src="form.image"
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="upload-preview-empty"
|
||||
>
|
||||
<AppIcon name="Picture" />
|
||||
<span>9:16 图片预览</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="style-form__fields">
|
||||
<el-form-item
|
||||
label="风格名"
|
||||
prop="name"
|
||||
><el-input v-model.trim="form.name"
|
||||
/></el-form-item>
|
||||
<el-form-item
|
||||
label="风格图片"
|
||||
required
|
||||
>
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:http-request="upload"
|
||||
accept="image/jpeg,image/png,image/gif"
|
||||
>
|
||||
<el-button><AppIcon name="Upload" />{{ form.image ? "重新上传" : "上传图片" }}</el-button>
|
||||
</el-upload>
|
||||
<p class="field-helper">推荐 9:16,支持 JPEG、PNG、GIF,最大 10MB</p>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer
|
||||
><el-button @click="dialogOpen = false">取消</el-button
|
||||
><el-button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
>保存</el-button
|
||||
></template
|
||||
>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.style-grid-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.style-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.style-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
transition:
|
||||
border-color 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
.style-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.style-card.is-dragging {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.style-card__visual {
|
||||
height: 230px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.style-card__visual img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.style-card__drag {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
background: #111827;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
}
|
||||
.style-card__actions {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.style-card > strong {
|
||||
padding: 13px 14px;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.style-form {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
gap: var(--space-lg);
|
||||
align-items: start;
|
||||
}
|
||||
.style-form__preview-column,
|
||||
.style-form__fields {
|
||||
min-width: 0;
|
||||
}
|
||||
.style-form__label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-sm);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 22px;
|
||||
}
|
||||
.style-form__label::before {
|
||||
margin-right: var(--space-xs);
|
||||
color: var(--color-danger);
|
||||
content: "*";
|
||||
}
|
||||
.upload-preview-frame {
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.upload-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.upload-preview-empty {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.upload-preview-empty :deep(.el-icon) {
|
||||
font-size: 28px;
|
||||
}
|
||||
.style-form__fields :deep(.el-form-item:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.field-helper {
|
||||
width: 100%;
|
||||
margin: var(--space-sm) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.style-card-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.style-card__visual {
|
||||
height: 190px;
|
||||
}
|
||||
.style-form {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.upload-preview-frame {
|
||||
width: min(180px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,730 @@
|
||||
<!-- 管理后台用户管理页,负责用户创建、状态维护、每日限额和积分发放。 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import { ElMessageBox, type FormInstance } from "element-plus";
|
||||
import http, { apiError } from "@/api/http";
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import AdminNumberInput from "@/components/AdminNumberInput.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const toast = useToastStore(),
|
||||
loading = ref(false),
|
||||
items = ref<Record<string, any>[]>([]),
|
||||
total = ref(0),
|
||||
selected = ref<Record<string, any>[]>([]),
|
||||
dialog = ref<"single" | "batch" | "">(""),
|
||||
pointGrantOpen = ref(false),
|
||||
pointGrantTarget = ref<Record<string, any>>(),
|
||||
grantingPoints = ref(false),
|
||||
editingLimitId = ref(""),
|
||||
editingLimitValue = ref<number | null>(null),
|
||||
savingLimitId = ref("");
|
||||
const query = reactive({ keyword: "", enabled: "", page: 1, page_size: 15 });
|
||||
const form = reactive({
|
||||
account: "",
|
||||
prefix: "",
|
||||
start_sequence: "001",
|
||||
end_sequence: "001",
|
||||
password: "",
|
||||
daily_limit: 9999,
|
||||
});
|
||||
const pointGrantForm = reactive({ points: null as number | null, reason: "", request_id: "" });
|
||||
const formRef = ref<FormInstance>();
|
||||
const dialogOpen = computed({
|
||||
get: () => dialog.value !== "",
|
||||
set: (value: boolean) => {
|
||||
if (!value) dialog.value = "";
|
||||
},
|
||||
});
|
||||
const canGrantPoints = computed(
|
||||
() =>
|
||||
Number.isFinite(Number(pointGrantForm.points)) &&
|
||||
Number(pointGrantForm.points) > 0 &&
|
||||
Boolean(pointGrantForm.reason.trim()),
|
||||
);
|
||||
const batchPreview = computed(() => {
|
||||
const prefix = form.prefix.trim();
|
||||
const startRaw = form.start_sequence.trim();
|
||||
const endRaw = form.end_sequence.trim();
|
||||
if (!prefix) return { error: "请输入账号前缀", count: 0, first: "", last: "" };
|
||||
if (!/^\d+$/.test(startRaw) || !/^\d+$/.test(endRaw)) {
|
||||
return { error: "起始序号和终止序号必须为数字", count: 0, first: "", last: "" };
|
||||
}
|
||||
const start = Number(startRaw);
|
||||
const end = Number(endRaw);
|
||||
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)) {
|
||||
return { error: "序号超出有效范围", count: 0, first: "", last: "" };
|
||||
}
|
||||
if (start > end) return { error: "起始序号不能大于终止序号", count: 0, first: "", last: "" };
|
||||
const count = end - start + 1;
|
||||
if (count > 50) return { error: "每次最多批量创建 50 个用户", count, first: "", last: "" };
|
||||
const width = Math.max(startRaw.length, endRaw.length);
|
||||
return {
|
||||
error: "",
|
||||
count,
|
||||
first: `${prefix}-${String(start).padStart(width, "0")}`,
|
||||
last: `${prefix}-${String(end).padStart(width, "0")}`,
|
||||
};
|
||||
});
|
||||
async function load(silent = false) {
|
||||
if (silent && editingLimitId.value) return;
|
||||
if (!silent) loading.value = true;
|
||||
try {
|
||||
const { data } = await http.get("/users", { params: query });
|
||||
items.value = data.data.items;
|
||||
total.value = data.data.total;
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
function editDailyLimit(row: Record<string, any>) {
|
||||
editingLimitId.value = row.id;
|
||||
editingLimitValue.value = Number(row.daily_limit);
|
||||
}
|
||||
function cancelDailyLimitEdit() {
|
||||
editingLimitId.value = "";
|
||||
editingLimitValue.value = null;
|
||||
}
|
||||
function blurLimitInput(event: KeyboardEvent) {
|
||||
(event.target as HTMLInputElement).blur();
|
||||
}
|
||||
async function saveDailyLimit(row: Record<string, any>) {
|
||||
if (editingLimitId.value !== row.id) return;
|
||||
const value = Number(editingLimitValue.value);
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
toast.warning("每日限额不能小于 0");
|
||||
cancelDailyLimitEdit();
|
||||
return;
|
||||
}
|
||||
if (value === Number(row.daily_limit)) {
|
||||
cancelDailyLimitEdit();
|
||||
return;
|
||||
}
|
||||
savingLimitId.value = row.id;
|
||||
try {
|
||||
await http.patch("/users/batch", {
|
||||
ids: [row.id],
|
||||
daily_limit: value.toFixed(2),
|
||||
reason: "管理员原地修改每日限额",
|
||||
});
|
||||
row.daily_limit = value;
|
||||
toast.success("每日限额已保存");
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
} finally {
|
||||
savingLimitId.value = "";
|
||||
cancelDailyLimitEdit();
|
||||
}
|
||||
}
|
||||
function open(type: "single" | "batch") {
|
||||
Object.assign(form, {
|
||||
account: "",
|
||||
prefix: "",
|
||||
start_sequence: "001",
|
||||
end_sequence: "001",
|
||||
password: "",
|
||||
daily_limit: 9999,
|
||||
});
|
||||
dialog.value = type;
|
||||
}
|
||||
async function create() {
|
||||
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||
if (dialog.value === "batch" && batchPreview.value.error) {
|
||||
toast.warning(batchPreview.value.error);
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
if (dialog.value === "single")
|
||||
await http.post("/users", {
|
||||
account: form.account,
|
||||
password: form.password,
|
||||
daily_limit: form.daily_limit,
|
||||
});
|
||||
else
|
||||
await http.post("/users/batch", {
|
||||
prefix: form.prefix,
|
||||
start_sequence: form.start_sequence,
|
||||
end_sequence: form.end_sequence,
|
||||
password: form.password,
|
||||
daily_limit: form.daily_limit,
|
||||
});
|
||||
toast.success("用户创建成功");
|
||||
dialog.value = "";
|
||||
load();
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function toggle(row: Record<string, any>, enabled: boolean) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`${enabled ? "启用" : "停用"}用户 ${row.account}(${row.username})?停用后会撤销其有效会话。`,
|
||||
"确认状态变更",
|
||||
{ type: "warning" },
|
||||
);
|
||||
await http.patch("/users/batch", {
|
||||
ids: [row.id],
|
||||
enabled,
|
||||
reason: `管理员${enabled ? "启用" : "停用"}用户`,
|
||||
});
|
||||
row.enabled = enabled;
|
||||
toast.success("用户状态已更新");
|
||||
} catch (e) {
|
||||
row.enabled = !enabled;
|
||||
if (e !== "cancel" && e !== "close") showError(e);
|
||||
}
|
||||
}
|
||||
async function adjustLimit() {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
"请输入新的每日限额(0 表示禁止消耗,9999 表示不限制)",
|
||||
"批量调整每日限额",
|
||||
{
|
||||
inputType: "text",
|
||||
inputValue: "9999",
|
||||
inputValidator: (v) => Number(v) >= 0 || "限额不能小于 0",
|
||||
},
|
||||
);
|
||||
const reason = await ElMessageBox.prompt("请填写调整原因", "填写审计原因", {
|
||||
inputValidator: (v) => Boolean(v.trim()) || "调整原因不能为空",
|
||||
});
|
||||
await http.patch("/users/batch", {
|
||||
ids: selected.value.map((i) => i.id),
|
||||
daily_limit: Number(value).toFixed(2),
|
||||
reason: reason.value,
|
||||
});
|
||||
toast.success("每日限额已调整");
|
||||
selected.value = [];
|
||||
load();
|
||||
} catch (e) {
|
||||
if (e !== "cancel" && e !== "close") showError(e);
|
||||
}
|
||||
}
|
||||
/** 打开单用户积分发放弹窗,并为本次操作生成幂等请求号。 */
|
||||
function openPointGrant(row: Record<string, any>) {
|
||||
pointGrantTarget.value = row;
|
||||
Object.assign(pointGrantForm, { points: null, reason: "", request_id: crypto.randomUUID() });
|
||||
pointGrantOpen.value = true;
|
||||
}
|
||||
/** 校验正数积分并通过账务接口完成入账,成功后同步列表余额。 */
|
||||
async function grantPoints() {
|
||||
if (!pointGrantTarget.value || !canGrantPoints.value) return;
|
||||
grantingPoints.value = true;
|
||||
try {
|
||||
const points = Number(pointGrantForm.points).toFixed(2);
|
||||
const { data } = await http.post(`/users/${pointGrantTarget.value.id}/points`, {
|
||||
request_id: pointGrantForm.request_id,
|
||||
points,
|
||||
reason: pointGrantForm.reason.trim(),
|
||||
});
|
||||
pointGrantTarget.value.point_balance = data.data.balance;
|
||||
toast.success(`已增加 ${points} 积分`);
|
||||
pointGrantOpen.value = false;
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
} finally {
|
||||
grantingPoints.value = false;
|
||||
}
|
||||
}
|
||||
async function remove(ids: string[]) {
|
||||
try {
|
||||
const reason = await ElMessageBox.prompt(
|
||||
`确认软删除 ${ids.length} 个用户?其订单、流水和使用记录将保留。`,
|
||||
"确认删除",
|
||||
{
|
||||
type: "warning",
|
||||
inputPlaceholder: "请输入删除原因",
|
||||
inputValidator: (v) => Boolean(v.trim()) || "删除原因不能为空",
|
||||
confirmButtonText: "确认删除",
|
||||
},
|
||||
);
|
||||
await http.delete("/users/batch", { data: { ids, reason: reason.value } });
|
||||
toast.success("用户已删除");
|
||||
selected.value = [];
|
||||
load();
|
||||
} catch (e) {
|
||||
if (e !== "cancel" && e !== "close") showError(e);
|
||||
}
|
||||
}
|
||||
const fmt = (v: any) =>
|
||||
v
|
||||
? new Date(v).toLocaleString("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
hour12: false,
|
||||
})
|
||||
: "从未在线";
|
||||
function showError(e: unknown) {
|
||||
const d = apiError(e);
|
||||
toast.error(d.message, d.copyText);
|
||||
}
|
||||
const timer = window.setInterval(() => load(true), 300000);
|
||||
onMounted(load);
|
||||
onBeforeUnmount(() => clearInterval(timer));
|
||||
</script>
|
||||
<template>
|
||||
<div class="page-stack management-page">
|
||||
<PageHeader
|
||||
title="用户管理"
|
||||
description="管理 WEB 用户账号、每日限额和启用状态"
|
||||
><el-button @click="open('batch')"> <AppIcon name="UserFilled" />批量新建 </el-button
|
||||
><el-button
|
||||
type="primary"
|
||||
@click="open('single')"
|
||||
>
|
||||
<AppIcon name="Plus" />新建用户
|
||||
</el-button></PageHeader
|
||||
>
|
||||
<section class="panel filter-bar">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="账号、用户名或 UID"
|
||||
/><el-select
|
||||
v-model="query.enabled"
|
||||
clearable
|
||||
placeholder="启用状态"
|
||||
><el-option
|
||||
label="已启用"
|
||||
value="true" /><el-option
|
||||
label="已停用"
|
||||
value="false" /></el-select
|
||||
><el-button
|
||||
type="primary"
|
||||
@click="
|
||||
query.page = 1;
|
||||
load();
|
||||
"
|
||||
>查询</el-button
|
||||
><el-button
|
||||
@click="
|
||||
query.keyword = '';
|
||||
query.enabled = '';
|
||||
query.page = 1;
|
||||
load();
|
||||
"
|
||||
>重置</el-button
|
||||
>
|
||||
</section>
|
||||
<section
|
||||
v-if="selected.length"
|
||||
class="batch-toolbar"
|
||||
>
|
||||
<strong>已选择 {{ selected.length }} 项</strong><el-button @click="selected = []">取消选择</el-button
|
||||
><el-button @click="adjustLimit">调整每日限额</el-button
|
||||
><el-button
|
||||
type="danger"
|
||||
@click="remove(selected.map((i) => i.id))"
|
||||
>批量删除</el-button
|
||||
>
|
||||
</section>
|
||||
<section
|
||||
class="panel table-panel"
|
||||
v-loading="loading"
|
||||
>
|
||||
<el-table
|
||||
class="desktop-user-table"
|
||||
:data="items"
|
||||
@selection-change="selected = $event"
|
||||
><el-table-column
|
||||
type="selection"
|
||||
width="46"
|
||||
/><el-table-column
|
||||
prop="account"
|
||||
label="账号"
|
||||
min-width="150"
|
||||
show-overflow-tooltip
|
||||
/><el-table-column
|
||||
prop="username"
|
||||
label="用户名"
|
||||
min-width="170"
|
||||
/><el-table-column
|
||||
prop="uid"
|
||||
label="UID"
|
||||
min-width="125"
|
||||
/><el-table-column
|
||||
label="积分余额"
|
||||
min-width="120"
|
||||
><template #default="{ row }">{{ Number(row.point_balance).toFixed(2) }}</template></el-table-column
|
||||
><el-table-column
|
||||
label="今日消耗"
|
||||
min-width="120"
|
||||
><template #default="{ row }">{{ Number(row.today_consumption).toFixed(2) }}</template></el-table-column
|
||||
><el-table-column
|
||||
label="每日限额"
|
||||
min-width="170"
|
||||
><template #default="{ row }">
|
||||
<div class="limit-cell">
|
||||
<AdminNumberInput
|
||||
v-if="editingLimitId === row.id"
|
||||
v-model="editingLimitValue"
|
||||
class="limit-input"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:disabled="savingLimitId === row.id"
|
||||
autofocus
|
||||
@blur="saveDailyLimit(row)"
|
||||
/><template v-else
|
||||
><span>{{ Number(row.daily_limit) === 9999 ? "不限制" : Number(row.daily_limit).toFixed(2) }}</span
|
||||
><el-button
|
||||
class="limit-edit-button"
|
||||
text
|
||||
circle
|
||||
type="primary"
|
||||
title="编辑每日限额"
|
||||
@click="editDailyLimit(row)"
|
||||
>
|
||||
<AppIcon
|
||||
name="Edit"
|
||||
:size="15"
|
||||
/> </el-button
|
||||
></template>
|
||||
</div> </template></el-table-column
|
||||
><el-table-column
|
||||
label="启用状态"
|
||||
width="96"
|
||||
><template #default="{ row }"
|
||||
><el-switch
|
||||
:model-value="row.enabled"
|
||||
@change="toggle(row, Boolean($event))" /></template></el-table-column
|
||||
><el-table-column
|
||||
label="最后在线时间"
|
||||
min-width="190"
|
||||
><template #default="{ row }">{{ fmt(row.last_online_at) }}</template></el-table-column
|
||||
><el-table-column
|
||||
label="操作"
|
||||
width="150"
|
||||
><template #default="{ row }"
|
||||
><el-button
|
||||
link
|
||||
type="primary"
|
||||
:disabled="!row.enabled"
|
||||
:title="row.enabled ? '增加积分' : '停用用户不可增加积分'"
|
||||
@click="openPointGrant(row)"
|
||||
>增加积分</el-button
|
||||
><el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="remove([row.id])"
|
||||
>删除</el-button
|
||||
></template
|
||||
></el-table-column
|
||||
></el-table
|
||||
>
|
||||
<div
|
||||
v-if="items.length"
|
||||
class="mobile-card-list"
|
||||
>
|
||||
<article
|
||||
v-for="row in items"
|
||||
:key="row.id"
|
||||
class="data-card"
|
||||
>
|
||||
<dl>
|
||||
<dt>账号</dt>
|
||||
<dd>{{ row.account }}</dd>
|
||||
<dt>用户名</dt>
|
||||
<dd>{{ row.username }}</dd>
|
||||
<dt>UID</dt>
|
||||
<dd>{{ row.uid }}</dd>
|
||||
<dt>积分余额</dt>
|
||||
<dd>{{ Number(row.point_balance).toFixed(2) }}</dd>
|
||||
<dt>今日消耗</dt>
|
||||
<dd>{{ Number(row.today_consumption).toFixed(2) }}</dd>
|
||||
<dt>每日限额</dt>
|
||||
<dd>
|
||||
<div class="limit-cell">
|
||||
<AdminNumberInput
|
||||
v-if="editingLimitId === row.id"
|
||||
v-model="editingLimitValue"
|
||||
class="limit-input"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:disabled="savingLimitId === row.id"
|
||||
autofocus
|
||||
@blur="saveDailyLimit(row)"
|
||||
/><template v-else
|
||||
><span>{{ Number(row.daily_limit) === 9999 ? "不限制" : Number(row.daily_limit).toFixed(2) }}</span
|
||||
><el-button
|
||||
class="limit-edit-button"
|
||||
text
|
||||
circle
|
||||
type="primary"
|
||||
title="编辑每日限额"
|
||||
@click="editDailyLimit(row)"
|
||||
>
|
||||
<AppIcon
|
||||
name="Edit"
|
||||
:size="15"
|
||||
/> </el-button
|
||||
></template>
|
||||
</div>
|
||||
</dd>
|
||||
<dt>最后在线</dt>
|
||||
<dd>{{ fmt(row.last_online_at) }}</dd>
|
||||
</dl>
|
||||
<div class="card-actions">
|
||||
<el-switch
|
||||
:model-value="row.enabled"
|
||||
@change="toggle(row, Boolean($event))"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="!row.enabled"
|
||||
@click="openPointGrant(row)"
|
||||
>增加积分</el-button
|
||||
>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
@click="remove([row.id])"
|
||||
>删除</el-button
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="!loading && !items.length"
|
||||
description="暂无用户"
|
||||
/><el-pagination
|
||||
v-if="total > query.page_size"
|
||||
v-model:current-page="query.page"
|
||||
background
|
||||
layout="total,prev,pager,next"
|
||||
:total="total"
|
||||
@current-change="load"
|
||||
/>
|
||||
</section>
|
||||
<el-dialog
|
||||
v-model="pointGrantOpen"
|
||||
title="增加用户积分"
|
||||
width="min(480px, calc(100vw - 32px))"
|
||||
:close-on-click-modal="!grantingPoints"
|
||||
:show-close="!grantingPoints"
|
||||
>
|
||||
<div
|
||||
v-if="pointGrantTarget"
|
||||
class="point-grant-summary"
|
||||
>
|
||||
<strong>{{ pointGrantTarget.account }}({{ pointGrantTarget.username }})</strong>
|
||||
<span>当前余额 {{ Number(pointGrantTarget.point_balance).toFixed(2) }} 积分</span>
|
||||
</div>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="增加积分">
|
||||
<AdminNumberInput
|
||||
v-model="pointGrantForm.points"
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
:disabled="grantingPoints"
|
||||
placeholder="请输入大于 0 的积分数量"
|
||||
/>
|
||||
<div class="field-helper">仅支持增加积分,到账后会写入用户积分变动记录</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="操作原因">
|
||||
<el-input
|
||||
v-model.trim="pointGrantForm.reason"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
:disabled="grantingPoints"
|
||||
placeholder="用于管理后台审计,不会向用户展示"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button
|
||||
:disabled="grantingPoints"
|
||||
@click="pointGrantOpen = false"
|
||||
>取消</el-button
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="grantingPoints"
|
||||
:disabled="!canGrantPoints"
|
||||
@click="grantPoints"
|
||||
>确认增加</el-button
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog
|
||||
v-model="dialogOpen"
|
||||
:title="dialog === 'single' ? '新建用户' : '批量新建用户'"
|
||||
width="min(560px, calc(100vw - 32px))"
|
||||
><el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
label-position="top"
|
||||
><el-form-item
|
||||
v-if="dialog === 'single'"
|
||||
label="账号"
|
||||
prop="account"
|
||||
:rules="[{ required: true, message: '请输入账号' }]"
|
||||
>
|
||||
<el-input
|
||||
v-model.trim="form.account"
|
||||
placeholder="用于用户登录,创建后不可修改"
|
||||
/>
|
||||
<div class="field-helper">系统将自动生成随机用户名,账号创建后不可修改</div>
|
||||
</el-form-item>
|
||||
<template v-else>
|
||||
<div class="dialog-form-grid batch-account-grid">
|
||||
<el-form-item
|
||||
class="dialog-field--full"
|
||||
label="账号前缀"
|
||||
prop="prefix"
|
||||
:rules="[{ required: true, message: '请输入账号前缀' }]"
|
||||
>
|
||||
<el-input
|
||||
v-model.trim="form.prefix"
|
||||
placeholder="例如 RR"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
label="起始序号"
|
||||
prop="start_sequence"
|
||||
:rules="[
|
||||
{ required: true, message: '请输入起始序号' },
|
||||
{ pattern: /^\d+$/, message: '起始序号必须为数字' },
|
||||
]"
|
||||
>
|
||||
<el-input
|
||||
v-model.trim="form.start_sequence"
|
||||
placeholder="例如 001"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
label="终止序号"
|
||||
prop="end_sequence"
|
||||
:rules="[
|
||||
{ required: true, message: '请输入终止序号' },
|
||||
{ pattern: /^\d+$/, message: '终止序号必须为数字' },
|
||||
]"
|
||||
>
|
||||
<el-input
|
||||
v-model.trim="form.end_sequence"
|
||||
placeholder="例如 050"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-alert
|
||||
class="batch-preview"
|
||||
:closable="false"
|
||||
:type="batchPreview.error ? 'error' : 'info'"
|
||||
:title="
|
||||
batchPreview.error
|
||||
? batchPreview.error
|
||||
: `将创建 ${batchPreview.count} 个账号:${batchPreview.first}${batchPreview.count > 1 ? ` 至 ${batchPreview.last}` : ''}`
|
||||
"
|
||||
/>
|
||||
<div class="field-helper batch-helper">
|
||||
账号之间使用短横线分隔;序号会保留输入位数,每次最多创建 50 个用户
|
||||
</div>
|
||||
</template>
|
||||
<el-form-item
|
||||
label="密码"
|
||||
prop="password"
|
||||
:rules="[{ required: true, min: 8, message: '密码至少 8 个字符' }]"
|
||||
><el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password" /></el-form-item
|
||||
><el-form-item label="每日限额"
|
||||
><AdminNumberInput
|
||||
v-model="form.daily_limit"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
/>
|
||||
<div class="field-helper">0 表示当日禁止消耗,9999 表示不限制</div>
|
||||
</el-form-item></el-form
|
||||
><template #footer
|
||||
><el-button @click="dialog = ''">取消</el-button
|
||||
><el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
:disabled="dialog === 'batch' && Boolean(batchPreview.error)"
|
||||
@click="create"
|
||||
>创建</el-button
|
||||
></template
|
||||
></el-dialog
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desktop-user-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.batch-preview {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.batch-helper {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.limit-cell {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.limit-cell > span {
|
||||
min-width: 58px;
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.limit-cell .el-button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.limit-edit-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.limit-input {
|
||||
width: 118px;
|
||||
}
|
||||
|
||||
.limit-input :deep(input) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.point-grant-summary {
|
||||
margin-bottom: var(--space-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.point-grant-summary span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.limit-cell {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.limit-input {
|
||||
width: min(128px, 100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"include": ["vite.config.ts"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ESNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// 剧游AI管理后台 Vite 构建配置,配置 Vue 插件、路径别名、代理与输出目录
|
||||
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { findAvailablePort } from '../vite-port'
|
||||
|
||||
const devHost = '127.0.0.1'
|
||||
const devPort = await findAvailablePort(devHost, 5500)
|
||||
|
||||
export default defineConfig({
|
||||
base: '/admin/',
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
api: 'modern-compiler',
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../API/static/admin',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
host: devHost,
|
||||
port: devPort,
|
||||
open: false,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8123',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user