初始化

This commit is contained in:
Ran
2026-08-25 17:59:42 +08:00
commit 4b7380dd9b
408 changed files with 327400 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+13
View File
@@ -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>
+2912
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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

+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { RouterView } from "vue-router";
import ToastContainer from "@/components/ToastContainer.vue";
</script>
<template>
<RouterView />
<ToastContainer />
</template>
+67
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
import logoUrl from "../../../WEB/src/assets/logo.png";
export default logoUrl;
+837
View File
@@ -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);
}
+36
View File
@@ -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;
}
+41
View File
@@ -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>
+123
View File
@@ -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>
+29
View File
@@ -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>
+326
View File
@@ -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>
+100
View File
@@ -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>
+11
View File
@@ -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>
+78
View File
@@ -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>
+148
View File
@@ -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>
+20
View File
@@ -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");
+16
View File
@@ -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;
+76
View File
@@ -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;
+31
View File
@@ -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;
}
},
},
});
+15
View File
@@ -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 };
});
+50
View File
@@ -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];
}
+243
View File
@@ -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>
+345
View File
@@ -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>
+85
View File
@@ -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>
+548
View File
@@ -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>
+475
View File
@@ -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>
+271
View File
@@ -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>
+181
View File
@@ -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: "密码长度必须为 820 位", 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="请输入 820 位新密码"
/>
</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>
+437
View File
@@ -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支持 JPEGPNGGIF最大 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>
+730
View File
@@ -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>
+13
View File
@@ -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/*"]
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}
+14
View File
@@ -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"]
}
}
+42
View File
@@ -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,
},
},
},
})