This commit is contained in:
Your Name
2026-08-03 10:00:05 +08:00
parent 0fb03d0bca
commit 01729b1e0b
109 changed files with 7577 additions and 1153 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
<template>
<div class="theme-toggle" role="group" aria-label="界面主题">
<button
type="button"
:class="{ active: theme === 'light' }"
:aria-pressed="theme === 'light'"
aria-label="使用浅色主题"
title="浅色主题"
@click="setTheme('light')"
>
<span>浅色</span>
</button>
<button
type="button"
:class="{ active: theme === 'dark' }"
:aria-pressed="theme === 'dark'"
aria-label="使用深色主题"
title="深色主题"
@click="setTheme('dark')"
>
<span>深色</span>
</button>
</div>
</template>
<script setup>
import { useTheme } from '@/composables/useTheme'
const { theme, setTheme } = useTheme()
</script>
<style scoped>
.theme-toggle {
display: inline-grid;
grid-template-columns: repeat(2, 42px);
gap: 0;
padding: 2px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-tertiary);
}
button {
display: grid;
width: 42px;
height: 30px;
place-items: center;
border: 1px solid transparent;
border-radius: 4px;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
transition: color 160ms ease, background 160ms ease, transform 160ms ease;
}
button:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
button:active {
transform: translateY(1px) scale(0.96);
}
button.active {
border-color: transparent;
background: var(--button-primary-bg);
color: var(--button-primary-text);
}
</style>
+70
View File
@@ -0,0 +1,70 @@
<template>
<svg
class="ui-icon"
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path :d="iconPath" />
</svg>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
name: { type: String, default: 'file' },
size: { type: [Number, String], default: 18 }
})
const paths = {
dashboard: 'M4 13h6V4H4v9Zm0 7h6v-4H4v4Zm10 0h6v-9h-6v9Zm0-12h6V4h-6v4Z',
users: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75',
departments: 'M3 21h18M5 21V8l7-4 7 4v13M9 11h1m4 0h1m-6 4h1m4 0h1m-4 6v-3h2v3',
roles: 'M12 3 4.5 6v5.5c0 4.7 3.2 8.1 7.5 9.5 4.3-1.4 7.5-4.8 7.5-9.5V6L12 3Zm-2.3 9.2 1.55 1.55 3.2-3.5',
conversations: 'M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4v8ZM8 9h8M8 13h5',
memberships: 'm12 3 2.7 5.45 6.02.88-4.36 4.25 1.03 6L12 17.7 6.61 20.5l1.03-6L3.28 9.33l6.02-.88L12 3Z',
models: 'M9 4.5V2m6 2.5V2M4.5 9H2m20 0h-2.5M7 5h10a2 2 0 0 1 2 2v9a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V7a2 2 0 0 1 2-2Zm2 6v2m6-2v2m-6 3h6',
permissions: 'M15 7a5 5 0 1 1 1.2 3.25L22 16v3h-3v2h-3v-3l-3.2-3.2M7 7h.01',
settings: 'M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm7.4-3.5c0-.55-.06-1.08-.17-1.6l2.05-1.6-2-3.46-2.5 1a8.4 8.4 0 0 0-2.77-1.6L13.62 2h-4l-.4 2.74a8.4 8.4 0 0 0-2.77 1.6l-2.5-1-2 3.46L4 10.4A7.7 7.7 0 0 0 3.83 12c0 .55.06 1.08.17 1.6l-2.05 1.6 2 3.46 2.5-1a8.4 8.4 0 0 0 2.77 1.6l.4 2.74h4l.4-2.74a8.4 8.4 0 0 0 2.77-1.6l2.5 1 2-3.46-2.05-1.6c.11-.52.17-1.05.17-1.6Z',
external: 'M14 3h7v7m0-7-9 9M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6',
logout: 'M10 17l5-5-5-5m5 5H3m11-9h5a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-5',
menu: 'M4 6h16M4 12h16M4 18h16',
folder: 'M3 6h7l2 2h9v11H3V6Z',
bolt: 'm13 2-9 12h7l-1 8 9-12h-7l1-8Z',
file: 'M6 2h8l4 4v16H6V2Zm8 0v5h5M9 13h6m-6 4h6',
activity: 'M3 12h4l2.5-6 5 12 2.5-6H21',
messages: 'M5 4h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H9l-5 4v-4a2 2 0 0 1-1-2V6a2 2 0 0 1 2-2Z',
chart: 'M4 20V10m6 10V4m6 16v-7m5 7H2'
}
const aliases = {
'/dashboard': 'dashboard',
'/users': 'users',
'/departments': 'departments',
'/roles': 'roles',
'/conversations': 'conversations',
'/memberships': 'memberships',
'/models': 'models',
'/permissions': 'permissions',
'/settings': 'settings',
dir: 'folder',
menu: 'file',
btn: 'bolt'
}
const iconPath = computed(() => paths[aliases[props.name] || props.name] || paths.file)
</script>
<style scoped>
.ui-icon {
display: block;
flex: 0 0 auto;
}
</style>
@@ -0,0 +1,56 @@
import { computed, ref } from 'vue'
const STORAGE_KEY = 'ai-chat-theme'
const DEFAULT_THEME = 'light'
function readStoredTheme() {
try {
return localStorage.getItem(STORAGE_KEY) === 'dark' ? 'dark' : DEFAULT_THEME
} catch {
return DEFAULT_THEME
}
}
const theme = ref(readStoredTheme())
let transitionTimer
function applyTheme(value, animate = false) {
const nextTheme = value === 'dark' ? 'dark' : 'light'
const root = document.documentElement
if (animate) {
root.classList.add('theme-transitioning')
window.clearTimeout(transitionTimer)
transitionTimer = window.setTimeout(() => root.classList.remove('theme-transitioning'), 260)
}
root.dataset.theme = nextTheme
root.style.colorScheme = nextTheme
theme.value = nextTheme
}
export function initializeTheme() {
applyTheme(theme.value)
}
export function useTheme() {
const isDark = computed(() => theme.value === 'dark')
function setTheme(value) {
const nextTheme = value === 'dark' ? 'dark' : 'light'
applyTheme(nextTheme, true)
try {
localStorage.setItem(STORAGE_KEY, nextTheme)
} catch {
// Theme still works for the current session when storage is unavailable.
}
}
return { theme, isDark, setTheme }
}
if (typeof window !== 'undefined') {
window.addEventListener('storage', event => {
if (event.key === STORAGE_KEY) applyTheme(event.newValue)
})
}
+9 -9
View File
@@ -10,7 +10,7 @@ export const permissionTree = [
name: '数据概览',
type: 'menu',
path: '/dashboard',
icon: '📊',
icon: 'dashboard',
children: []
}
]
@@ -25,7 +25,7 @@ export const permissionTree = [
name: '用户管理',
type: 'menu',
path: '/users',
icon: '👥',
icon: 'users',
children: [
{ code: 'btn:user:create', name: '新增用户', type: 'btn' },
{ code: 'btn:user:edit', name: '编辑用户', type: 'btn' },
@@ -38,7 +38,7 @@ export const permissionTree = [
name: '部门管理',
type: 'menu',
path: '/departments',
icon: '🏢',
icon: 'departments',
children: [
{ code: 'btn:dept:create', name: '新增部门', type: 'btn' },
{ code: 'btn:dept:edit', name: '编辑部门', type: 'btn' },
@@ -50,7 +50,7 @@ export const permissionTree = [
name: '角色管理',
type: 'menu',
path: '/roles',
icon: '🛡️',
icon: 'roles',
children: [
{ code: 'btn:role:create', name: '新增角色', type: 'btn' },
{ code: 'btn:role:edit', name: '编辑角色', type: 'btn' },
@@ -69,7 +69,7 @@ export const permissionTree = [
name: '会话管理',
type: 'menu',
path: '/conversations',
icon: '💬',
icon: 'conversations',
children: [
{ code: 'btn:conv:view_all', name: '查看全部会话', type: 'btn' },
{ code: 'btn:conv:view_subordinate', name: '查看下级部门会话', type: 'btn' }
@@ -80,7 +80,7 @@ export const permissionTree = [
name: '会员等级',
type: 'menu',
path: '/memberships',
icon: '',
icon: 'memberships',
children: [
{ code: 'btn:membership:create', name: '新增会员等级', type: 'btn' },
{ code: 'btn:membership:edit', name: '编辑会员等级', type: 'btn' },
@@ -99,7 +99,7 @@ export const permissionTree = [
name: 'AI 模型',
type: 'menu',
path: '/models',
icon: '🤖',
icon: 'models',
children: [
{ code: 'btn:model:create', name: '新增模型', type: 'btn' },
{ code: 'btn:model:edit', name: '编辑模型', type: 'btn' },
@@ -112,7 +112,7 @@ export const permissionTree = [
name: '权限管理',
type: 'menu',
path: '/permissions',
icon: '🔑',
icon: 'permissions',
children: [
{ code: 'btn:perm:create', name: '新增权限', type: 'btn' },
{ code: 'btn:perm:edit', name: '编辑权限', type: 'btn' },
@@ -124,7 +124,7 @@ export const permissionTree = [
name: '系统设置',
type: 'menu',
path: '/settings',
icon: '🔧',
icon: 'settings',
children: [
{ code: 'btn:settings:save', name: '保存设置', type: 'btn' }
]
+229 -81
View File
@@ -2,12 +2,18 @@
<div class="admin-layout">
<aside class="sidebar" :class="{ open: sidebarOpen }">
<div class="sidebar-brand">
<span class="brand-icon"></span>
<span>AI Chat 管理</span>
<span class="brand-icon"><UiIcon name="bolt" :size="18" /></span>
<span class="brand-copy">
<strong>AI Chat</strong>
<small>CONTROL PANEL</small>
</span>
</div>
<a :href="memberUrl" target="_blank" class="sidebar-member-link"> 会员聊天端</a>
<a :href="memberUrl" target="_blank" class="sidebar-member-link">
<UiIcon name="external" :size="15" />
<span>打开会员聊天端</span>
</a>
<nav class="sidebar-nav">
<nav class="sidebar-nav" aria-label="管理后台导航">
<template v-for="group in navGroups" :key="group.dir">
<div v-if="group.dirName" class="nav-group-title">{{ group.dirName }}</div>
<router-link
@@ -17,18 +23,26 @@
class="nav-item"
@click="sidebarOpen = false"
>
<span class="nav-icon">{{ item.icon }}</span>
{{ item.label }}
<span class="nav-icon"><UiIcon :name="item.path" :size="17" /></span>
<span class="nav-label">{{ item.label }}</span>
<span class="nav-pip" aria-hidden="true" />
</router-link>
</template>
</nav>
<div class="sidebar-footer">
<ThemeToggle class="sidebar-theme-toggle" />
<div class="admin-user">
<span class="avatar">{{ avatarLetter }}</span>
<span>{{ auth.user?.nickname || auth.user?.username }}</span>
<span class="admin-user-copy">
<strong>{{ auth.user?.nickname || auth.user?.username }}</strong>
<small>管理员账户</small>
</span>
</div>
<button class="btn btn-ghost logout-btn" @click="handleLogout">退出登录</button>
<button class="btn btn-ghost logout-btn" type="button" @click="handleLogout">
<UiIcon name="logout" :size="15" />
退出登录
</button>
</div>
</aside>
@@ -36,9 +50,15 @@
<div class="main-area">
<header class="topbar">
<button class="menu-btn" @click="sidebarOpen = true"></button>
<button class="menu-btn" type="button" aria-label="打开导航" @click="sidebarOpen = true">
<UiIcon name="menu" :size="20" />
</button>
<span class="page-title">{{ currentTitle }}</span>
<a :href="memberUrl" target="_blank" class="member-link">会员端</a>
<ThemeToggle />
<a :href="memberUrl" target="_blank" class="member-link">
<UiIcon name="external" :size="14" />
会员端
</a>
</header>
<main class="main-content">
<router-view />
@@ -51,6 +71,8 @@
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import UiIcon from '@/components/UiIcon.vue'
import ThemeToggle from '@/components/ThemeToggle.vue'
const route = useRoute()
const router = useRouter()
@@ -69,7 +91,7 @@ const navGroups = computed(() => {
group.items.push({
path: menu.path,
label: menu.name,
icon: menu.icon || '📄'
icon: menu.icon || 'file'
})
}
return groups
@@ -93,161 +115,284 @@ function handleLogout() {
.admin-layout {
display: flex;
height: 100%;
min-height: 100dvh;
}
.sidebar {
width: var(--sidebar-width);
background: var(--bg-secondary);
border-right: 1px solid var(--border);
position: relative;
display: flex;
width: var(--sidebar-width);
flex: 0 0 var(--sidebar-width);
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
border-right: 1px solid var(--border);
background:
radial-gradient(circle at 45% -10%, rgba(183, 243, 107, 0.08), transparent 25%),
rgba(13, 16, 21, 0.96);
box-shadow: 18px 0 50px rgba(0, 0, 0, 0.16);
backdrop-filter: blur(20px);
}
.sidebar::after {
position: absolute;
top: 0;
right: -1px;
width: 1px;
height: 24%;
background: linear-gradient(to bottom, var(--accent), transparent);
box-shadow: 0 0 16px rgba(183, 243, 107, 0.42);
content: "";
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 16px;
font-size: 16px;
font-weight: 600;
border-bottom: 1px solid var(--border);
gap: 11px;
padding: 20px 18px 18px;
}
.brand-icon {
font-size: 20px;
display: grid;
width: 36px;
height: 36px;
flex: 0 0 auto;
place-items: center;
border: 1px solid rgba(183, 243, 107, 0.42);
border-radius: 11px;
background: var(--accent);
color: #12170f;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 4px 0 #55782f, 0 10px 24px rgba(110, 166, 54, 0.18);
}
.brand-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.brand-copy strong {
font-size: 16px;
letter-spacing: -0.02em;
}
.brand-copy small {
color: var(--text-muted);
font-family: "Cascadia Code", Consolas, monospace;
font-size: 9px;
letter-spacing: 0.14em;
}
.sidebar-member-link {
display: block;
margin: 0 12px 8px;
padding: 8px 12px;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
margin: 0 12px 10px;
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
color: var(--text-secondary);
text-decoration: none;
border-radius: 8px;
font-size: 12px;
transition: border-color 160ms ease, background 160ms ease, color 160ms ease, transform 160ms ease;
}
.sidebar-member-link:hover {
border-color: rgba(183, 243, 107, 0.24);
background: var(--accent-soft);
color: var(--accent);
background: var(--bg-hover);
transform: translateX(2px);
}
.sidebar-nav {
flex: 1;
padding: 12px 8px;
padding: 4px 10px 14px;
overflow-y: auto;
}
.nav-group-title {
padding: 12px 12px 6px;
font-size: 11px;
font-weight: 600;
padding: 15px 10px 7px;
color: var(--text-muted);
letter-spacing: 0.04em;
font-size: 10px;
font-weight: 750;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.nav-item {
position: relative;
display: flex;
min-height: 42px;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
margin-bottom: 3px;
padding: 8px 10px;
overflow: hidden;
border: 1px solid transparent;
border-radius: 11px;
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 2px;
transition: all 0.15s;
font-size: 13px;
transition: transform 180ms var(--ease-spring), border-color 160ms ease, background 160ms ease, color 160ms ease;
}
.nav-item:hover {
background: var(--bg-hover);
border-color: var(--border);
background: rgba(255, 255, 255, 0.035);
color: var(--text-primary);
transform: translateX(2px);
}
.nav-item.router-link-active {
background: var(--accent);
color: white;
border-color: rgba(183, 243, 107, 0.28);
background: linear-gradient(90deg, rgba(183, 243, 107, 0.14), rgba(183, 243, 107, 0.045));
color: var(--accent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
}
.nav-icon {
font-size: 16px;
display: grid;
width: 28px;
height: 28px;
flex: 0 0 auto;
place-items: center;
border-radius: 8px;
background: rgba(255, 255, 255, 0.035);
}
.router-link-active .nav-icon {
background: rgba(183, 243, 107, 0.12);
box-shadow: 0 0 18px rgba(183, 243, 107, 0.08);
}
.nav-label {
flex: 1;
}
.nav-pip {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
opacity: 0;
box-shadow: 0 0 9px currentColor;
transition: opacity 160ms ease;
}
.router-link-active .nav-pip {
opacity: 1;
}
.sidebar-footer {
padding: 12px;
padding: 13px;
border-top: 1px solid var(--border);
background: rgba(0, 0, 0, 0.14);
}
.sidebar-theme-toggle {
margin: 0 4px 8px;
}
.admin-user {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
font-size: 13px;
margin-bottom: 8px;
gap: 10px;
margin-bottom: 10px;
padding: 6px 4px;
}
.avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--accent);
display: flex;
align-items: center;
justify-content: center;
display: grid;
width: 34px;
height: 34px;
flex: 0 0 auto;
place-items: center;
border: 1px solid rgba(183, 243, 107, 0.3);
border-radius: 10px;
background: var(--accent-soft);
color: var(--accent);
font-size: 12px;
font-weight: 600;
font-weight: 750;
}
.admin-user-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.admin-user-copy strong {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-user-copy small {
color: var(--text-muted);
font-size: 10px;
}
.logout-btn {
width: 100%;
font-size: 13px;
font-size: 12px;
}
.main-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
flex-direction: column;
}
.topbar {
display: none;
min-height: var(--header-height);
align-items: center;
gap: 12px;
height: var(--header-height);
padding: 0 16px;
padding: 0 14px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
background: rgba(12, 15, 20, 0.88);
backdrop-filter: blur(18px);
}
.menu-btn {
padding: 8px;
font-size: 18px;
display: grid;
width: 38px;
height: 38px;
place-items: center;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.025);
}
.page-title {
font-size: 16px;
font-weight: 500;
flex: 1;
font-size: 14px;
font-weight: 650;
}
.member-link {
font-size: 13px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 9px;
color: var(--text-secondary);
text-decoration: none;
padding: 6px 12px;
border-radius: 6px;
}
.member-link:hover {
color: var(--accent);
background: var(--bg-hover);
font-size: 12px;
}
.main-content {
flex: 1;
overflow-y: auto;
padding: 24px;
padding: clamp(24px, 3vw, 42px);
}
.main-content > :deep(*) {
max-width: 1480px;
margin-right: auto;
margin-left: auto;
}
.sidebar-overlay {
@@ -257,28 +402,31 @@ function handleLogout() {
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
inset: 0 auto 0 0;
z-index: 100;
transform: translateX(-100%);
transition: transform 0.3s;
transition: transform 280ms var(--ease-spring);
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-overlay.active {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
display: block;
background: rgba(3, 4, 6, 0.72);
backdrop-filter: blur(6px);
}
.topbar {
display: flex;
}
.main-content {
padding: 16px;
padding: 20px 14px 28px;
}
}
</style>
+3
View File
@@ -2,8 +2,11 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { initializeTheme } from './composables/useTheme'
import './assets/main.css'
initializeTheme()
const app = createApp(App)
app.use(createPinia())
app.use(router)
+10 -14
View File
@@ -73,7 +73,9 @@
class="message"
:class="msg.role"
>
<div class="message-avatar">{{ msg.role === 'user' ? '用户' : 'AI' }}</div>
<div class="message-avatar">
<UiIcon :name="msg.role === 'user' ? 'users' : 'models'" :size="16" />
</div>
<div class="message-body">
<div v-if="getAttachments(msg).length" class="attachments">
<template v-for="(att, i) in getAttachments(msg)" :key="i">
@@ -91,7 +93,8 @@
rel="noopener"
class="att-link"
>
{{ documentIcon(att) }} {{ att.name || '附件' }}
<UiIcon name="file" :size="16" />
{{ att.name || '附件' }}
</a>
</template>
</div>
@@ -116,6 +119,7 @@
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '@/api'
import UiIcon from '@/components/UiIcon.vue'
const route = useRoute()
const router = useRouter()
@@ -257,14 +261,6 @@ function getAttachments(msg) {
return list.filter(att => att && typeof att === 'object')
}
function documentIcon(att) {
const name = (att?.name || '').toLowerCase()
const mime = att?.mime || ''
if (name.endsWith('.pdf') || mime.includes('pdf')) return '📕'
if (name.endsWith('.doc') || name.endsWith('.docx') || mime.includes('word')) return '📘'
return '📄'
}
function previewImage(url) {
window.open(url, '_blank')
}
@@ -340,8 +336,8 @@ function formatDate(d) {
}
.conv-item.active {
background: rgba(99, 102, 241, 0.15);
border: 1px solid rgba(99, 102, 241, 0.35);
background: var(--accent-soft);
border: 1px solid rgba(183, 243, 107, 0.28);
}
.conv-item-title {
@@ -459,8 +455,8 @@ function formatDate(d) {
}
.message.user .message-content {
background: rgba(99, 102, 241, 0.2);
border: 1px solid rgba(99, 102, 241, 0.3);
background: var(--accent-soft);
border: 1px solid rgba(183, 243, 107, 0.24);
}
.message-time {
+74 -28
View File
@@ -6,26 +6,30 @@
</div>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-icon">👥</span>
<article class="stat-card">
<span class="stat-icon"><UiIcon name="users" :size="21" /></span>
<span class="stat-value">{{ stats.users }}</span>
<span class="stat-label">用户总数</span>
</div>
<div class="stat-card">
<span class="stat-icon">💬</span>
<span class="stat-index">01</span>
</article>
<article class="stat-card">
<span class="stat-icon"><UiIcon name="conversations" :size="21" /></span>
<span class="stat-value">{{ stats.conversations }}</span>
<span class="stat-label">会话总数</span>
</div>
<div class="stat-card">
<span class="stat-icon">📝</span>
<span class="stat-index">02</span>
</article>
<article class="stat-card">
<span class="stat-icon"><UiIcon name="messages" :size="21" /></span>
<span class="stat-value">{{ stats.messages }}</span>
<span class="stat-label">消息总数</span>
</div>
<div class="stat-card">
<span class="stat-icon">📈</span>
<span class="stat-index">03</span>
</article>
<article class="stat-card">
<span class="stat-icon"><UiIcon name="activity" :size="21" /></span>
<span class="stat-value">{{ stats.today_messages }}</span>
<span class="stat-label">今日消息</span>
</div>
<span class="stat-index">04</span>
</article>
</div>
</div>
</template>
@@ -33,6 +37,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import api from '@/api'
import UiIcon from '@/components/UiIcon.vue'
const stats = ref({ users: 0, conversations: 0, messages: 0, today_messages: 0 })
@@ -45,34 +50,75 @@ onMounted(async () => {
<style scoped>
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
grid-template-columns: repeat(4, minmax(180px, 1fr));
gap: 14px;
}
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
text-align: center;
display: grid;
min-height: 178px;
grid-template-columns: 1fr auto;
grid-template-rows: auto 1fr auto;
padding: 19px;
transition: transform 220ms var(--ease-spring), border-color 180ms ease, box-shadow 180ms ease;
}
.stat-card:hover {
border-color: rgba(183, 243, 107, 0.22);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045), 0 22px 52px rgba(0, 0, 0, 0.34);
transform: translateY(-3px);
}
.stat-icon {
font-size: 28px;
display: block;
margin-bottom: 8px;
z-index: 1;
display: grid;
width: 42px;
height: 42px;
place-items: center;
border: 1px solid rgba(183, 243, 107, 0.24);
border-radius: 12px;
background: var(--accent-soft);
color: var(--accent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055), 0 0 22px rgba(183, 243, 107, 0.06);
}
.stat-value {
display: block;
font-size: 36px;
font-weight: 700;
color: var(--accent);
z-index: 1;
align-self: end;
color: var(--text-primary);
font-family: "Cascadia Code", Consolas, monospace;
font-size: clamp(34px, 4vw, 48px);
font-weight: 760;
font-variant-numeric: tabular-nums;
letter-spacing: -0.065em;
line-height: 1;
}
.stat-label {
font-size: 14px;
z-index: 1;
align-self: end;
margin-top: 9px;
color: var(--text-secondary);
margin-top: 4px;
font-size: 12px;
font-weight: 600;
}
.stat-index {
z-index: 1;
grid-column: 2;
grid-row: 1;
color: var(--text-muted);
font-family: "Cascadia Code", Consolas, monospace;
font-size: 10px;
letter-spacing: 0.08em;
}
@media (max-width: 1050px) {
.stats-grid { grid-template-columns: repeat(2, minmax(180px, 1fr)); }
}
@media (max-width: 560px) {
.stats-grid { grid-template-columns: 1fr; }
.stat-card { min-height: 150px; }
}
</style>
+1 -1
View File
@@ -184,6 +184,6 @@ async function removeDept(dept) {
}
.danger {
color: #ef4444;
color: var(--danger);
}
</style>
+166 -39
View File
@@ -1,26 +1,38 @@
<template>
<div class="login-page">
<div class="login-card">
<div class="login-header">
<div class="logo"></div>
<h1>AI Chat 管理后台</h1>
<p>请使用管理员账户登录</p>
<ThemeToggle class="login-theme-toggle" />
<div class="login-shell">
<div class="login-aside">
<span class="login-mark"><UiIcon name="bolt" :size="22" /></span>
<span class="login-eyebrow">AI CHAT / ADMIN</span>
<h1>让系统配置<br />保持清晰可控</h1>
<p>统一管理用户模型权限与会话数据</p>
<span class="login-version">CONTROL SURFACE · V2</span>
</div>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label>账号</label>
<input v-model="account" class="form-input" placeholder="管理员用户名或邮箱" required />
<div class="login-card">
<div class="login-header">
<span class="status-dot" aria-hidden="true" />
<span>安全入口</span>
<h2>登录管理后台</h2>
<p>请使用管理员账户继续</p>
</div>
<div class="form-group">
<label>密码</label>
<input v-model="password" type="password" class="form-input" placeholder="请输入密码" required />
</div>
<p v-if="error" class="form-error">{{ error }}</p>
<button type="submit" class="btn btn-primary login-btn" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label>账号</label>
<input v-model="account" class="form-input" placeholder="管理员用户名或邮箱" required />
</div>
<div class="form-group">
<label>密码</label>
<input v-model="password" type="password" class="form-input" placeholder="请输入密码" required />
</div>
<p v-if="error" class="form-error">{{ error }}</p>
<button type="submit" class="btn btn-primary login-btn" :disabled="loading">
{{ loading ? '登录中...' : '进入控制台' }}
</button>
</form>
</div>
</div>
</div>
</template>
@@ -29,6 +41,8 @@
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import UiIcon from '@/components/UiIcon.vue'
import ThemeToggle from '@/components/ThemeToggle.vue'
const router = useRouter()
const route = useRoute()
@@ -55,46 +69,159 @@ async function handleLogin() {
<style scoped>
.login-page {
min-height: 100%;
position: relative;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 28px;
overflow: hidden;
background:
radial-gradient(circle at 70% 20%, rgba(183, 243, 107, 0.08), transparent 24%),
var(--bg-primary);
}
.login-theme-toggle {
position: absolute;
top: 18px;
right: 18px;
z-index: 3;
}
.login-shell {
position: relative;
display: grid;
width: min(880px, 100%);
grid-template-columns: 1.08fr 0.92fr;
overflow: hidden;
border: 1px solid var(--border-strong);
border-radius: 22px;
background: var(--bg-secondary);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), var(--shadow);
}
.login-shell::after {
position: absolute;
top: 0;
left: 18%;
width: 36%;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
box-shadow: 0 0 17px rgba(183, 243, 107, 0.44);
content: "";
}
.login-aside {
position: relative;
display: flex;
align-items: center;
min-height: 530px;
flex-direction: column;
justify-content: center;
padding: 24px;
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%);
padding: 52px;
overflow: hidden;
border-right: 1px solid var(--border);
background:
linear-gradient(135deg, rgba(183, 243, 107, 0.07), transparent 45%),
repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.018) 0 1px, transparent 1px 14px),
#0c0f13;
}
.login-mark {
display: grid;
width: 46px;
height: 46px;
place-items: center;
margin-bottom: 44px;
border: 1px solid rgba(183, 243, 107, 0.48);
border-radius: 14px;
background: var(--accent);
color: #11150e;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 5px 0 #55782f, 0 14px 28px rgba(110, 166, 54, 0.17);
}
.login-eyebrow,
.login-version {
color: var(--accent);
font-family: "Cascadia Code", Consolas, monospace;
font-size: 10px;
letter-spacing: 0.16em;
}
.login-aside h1 {
margin: 15px 0 18px;
font-size: clamp(34px, 4.6vw, 52px);
font-weight: 760;
letter-spacing: -0.055em;
line-height: 1.04;
text-wrap: balance;
}
.login-aside p {
max-width: 32ch;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.75;
}
.login-version {
position: absolute;
bottom: 28px;
left: 52px;
color: var(--text-muted);
font-size: 9px;
}
.login-card {
width: 100%;
max-width: 400px;
padding: 40px 32px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 16px;
display: flex;
flex-direction: column;
justify-content: center;
padding: 48px 42px;
background:
radial-gradient(circle at 100% 0%, rgba(183, 243, 107, 0.055), transparent 26%),
var(--bg-secondary);
}
.login-header {
text-align: center;
margin-bottom: 32px;
margin-bottom: 30px;
}
.logo {
font-size: 48px;
margin-bottom: 12px;
.login-header > span:not(.status-dot) {
color: var(--text-muted);
font-size: 11px;
font-weight: 650;
letter-spacing: 0.08em;
}
.login-header h1 {
font-size: 22px;
margin-bottom: 8px;
.status-dot {
display: inline-block;
width: 7px;
height: 7px;
margin-right: 7px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px rgba(183, 243, 107, 0.6);
}
.login-header h2 {
margin: 12px 0 7px;
font-size: 26px;
letter-spacing: -0.04em;
}
.login-header p {
color: var(--text-secondary);
font-size: 14px;
font-size: 13px;
}
.login-btn {
width: 100%;
padding: 12px;
margin-top: 8px;
min-height: 46px;
margin-top: 7px;
}
@media (max-width: 720px) {
.login-page { padding: 14px; }
.login-shell { grid-template-columns: 1fr; }
.login-aside { display: none; }
.login-card { min-height: 520px; padding: 38px 25px; }
}
</style>
+15 -4
View File
@@ -242,10 +242,20 @@ async function removeLevel(level) {
}
.membership-card {
background: var(--bg-secondary);
position: relative;
overflow: hidden;
background: radial-gradient(circle at 100% 0%, rgba(183, 243, 107, 0.05), transparent 30%), var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
border-radius: 16px;
padding: 20px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035), var(--shadow-soft);
transition: transform 200ms var(--ease-spring), border-color 180ms ease, box-shadow 180ms ease;
}
.membership-card:hover {
border-color: rgba(183, 243, 107, 0.22);
transform: translateY(-3px);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045), 0 22px 52px rgba(0, 0, 0, 0.34);
}
.card-header {
@@ -290,7 +300,8 @@ async function removeLevel(level) {
.perm-tag {
font-size: 12px;
padding: 2px 8px;
background: rgba(99, 102, 241, 0.15);
border: 1px solid rgba(183, 243, 107, 0.22);
background: var(--accent-soft);
color: var(--accent);
border-radius: 4px;
}
@@ -320,6 +331,6 @@ async function removeLevel(level) {
}
.danger {
color: #ef4444;
color: var(--danger);
}
</style>
+22 -9
View File
@@ -32,10 +32,9 @@
<span class="type-badge" :class="row.type">{{ typeLabel(row.type) }}</span>
</td>
<td><code>{{ row.code }}</code></td>
<td>
<span v-if="row.path">{{ row.path }}</span>
<span v-if="row.icon"> {{ row.icon }}</span>
<span v-if="!row.path && !row.icon">-</span>
<td class="path-cell">
<UiIcon :name="row.path || row.type" :size="16" />
<span>{{ row.path || row.icon || '-' }}</span>
</td>
<td>{{ row.is_system ? '是' : '否' }}</td>
<td>
@@ -93,7 +92,7 @@
</div>
<div v-if="form.type === 'menu'" class="form-group">
<label>图标</label>
<input v-model="form.icon" class="form-input" placeholder="可选 emoji" />
<input v-model="form.icon" class="form-input" placeholder="可选图标标识" />
</div>
<div class="form-group">
<label>排序</label>
@@ -114,6 +113,7 @@
import { ref, reactive, computed, onMounted } from 'vue'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
import UiIcon from '@/components/UiIcon.vue'
const auth = useAuthStore()
const tree = ref([])
@@ -273,9 +273,22 @@ async function removeRow(row) {
padding: 2px 6px;
border-radius: 4px;
}
.type-badge.dir { background: rgba(14, 165, 233, 0.15); color: #0ea5e9; }
.type-badge.menu { background: rgba(99, 102, 241, 0.15); color: var(--accent); }
.type-badge.btn { background: rgba(34, 197, 94, 0.15); color: #22c55e; }
.type-badge.dir { background: rgba(255, 255, 255, 0.045); color: var(--text-secondary); }
.type-badge.menu { background: var(--accent-soft); color: var(--accent); }
.type-badge.btn { background: rgba(143, 224, 106, 0.08); color: var(--success); }
.path-cell {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-family: "Cascadia Code", Consolas, monospace;
font-size: 12px;
}
.path-cell :deep(svg) {
color: var(--accent);
}
.field-hint {
margin-top: 6px;
@@ -283,5 +296,5 @@ async function removeRow(row) {
color: var(--text-muted);
}
.danger { color: #ef4444; }
.danger { color: var(--danger); }
</style>
+7 -7
View File
@@ -350,7 +350,7 @@ async function removeRole(role) {
.perm-tag {
font-size: 12px;
padding: 2px 8px;
background: rgba(99, 102, 241, 0.15);
background: var(--accent-soft);
color: var(--accent);
border-radius: 4px;
}
@@ -446,21 +446,21 @@ async function removeRole(role) {
}
.type-badge.dir {
background: rgba(14, 165, 233, 0.15);
color: #0ea5e9;
background: rgba(183, 243, 107, 0.07);
color: var(--text-secondary);
}
.type-badge.menu {
background: rgba(99, 102, 241, 0.15);
background: var(--accent-soft);
color: var(--accent);
}
.type-badge.btn {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
background: rgba(143, 224, 106, 0.08);
color: var(--success);
}
.danger {
color: #ef4444;
color: var(--danger);
}
</style>
+553 -40
View File
@@ -2,7 +2,7 @@
<div>
<div class="page-header">
<h2>系统设置</h2>
<p>控制前端功能开关与站点配置</p>
<p>配置站点功能AI 客服人物与真人感音色</p>
</div>
<div class="panel">
@@ -13,40 +13,202 @@
</div>
<div class="form-group">
<label class="check-item">
<input type="checkbox" v-model="allowRegister" />
<input v-model="allowRegister" type="checkbox" />
允许用户注册
</label>
</div>
</div>
<div class="panel" style="margin-top: 16px">
<div class="panel voice-persona-panel">
<div class="persona-heading">
<div class="persona-preview">
<span class="persona-avatar">{{ personaInitial }}</span>
<div>
<span class="persona-kicker">AI CUSTOMER PERSONA</span>
<h3>{{ voicePersona.name || '未命名客服' }}</h3>
<p>{{ voicePersona.greeting || '设置一句自然的客服开场语' }}</p>
</div>
</div>
<label class="enable-switch">
<input v-model="voicePersona.enabled" type="checkbox" />
<span>{{ voicePersona.enabled ? 'CosyVoice 已启用' : '使用备用音色' }}</span>
</label>
</div>
<div class="preset-row">
<span>人物模板</span>
<button
v-for="preset in personaPresets"
:key="preset.id"
type="button"
class="preset-btn"
@click="applyPersonaPreset(preset)"
>{{ preset.label }}</button>
</div>
<div class="form-grid">
<div class="form-group">
<label>客服人物名称</label>
<input v-model.trim="voicePersona.name" class="form-input" maxlength="40" placeholder="例如:小暖" />
</div>
<div class="form-group">
<label>内置说话人</label>
<input v-model.trim="voicePersona.speaker" class="form-input" maxlength="80" placeholder="中文女 / 中文男" />
</div>
</div>
<div class="form-group">
<label>前台开场语</label>
<input
v-model.trim="voicePersona.greeting"
class="form-input"
maxlength="200"
placeholder="您好,我是 AI 客服小暖,请问有什么可以帮您?"
/>
</div>
<div class="form-group">
<label>人物性格与客服规则</label>
<textarea
v-model.trim="voicePersona.role_prompt"
class="form-input persona-textarea"
rows="3"
maxlength="2000"
placeholder="描述人物性格、服务方式、用词习惯和需要避免的表达"
/>
</div>
<div class="form-grid">
<div class="form-group">
<label>CosyVoice 服务地址</label>
<input v-model.trim="voicePersona.base_url" class="form-input" placeholder="http://127.0.0.1:50000" />
</div>
<div class="form-group">
<label>合成模式</label>
<select v-model="voicePersona.mode" class="form-select">
<option value="sft">SFT 内置音色</option>
<option value="instruct">Instruct 情绪控制</option>
<option value="zero_shot">Zero-shot 克隆音色</option>
<option value="cross_lingual">跨语种克隆</option>
<option value="instruct2">Instruct2 克隆 + 情绪</option>
</select>
</div>
</div>
<div class="form-grid form-grid-3">
<div class="form-group">
<label>输出采样率</label>
<select v-model.number="voicePersona.sample_rate" class="form-select">
<option :value="22050">22050 Hz · CosyVoice 1</option>
<option :value="24000">24000 Hz · CosyVoice 2/3</option>
<option :value="16000">16000 Hz</option>
<option :value="44100">44100 Hz</option>
<option :value="48000">48000 Hz</option>
</select>
</div>
<div class="form-group">
<label>备用神经音色</label>
<select v-model="voicePersona.fallback_voice" class="form-select">
<option value="marin">Marin · 自然女声</option>
<option value="cedar">Cedar · 自然男声</option>
<option value="coral">Coral · 亲切明亮</option>
<option value="nova">Nova · 清晰柔和</option>
<option value="onyx">Onyx · 沉稳男声</option>
<option value="alloy">Alloy · 中性</option>
</select>
</div>
<div class="form-group">
<label>单句超时</label>
<div class="input-suffix">
<input v-model.number="voicePersona.timeout_seconds" class="form-input" type="number" min="2" max="60" />
<span></span>
</div>
</div>
</div>
<div class="form-group">
<label>音色表达指令</label>
<textarea
v-model.trim="voicePersona.instruct_text"
class="form-input persona-textarea"
rows="3"
maxlength="1000"
placeholder="例如:温暖自然、语速适中、停顿真实,避免播音腔和夸张情绪"
/>
<p class="field-hint">Instruct / Instruct2 模式会直接使用其他模式也会保留切换后无需重填</p>
</div>
<div v-if="referenceRequired" class="reference-box">
<div>
<strong>真人音色参考</strong>
<p>上传已取得说话人授权无背景音乐的 WAV并填写完全一致的逐字稿</p>
</div>
<input ref="referenceInput" hidden type="file" accept=".wav,audio/wav" @change="uploadVoiceReference" />
<button type="button" class="btn btn-ghost reference-btn" :disabled="uploadingVoice" @click="referenceInput?.click()">
{{ uploadingVoice ? '上传中...' : voicePersona.prompt_wav_name || '上传 WAV 音色样本' }}
</button>
</div>
<div v-if="voicePersona.mode === 'zero_shot'" class="form-group">
<label>参考音频逐字稿</label>
<textarea
v-model.trim="voicePersona.prompt_text"
class="form-input persona-textarea"
rows="3"
maxlength="1500"
placeholder="CosyVoice 3 示例:You are a helpful assistant.<|endofprompt|>这里填写参考音频逐字稿。"
/>
</div>
<div class="persona-actions">
<button
v-if="auth.hasButton('btn:settings:save')"
type="button"
class="btn btn-ghost preview-btn"
:disabled="saving || previewingVoice"
@click="previewVoice"
>{{ previewingVoice ? '正在生成试听...' : '保存并试听音色' }}</button>
<audio v-if="previewUrl" ref="previewAudio" class="preview-audio" :src="previewUrl" controls />
<span v-if="voiceStatus" class="voice-status" :class="{ error: voiceStatusError }">{{ voiceStatus }}</span>
</div>
</div>
<div class="panel feature-panel">
<h3 class="section-title">功能开关会员端</h3>
<p class="section-desc">关闭后会员端对应功能将不可用</p>
<div class="feature-grid">
<label v-for="(val, key) in features" :key="key" class="feature-item">
<input type="checkbox" v-model="features[key]" />
<input v-model="features[key]" type="checkbox" />
<span>{{ featureLabels[key] || key }}</span>
</label>
</div>
<button v-if="auth.hasButton('btn:settings:save')" class="btn btn-primary" @click="saveAll" :disabled="saving">
<button v-if="auth.hasButton('btn:settings:save')" class="btn btn-primary" :disabled="saving" @click="saveAll">
{{ saving ? '保存中...' : '保存全部设置' }}
</button>
<p v-if="saved" class="success-msg">保存成功</p>
<p v-if="pageError" class="form-error">{{ pageError }}</p>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const siteName = ref('AI Chat')
const allowRegister = ref(true)
const saving = ref(false)
const saved = ref(false)
const pageError = ref('')
const referenceInput = ref(null)
const uploadingVoice = ref(false)
const previewingVoice = ref(false)
const previewUrl = ref('')
const previewAudio = ref(null)
const voiceStatus = ref('')
const voiceStatusError = ref(false)
const features = reactive({
markdown: true,
@@ -61,6 +223,55 @@ const features = reactive({
paste_image: true
})
const voicePersona = reactive({
enabled: true,
name: '小暖',
greeting: '您好,我是 AI 客服小暖,请问有什么可以帮您?',
role_prompt: '温暖、专业、耐心,像经验丰富的真人客服一样理解用户的真实诉求。',
base_url: 'http://127.0.0.1:50000',
mode: 'sft',
speaker: '中文女',
instruct_text: '请用温暖、自然、耐心的中文客服语气表达,语速适中,停顿真实,避免播音腔和夸张情绪。',
prompt_text: '',
prompt_wav: '',
prompt_wav_name: '',
sample_rate: 22050,
fallback_voice: 'marin',
connect_timeout_ms: 800,
timeout_seconds: 8,
failure_ttl: 20
})
const personaPresets = [
{
id: 'warm',
label: '温柔耐心',
name: '小暖',
speaker: '中文女',
fallback_voice: 'marin',
role_prompt: '温暖、专业、耐心,善于安抚情绪,像经验丰富的真人客服一样理解用户的真实诉求。',
instruct_text: '请用温暖、自然、耐心的中文客服语气表达,语速适中,停顿真实,避免播音腔和夸张情绪。'
},
{
id: 'steady',
label: '沉稳专业',
name: '阿诚',
speaker: '中文男',
fallback_voice: 'cedar',
role_prompt: '沉稳、可靠、专业,回答简洁清楚,先解决问题再补充必要信息。',
instruct_text: '请用沉稳可信、自然克制的中文男声表达,语速稍慢,停顿从容,不要播音腔。'
},
{
id: 'bright',
label: '亲切活力',
name: '小晴',
speaker: '中文女',
fallback_voice: 'coral',
role_prompt: '亲切、积极、有活力,表达轻松但不过度热情,快速抓住用户重点。',
instruct_text: '请用亲切明亮、自然轻松的中文语气表达,节奏轻快,保留真实呼吸与停顿。'
}
]
const featureLabels = {
markdown: 'Markdown 解析',
image: '图片解析',
@@ -74,48 +285,343 @@ const featureLabels = {
paste_image: '粘贴图片'
}
onMounted(async () => {
const res = await api.get('/admin/settings')
const data = res.data.data
const personaInitial = computed(() => (voicePersona.name || 'AI').trim().slice(0, 1).toUpperCase())
const referenceRequired = computed(() => ['zero_shot', 'cross_lingual', 'instruct2'].includes(voicePersona.mode))
if (data.site_name) {
siteName.value = data.site_name.value
}
if (data.allow_register) {
allowRegister.value = data.allow_register.value === true || data.allow_register.value === 'true'
}
if (data.features?.value) {
Object.assign(features, data.features.value)
}
})
onMounted(loadSettings)
onBeforeUnmount(revokePreview)
async function saveAll() {
async function loadSettings() {
pageError.value = ''
try {
const res = await api.get('/admin/settings')
const data = res.data.data || {}
if (data.site_name) siteName.value = data.site_name.value
if (data.allow_register) {
allowRegister.value = data.allow_register.value === true || data.allow_register.value === 'true'
}
if (data.features?.value) Object.assign(features, data.features.value)
if (data.voice_persona?.value) Object.assign(voicePersona, data.voice_persona.value)
} catch (error) {
pageError.value = error.message || '设置加载失败'
}
}
function applyPersonaPreset(preset) {
Object.assign(voicePersona, {
name: preset.name,
speaker: preset.speaker,
fallback_voice: preset.fallback_voice,
role_prompt: preset.role_prompt,
instruct_text: preset.instruct_text
})
voiceStatus.value = `已应用“${preset.label}”模板,保存后生效`
voiceStatusError.value = false
}
async function saveAll(showMessage = true) {
saving.value = true
saved.value = false
pageError.value = ''
try {
await api.put('/admin/settings', {
site_name: siteName.value,
allow_register: allowRegister.value ? 'true' : 'false',
features: { ...features }
features: { ...features },
voice_persona: { ...voicePersona }
})
saved.value = true
setTimeout(() => { saved.value = false }, 3000)
if (showMessage !== false) {
saved.value = true
setTimeout(() => { saved.value = false }, 3000)
}
return true
} catch (error) {
pageError.value = error.message || '保存失败'
return false
} finally {
saving.value = false
}
}
async function uploadVoiceReference(event) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
uploadingVoice.value = true
voiceStatus.value = ''
try {
const form = new FormData()
form.append('file', file)
const res = await api.post('/admin/voice/reference', form, {
headers: { 'Content-Type': 'multipart/form-data' }
})
Object.assign(voicePersona, {
prompt_wav: res.data.data.path,
prompt_wav_name: res.data.data.name
})
voiceStatus.value = `音色样本“${res.data.data.name}”已上传`
voiceStatusError.value = false
} catch (error) {
voiceStatus.value = error.message || '音色样本上传失败'
voiceStatusError.value = true
} finally {
uploadingVoice.value = false
}
}
async function previewVoice() {
previewingVoice.value = true
voiceStatus.value = ''
voiceStatusError.value = false
revokePreview()
try {
const savedOk = await saveAll(false)
if (!savedOk) throw new Error(pageError.value || '请先修正配置')
const token = localStorage.getItem('admin_token')
const response = await fetch('/api/admin/voice/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
text: `${voicePersona.greeting} 我会认真听取您的问题,并尽快为您处理。`
})
})
if (!response.ok) {
const error = await response.json().catch(() => null)
throw new Error(error?.message || `试听生成失败(HTTP ${response.status}`)
}
const blob = await response.blob()
previewUrl.value = URL.createObjectURL(blob)
voiceStatus.value = '试听已生成,正在播放'
await nextTick()
const playRequest = previewAudio.value?.play()
await playRequest?.catch(() => {})
} catch (error) {
voiceStatus.value = error.message || '试听生成失败'
voiceStatusError.value = true
} finally {
previewingVoice.value = false
}
}
function revokePreview() {
previewAudio.value?.pause()
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
</script>
<style scoped>
.section-title {
font-size: 16px;
margin-bottom: 16px;
font-size: 16px;
}
.section-desc,
.field-hint {
color: var(--text-secondary);
font-size: 12px;
}
.section-desc {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 16px;
font-size: 13px;
}
.voice-persona-panel,
.feature-panel {
margin-top: 16px;
}
.persona-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.persona-preview {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.persona-avatar {
width: 54px;
height: 54px;
flex: 0 0 54px;
border-radius: 18px;
display: grid;
place-items: center;
border: 1px solid rgba(183, 243, 107, 0.42);
background: linear-gradient(145deg, var(--accent-hover), var(--accent));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45), 0 5px 0 #577c2f, 0 13px 26px rgba(110, 166, 54, 0.16);
color: #11150e;
font-size: 22px;
font-weight: 700;
}
.persona-kicker {
color: var(--accent);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.13em;
}
.persona-preview h3 {
margin-top: 3px;
font-size: 18px;
}
.persona-preview p {
margin-top: 4px;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.5;
}
.enable-switch {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 11px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-secondary);
font-size: 12px;
white-space: nowrap;
}
.enable-switch input,
.feature-item input,
.check-item input {
accent-color: var(--accent);
}
.preset-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin: 18px 0;
}
.preset-row > span {
margin-right: 4px;
color: var(--text-secondary);
font-size: 12px;
}
.preset-btn {
padding: 7px 11px;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(255, 255, 255, 0.02);
color: var(--text-secondary);
font-size: 12px;
}
.preset-btn:hover {
border-color: rgba(183, 243, 107, 0.4);
background: var(--accent-soft);
color: var(--accent);
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.form-grid-3 {
grid-template-columns: 1fr 1fr 0.7fr;
}
.persona-textarea {
min-height: 86px;
resize: vertical;
line-height: 1.55;
}
.field-hint {
margin-top: 6px;
line-height: 1.5;
}
.input-suffix {
position: relative;
}
.input-suffix input {
padding-right: 38px;
}
.input-suffix span {
position: absolute;
top: 50%;
right: 12px;
color: var(--text-muted);
font-size: 12px;
transform: translateY(-50%);
}
.reference-box {
margin-bottom: 16px;
padding: 14px;
border: 1px dashed var(--border-strong);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: rgba(183, 243, 107, 0.035);
}
.reference-box strong {
font-size: 13px;
}
.reference-box p {
margin-top: 4px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
}
.reference-btn {
flex: 0 0 auto;
border: 1px solid var(--border);
}
.persona-actions {
min-height: 38px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.preview-btn {
border: 1px solid var(--border-strong);
}
.preview-audio {
width: min(320px, 100%);
height: 36px;
}
.voice-status {
color: var(--success);
font-size: 12px;
}
.voice-status.error {
color: var(--danger);
}
.feature-grid {
@@ -125,28 +631,35 @@ async function saveAll() {
margin-bottom: 20px;
}
.feature-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
cursor: pointer;
}
.feature-item input {
accent-color: var(--accent);
}
.feature-item,
.check-item {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
}
.success-msg {
margin-top: 12px;
color: var(--success);
font-size: 14px;
margin-top: 12px;
}
@media (max-width: 760px) {
.persona-heading,
.reference-box {
align-items: stretch;
flex-direction: column;
}
.enable-switch {
align-self: flex-start;
}
.form-grid,
.form-grid-3 {
grid-template-columns: 1fr;
}
}
</style>
+2 -2
View File
@@ -422,7 +422,7 @@ function formatDate(d) {
.perm-tag {
font-size: 12px;
padding: 2px 8px;
background: rgba(99, 102, 241, 0.15);
background: var(--accent-soft);
color: var(--accent);
border-radius: 4px;
}
@@ -433,6 +433,6 @@ function formatDate(d) {
}
.danger {
color: #ef4444;
color: var(--danger);
}
</style>