gengx
This commit is contained in:
@@ -952,3 +952,169 @@ html[data-theme] .persona-avatar {
|
||||
color: var(--accent-strong);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Aurora UI — bright, calm and dimensional */
|
||||
:root {
|
||||
--bg-primary: #f3f6fb;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #eef2f8;
|
||||
--bg-hover: #edf1ff;
|
||||
--surface-raised: #ffffff;
|
||||
--surface-inset: #f7f9fc;
|
||||
--surface-neutral: rgba(67, 56, 202, 0.055);
|
||||
--surface-neutral-hover: rgba(67, 56, 202, 0.1);
|
||||
--glass-header: rgba(255, 255, 255, 0.86);
|
||||
--glass-panel: rgba(255, 255, 255, 0.94);
|
||||
--text-primary: #172033;
|
||||
--text-secondary: #5f6b7e;
|
||||
--text-muted: #929db0;
|
||||
--accent: #5457d9;
|
||||
--accent-hover: #4547c4;
|
||||
--accent-strong: #3436a8;
|
||||
--accent-soft: #eef0ff;
|
||||
--border: #e4e9f2;
|
||||
--border-strong: #d5ddea;
|
||||
--danger: #dc4c64;
|
||||
--success: #159570;
|
||||
--warning: #c58116;
|
||||
--button-primary-bg: #5154d8;
|
||||
--button-primary-hover: #4144bf;
|
||||
--button-primary-text: #ffffff;
|
||||
--sidebar-width: 268px;
|
||||
--shadow: 0 22px 60px rgba(43, 57, 91, 0.14);
|
||||
--shadow-soft: 0 8px 28px rgba(43, 57, 91, 0.08);
|
||||
--shadow-control: rgba(43, 57, 91, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--bg-primary: #0e1422;
|
||||
--bg-secondary: #151d2e;
|
||||
--bg-tertiary: #1c2639;
|
||||
--bg-hover: #242f46;
|
||||
--surface-raised: #1a2437;
|
||||
--surface-inset: #111929;
|
||||
--surface-neutral: rgba(142, 147, 255, 0.08);
|
||||
--surface-neutral-hover: rgba(142, 147, 255, 0.14);
|
||||
--glass-header: rgba(21, 29, 46, 0.86);
|
||||
--glass-panel: rgba(21, 29, 46, 0.94);
|
||||
--text-primary: #f4f6fb;
|
||||
--text-secondary: #aeb8ca;
|
||||
--text-muted: #738097;
|
||||
--accent: #979aff;
|
||||
--accent-hover: #b1b3ff;
|
||||
--accent-strong: #c8caff;
|
||||
--accent-soft: rgba(111, 115, 255, 0.16);
|
||||
--border: #263249;
|
||||
--border-strong: #34415b;
|
||||
--button-primary-bg: #777bf2;
|
||||
--button-primary-hover: #8d90ff;
|
||||
--button-primary-text: #ffffff;
|
||||
--shadow: 0 24px 64px rgba(0, 0, 0, 0.35);
|
||||
--shadow-soft: 0 10px 30px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
html[data-theme] body,
|
||||
html[data-theme] .admin-layout,
|
||||
html[data-theme] .main-area,
|
||||
html[data-theme] .main-content {
|
||||
background:
|
||||
radial-gradient(circle at 72% -12%, rgba(99, 102, 241, 0.1), transparent 34%),
|
||||
radial-gradient(circle at 105% 90%, rgba(56, 189, 248, 0.07), transparent 30%),
|
||||
var(--bg-primary) !important;
|
||||
}
|
||||
|
||||
html[data-theme] .sidebar {
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background:
|
||||
radial-gradient(circle at 15% -5%, rgba(125, 130, 255, 0.3), transparent 28%),
|
||||
linear-gradient(180deg, #18213a 0%, #11182a 100%) !important;
|
||||
box-shadow: 12px 0 34px rgba(27, 37, 66, 0.12) !important;
|
||||
color: #eef2ff;
|
||||
}
|
||||
|
||||
html[data-theme] .sidebar :is(.brand-copy strong, .admin-user-copy strong) { color: #f7f8ff; }
|
||||
html[data-theme] .sidebar :is(.brand-copy small, .nav-group-title, .admin-user-copy small) { color: #8290ad; }
|
||||
html[data-theme] .sidebar :is(.nav-item, .sidebar-member-link) { color: #aeb8cf; }
|
||||
|
||||
html[data-theme] .brand-icon {
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(145deg, #8589ff, #5659db);
|
||||
box-shadow: 0 8px 22px rgba(84, 87, 217, 0.38) !important;
|
||||
}
|
||||
|
||||
html[data-theme] .sidebar-member-link {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
html[data-theme] .nav-item { border-radius: 10px; }
|
||||
html[data-theme] .nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: #ffffff;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
html[data-theme] .nav-item.router-link-active {
|
||||
background: linear-gradient(90deg, rgba(129, 134, 255, 0.27), rgba(129, 134, 255, 0.1));
|
||||
color: #ffffff;
|
||||
box-shadow: inset 3px 0 0 #8e92ff;
|
||||
}
|
||||
|
||||
html[data-theme] .router-link-active .nav-icon,
|
||||
html[data-theme] .nav-icon {
|
||||
border-radius: 9px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
html[data-theme] .sidebar-footer {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(6, 10, 20, 0.22);
|
||||
}
|
||||
|
||||
html[data-theme] .sidebar .avatar {
|
||||
border-color: rgba(142, 146, 255, 0.28);
|
||||
border-radius: 10px;
|
||||
background: rgba(129, 134, 255, 0.16);
|
||||
color: #c8caff;
|
||||
}
|
||||
|
||||
html[data-theme] .sidebar .btn-ghost {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
color: #c5cde0;
|
||||
}
|
||||
|
||||
html[data-theme] .main-content { padding: clamp(28px, 3vw, 48px); }
|
||||
html[data-theme] .page-header { padding-left: 0; }
|
||||
html[data-theme] .page-header::before { display: none; }
|
||||
html[data-theme] .page-header h2 { color: var(--text-primary); font-size: clamp(26px, 2.4vw, 36px); }
|
||||
|
||||
html[data-theme] :is(.panel, .membership-card, .stat-card) {
|
||||
border-color: rgba(213, 221, 234, 0.82);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: var(--shadow-soft) !important;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] :is(.panel, .membership-card, .stat-card) {
|
||||
border-color: var(--border);
|
||||
background: rgba(21, 29, 46, 0.9);
|
||||
}
|
||||
|
||||
html[data-theme] .btn { border-radius: 10px; }
|
||||
html[data-theme] .btn:hover { border-color: #cbd4e3; box-shadow: 0 5px 14px rgba(43, 57, 91, 0.08); transform: translateY(-1px); }
|
||||
html[data-theme] .btn-primary {
|
||||
border-color: transparent;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #6265e5, #484bc8);
|
||||
box-shadow: 0 8px 20px rgba(72, 75, 200, 0.24);
|
||||
}
|
||||
html[data-theme] .btn-primary:hover { border-color: transparent; background: linear-gradient(135deg, #7174ee, #4144bc); box-shadow: 0 10px 24px rgba(72, 75, 200, 0.3); }
|
||||
|
||||
html[data-theme] :is(.form-input, .form-select) { border-radius: 10px; background: var(--surface-inset); }
|
||||
html[data-theme] :is(.form-input, .form-select):focus { border-color: #7a7de8; box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.13); }
|
||||
html[data-theme] .data-table th { background: rgba(247, 249, 252, 0.92); color: #7b879a; }
|
||||
html[data-theme] .data-table tbody tr:hover td { background: rgba(238, 240, 255, 0.62); }
|
||||
html[data-theme] .modal { border-radius: 16px; box-shadow: var(--shadow) !important; }
|
||||
|
||||
@@ -26,7 +26,9 @@ const props = defineProps({
|
||||
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',
|
||||
visitors: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9v-1a7 7 0 0 1 14 0v1M18.5 8.5 21 11l-2.5 2.5M21 11h-5',
|
||||
departments: 'M3 21h18M5 21V8l7-4 7 4v13M9 11h1m4 0h1m-6 4h1m4 0h1m-4 6v-3h2v3',
|
||||
ticket: 'M3 7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v3a2 2 0 0 0 0 4v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3a2 2 0 0 0 0-4V7Zm9 0v2m0 2v2m0 2v2',
|
||||
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',
|
||||
@@ -47,7 +49,9 @@ const paths = {
|
||||
const aliases = {
|
||||
'/dashboard': 'dashboard',
|
||||
'/users': 'users',
|
||||
'/guests': 'visitors',
|
||||
'/departments': 'departments',
|
||||
'/invitations': 'ticket',
|
||||
'/roles': 'roles',
|
||||
'/conversations': 'conversations',
|
||||
'/memberships': 'memberships',
|
||||
|
||||
@@ -33,6 +33,17 @@ export const permissionTree = [
|
||||
{ code: 'btn:user:delete', name: '删除用户', type: 'btn' }
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'menu:guests',
|
||||
name: '访客管理',
|
||||
type: 'menu',
|
||||
path: '/guests',
|
||||
icon: 'visitors',
|
||||
children: [
|
||||
{ code: 'btn:guest:status', name: '启用/禁用访客', type: 'btn' },
|
||||
{ code: 'btn:guest:delete', name: '删除访客', type: 'btn' }
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'menu:departments',
|
||||
name: '部门管理',
|
||||
@@ -45,6 +56,17 @@ export const permissionTree = [
|
||||
{ code: 'btn:dept:delete', name: '删除部门', type: 'btn' }
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'menu:invitations',
|
||||
name: '邀请码管理',
|
||||
type: 'menu',
|
||||
path: '/invitations',
|
||||
icon: 'ticket',
|
||||
children: [
|
||||
{ code: 'btn:invitation:create', name: '生成邀请码', type: 'btn' },
|
||||
{ code: 'btn:invitation:revoke', name: '作废邀请码', type: 'btn' }
|
||||
]
|
||||
},
|
||||
{
|
||||
code: 'menu:roles',
|
||||
name: '角色管理',
|
||||
|
||||
@@ -103,7 +103,9 @@ const titleMap = computed(() =>
|
||||
|
||||
const currentTitle = computed(() => titleMap.value[route.path] || '管理后台')
|
||||
const avatarLetter = computed(() => (auth.user?.username || 'A').charAt(0).toUpperCase())
|
||||
const memberUrl = import.meta.env.VITE_MEMBER_URL || `${window.location.protocol}//${window.location.hostname}:5173`
|
||||
const memberUrl = import.meta.env.VITE_MEMBER_URL || (import.meta.env.PROD
|
||||
? `${window.location.origin}/`
|
||||
: `${window.location.protocol}//${window.location.hostname}:5173`)
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout()
|
||||
|
||||
@@ -17,6 +17,7 @@ const routes = [
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/DashboardView.vue'), meta: { menu: 'menu:dashboard' } },
|
||||
{ path: 'users', name: 'Users', component: () => import('@/views/UsersView.vue'), meta: { menu: 'menu:users' } },
|
||||
{ path: 'guests', name: 'Guests', component: () => import('@/views/GuestsView.vue'), meta: { menu: 'menu:guests' } },
|
||||
{
|
||||
path: 'conversations',
|
||||
name: 'Conversations',
|
||||
@@ -30,6 +31,7 @@ const routes = [
|
||||
{ path: 'memberships', name: 'Memberships', component: () => import('@/views/MembershipsView.vue'), meta: { menu: 'menu:memberships' } },
|
||||
{ path: 'roles', name: 'Roles', component: () => import('@/views/RolesView.vue'), meta: { menu: 'menu:roles' } },
|
||||
{ path: 'departments', name: 'Departments', component: () => import('@/views/DepartmentsView.vue'), meta: { menu: 'menu:departments' } },
|
||||
{ path: 'invitations', name: 'Invitations', component: () => import('@/views/InvitationCodesView.vue'), meta: { menu: 'menu:invitations' } },
|
||||
{ path: 'permissions', name: 'Permissions', component: () => import('@/views/PermissionsView.vue'), meta: { menu: 'menu:permissions' } },
|
||||
{ path: 'settings', name: 'Settings', component: () => import('@/views/SettingsView.vue'), meta: { menu: 'menu:settings' } }
|
||||
]
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>访客管理</h2>
|
||||
<p>单独记录匿名访问者的使用情况,不计入注册用户管理列表</p>
|
||||
</div>
|
||||
|
||||
<div class="guest-summary">
|
||||
<div class="summary-card">
|
||||
<span>当前记录</span>
|
||||
<strong>{{ total }}</strong>
|
||||
<small>匿名访客</small>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span>本页会话</span>
|
||||
<strong>{{ pageConversationCount }}</strong>
|
||||
<small>未删除会话</small>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span>本页消息</span>
|
||||
<strong>{{ pageMessageCount }}</strong>
|
||||
<small>累计消息</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar guest-toolbar">
|
||||
<div class="filter-group">
|
||||
<button
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
class="btn btn-ghost filter-btn"
|
||||
:class="{ active: status === item.value }"
|
||||
type="button"
|
||||
@click="setStatus(item.value)"
|
||||
>{{ item.label }}</button>
|
||||
</div>
|
||||
<form class="search-form" @submit.prevent="searchGuests">
|
||||
<input v-model.trim="keyword" class="form-input" placeholder="搜索访客编号" />
|
||||
<button class="btn btn-ghost" type="submit">搜索</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>访客编号</th>
|
||||
<th>状态</th>
|
||||
<th>会话数</th>
|
||||
<th>消息数</th>
|
||||
<th>首次访问</th>
|
||||
<th>最近访问</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="guest in guests" :key="guest.id">
|
||||
<td>{{ guest.id }}</td>
|
||||
<td>
|
||||
<div class="guest-identity">
|
||||
<strong>访客 #{{ shortGuestId(guest.username) }}</strong>
|
||||
<code>{{ guest.username }}</code>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="guest.status === 'active' ? 'badge-success' : 'badge-danger'">
|
||||
{{ guest.status === 'active' ? '正常' : '已禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ guest.conversation_count || 0 }}</td>
|
||||
<td>{{ guest.message_count || 0 }}</td>
|
||||
<td>{{ formatDate(guest.created_at) }}</td>
|
||||
<td>{{ formatDate(guest.last_login_at) }}</td>
|
||||
<td class="actions-cell">
|
||||
<button
|
||||
v-if="auth.hasButton('btn:guest:status')"
|
||||
class="btn btn-ghost"
|
||||
type="button"
|
||||
@click="toggleStatus(guest)"
|
||||
>{{ guest.status === 'active' ? '禁用' : '启用' }}</button>
|
||||
<button
|
||||
v-if="auth.hasButton('btn:guest:delete')"
|
||||
class="btn btn-ghost danger"
|
||||
type="button"
|
||||
@click="removeGuest(guest)"
|
||||
>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="empty">正在加载...</p>
|
||||
<p v-else-if="!guests.length" class="empty">暂无访客记录</p>
|
||||
|
||||
<div v-if="total > limit" class="pagination">
|
||||
<button class="btn btn-ghost" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button>
|
||||
<span>{{ page }} / {{ totalPages }}</span>
|
||||
<button class="btn btn-ghost" :disabled="page >= totalPages" @click="changePage(page + 1)">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import api from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const guests = ref([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const limit = 20
|
||||
const total = ref(0)
|
||||
const status = ref('')
|
||||
const keyword = ref('')
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '正常', value: 'active' },
|
||||
{ label: '已禁用', value: 'disabled' }
|
||||
]
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit)))
|
||||
const pageConversationCount = computed(() => guests.value.reduce((sum, item) => sum + Number(item.conversation_count || 0), 0))
|
||||
const pageMessageCount = computed(() => guests.value.reduce((sum, item) => sum + Number(item.message_count || 0), 0))
|
||||
|
||||
onMounted(loadGuests)
|
||||
|
||||
async function loadGuests() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/admin/guests', {
|
||||
params: {
|
||||
page: page.value,
|
||||
limit,
|
||||
status: status.value || undefined,
|
||||
keyword: keyword.value || undefined
|
||||
}
|
||||
})
|
||||
const data = res.data.data || {}
|
||||
guests.value = data.list || []
|
||||
total.value = data.total ?? guests.value.length
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(value) {
|
||||
status.value = value
|
||||
page.value = 1
|
||||
loadGuests()
|
||||
}
|
||||
|
||||
function searchGuests() {
|
||||
page.value = 1
|
||||
loadGuests()
|
||||
}
|
||||
|
||||
async function changePage(nextPage) {
|
||||
page.value = nextPage
|
||||
await loadGuests()
|
||||
}
|
||||
|
||||
async function toggleStatus(guest) {
|
||||
const nextStatus = guest.status === 'active' ? 'disabled' : 'active'
|
||||
const action = nextStatus === 'active' ? '启用' : '禁用'
|
||||
if (!confirm(`确定${action}访客 #${shortGuestId(guest.username)}?`)) return
|
||||
|
||||
try {
|
||||
await api.put(`/admin/guests/${guest.id}/status`, { status: nextStatus })
|
||||
await loadGuests()
|
||||
} catch (e) {
|
||||
alert(e.message || `${action}失败`)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGuest(guest) {
|
||||
if (!confirm(`确定删除访客 #${shortGuestId(guest.username)}?其会话和消息记录也会一并删除,此操作不可恢复。`)) return
|
||||
|
||||
try {
|
||||
await api.delete(`/admin/guests/${guest.id}`)
|
||||
if (guests.value.length === 1 && page.value > 1) page.value -= 1
|
||||
await loadGuests()
|
||||
} catch (e) {
|
||||
alert(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function shortGuestId(username) {
|
||||
return String(username || '').replace(/^guest_/, '').slice(-8).toUpperCase()
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-'
|
||||
return new Date(String(value).replace(' ', 'T')).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.guest-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 4px 16px;
|
||||
padding: 17px 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--glass-panel);
|
||||
}
|
||||
|
||||
.summary-card span,
|
||||
.summary-card small {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
grid-row: span 2;
|
||||
font-size: 27px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.guest-toolbar,
|
||||
.filter-group,
|
||||
.search-form,
|
||||
.actions-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.guest-toolbar {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
border-color: var(--accent-line);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.search-form .form-input {
|
||||
width: min(260px, 42vw);
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.guest-identity {
|
||||
display: flex;
|
||||
min-width: 220px;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.guest-identity strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.guest-identity code {
|
||||
width: fit-content;
|
||||
padding: 3px 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 34px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.guest-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.guest-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-form .form-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>邀请码管理</h2>
|
||||
<p>生成一次性注册码并绑定部门,新用户注册成功后会自动归属到该部门</p>
|
||||
</div>
|
||||
|
||||
<div class="toolbar invitation-toolbar">
|
||||
<div class="filter-group">
|
||||
<button
|
||||
v-for="item in filters"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="btn btn-ghost filter-btn"
|
||||
:class="{ active: statusFilter === item.value }"
|
||||
@click="setFilter(item.value)"
|
||||
>{{ item.label }}</button>
|
||||
</div>
|
||||
<button v-if="auth.hasButton('btn:invitation:create')" class="btn btn-primary" @click="openCreate">
|
||||
生成邀请码
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel invitation-panel">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>邀请码</th>
|
||||
<th>归属部门</th>
|
||||
<th>状态</th>
|
||||
<th>有效期</th>
|
||||
<th>使用用户</th>
|
||||
<th>创建人 / 时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in invitations" :key="item.id">
|
||||
<td><code class="invite-code">{{ item.code }}</code></td>
|
||||
<td>{{ item.department_name || '未指定部门' }}</td>
|
||||
<td><span class="badge" :class="statusMeta(item).className">{{ statusMeta(item).label }}</span></td>
|
||||
<td>{{ item.expires_at ? formatDate(item.expires_at) : '长期有效' }}</td>
|
||||
<td>{{ item.used_by_name || '-' }}</td>
|
||||
<td>
|
||||
<div class="creator-cell">
|
||||
<span>{{ item.creator_name || '-' }}</span>
|
||||
<small>{{ formatDate(item.created_at) }}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-ghost" type="button" @click="copyCode(item.code)">复制</button>
|
||||
<button
|
||||
v-if="item.status === 'active' && auth.hasButton('btn:invitation:revoke')"
|
||||
class="btn btn-ghost danger"
|
||||
type="button"
|
||||
@click="revoke(item)"
|
||||
>作废</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="loading" class="empty">正在加载...</p>
|
||||
<p v-else-if="!invitations.length" class="empty">暂无邀请码</p>
|
||||
</div>
|
||||
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>生成邀请码</h3>
|
||||
<button type="button" @click="closeModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>注册后归属部门</label>
|
||||
<select v-model="form.department_id" class="form-select">
|
||||
<option value="">不指定部门</option>
|
||||
<option v-for="dept in departments" :key="dept.id" :value="dept.id">
|
||||
{{ dept.label || dept.name }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="form-help">指定后,使用该邀请码注册的用户会自动加入此部门。</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>过期时间</label>
|
||||
<input v-model="form.expires_at" type="datetime-local" class="form-input" :min="minExpiresAt" />
|
||||
<p class="form-help">留空表示长期有效;邀请码无论是否设置有效期,都只能使用一次。</p>
|
||||
</div>
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" type="button" @click="closeModal">取消</button>
|
||||
<button class="btn btn-primary" type="button" :disabled="creating" @click="createInvitation">
|
||||
{{ creating ? '生成中...' : '确认生成' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import api from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const invitations = ref([])
|
||||
const departments = ref([])
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const showModal = ref(false)
|
||||
const statusFilter = ref('')
|
||||
const error = ref('')
|
||||
const form = reactive({ department_id: '', expires_at: '' })
|
||||
|
||||
const filters = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '可使用', value: 'active' },
|
||||
{ label: '已使用', value: 'used' },
|
||||
{ label: '已作废', value: 'revoked' }
|
||||
]
|
||||
|
||||
const minExpiresAt = computed(() => {
|
||||
const date = new Date(Date.now() + 60 * 60 * 1000)
|
||||
date.setMinutes(date.getMinutes() - date.getTimezoneOffset())
|
||||
return date.toISOString().slice(0, 16)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadInvitations(), loadDepartments()])
|
||||
})
|
||||
|
||||
async function loadInvitations() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/admin/invitations', {
|
||||
params: statusFilter.value ? { status: statusFilter.value } : {}
|
||||
})
|
||||
invitations.value = res.data.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDepartments() {
|
||||
const res = await api.get('/admin/department-options')
|
||||
departments.value = res.data.data || []
|
||||
}
|
||||
|
||||
function setFilter(value) {
|
||||
statusFilter.value = value
|
||||
loadInvitations()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form.department_id = ''
|
||||
form.expires_at = ''
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
async function createInvitation() {
|
||||
creating.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await api.post('/admin/invitations', {
|
||||
department_id: form.department_id || null,
|
||||
expires_at: form.expires_at || null
|
||||
})
|
||||
await copyCode(res.data.data.code)
|
||||
closeModal()
|
||||
statusFilter.value = ''
|
||||
await loadInvitations()
|
||||
} catch (e) {
|
||||
error.value = e.message || '生成失败'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(item) {
|
||||
if (!confirm(`确定作废邀请码「${item.code}」?`)) return
|
||||
try {
|
||||
await api.put(`/admin/invitations/${item.id}/revoke`)
|
||||
await loadInvitations()
|
||||
} catch (e) {
|
||||
alert(e.message || '作废失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCode(code) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
} catch {
|
||||
window.prompt('请复制邀请码', code)
|
||||
}
|
||||
}
|
||||
|
||||
function statusMeta(item) {
|
||||
if (item.status === 'used') return { label: '已使用', className: 'badge-info' }
|
||||
if (item.status === 'revoked') return { label: '已作废', className: 'badge-danger' }
|
||||
if (item.expires_at && new Date(item.expires_at.replace(' ', 'T')).getTime() <= Date.now()) {
|
||||
return { label: '已过期', className: 'badge-warn' }
|
||||
}
|
||||
return { label: '可使用', className: 'badge-success' }
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-'
|
||||
return new Date(String(value).replace(' ', 'T')).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.invitation-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
border-color: var(--accent-line);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.invitation-panel,
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.invite-code {
|
||||
display: inline-block;
|
||||
padding: 6px 8px;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.creator-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.creator-cell small,
|
||||
.form-help {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
margin: 7px 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 34px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.invitation-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -80,14 +80,14 @@
|
||||
<select v-model="form.provider" class="form-input" @change="onProviderChange">
|
||||
<option value="openai">OpenAI 兼容(GPT / DeepSeek / vLLM / SGLang 等)</option>
|
||||
<option value="dify">Dify 应用(chat-messages 接口)</option>
|
||||
<option value="comfy">ComfyUI 文生图</option>
|
||||
<option value="comfy">ComfyUI(图片 / 短剧工作节点)</option>
|
||||
</select>
|
||||
<p class="field-hint" v-if="form.provider === 'dify'">
|
||||
Dify 使用自己的一套接口协议(/chat-messages),跟 OpenAI 的 /chat/completions 不同,不要混用,否则会报 404
|
||||
</p>
|
||||
<p class="field-hint" v-if="form.provider === 'comfy'">
|
||||
用户发送的文字会写入工作流的提示词节点并调用本地 ComfyUI 生图。可在下方粘贴自定义工作流 JSON;留空则使用服务器默认文件
|
||||
<code>backend/config/comfyui_workflow.json</code>(当前为 ZImageTurbo)。
|
||||
每个不同的 API 地址会被短剧系统识别为一个独立工作节点,最多并发 3 个;相同地址仍按 ComfyUI 单队列串行,避免假并发和显存溢出。
|
||||
下方工作流 JSON 用于对话生图;留空则使用服务器默认文件 <code>backend/config/comfyui_workflow.json</code>。
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group" v-if="form.provider === 'openai'">
|
||||
|
||||
@@ -220,7 +220,8 @@ const features = reactive({
|
||||
upload_image: true,
|
||||
upload_video: true,
|
||||
upload_file: true,
|
||||
paste_image: true
|
||||
paste_image: true,
|
||||
short_drama: true
|
||||
})
|
||||
|
||||
const voicePersona = reactive({
|
||||
@@ -282,7 +283,8 @@ const featureLabels = {
|
||||
upload_image: '上传图片',
|
||||
upload_video: '上传视频',
|
||||
upload_file: '上传文件',
|
||||
paste_image: '粘贴图片'
|
||||
paste_image: '粘贴图片',
|
||||
short_drama: '短剧工坊入口(登录用户)'
|
||||
}
|
||||
|
||||
const personaInitial = computed(() => (voicePersona.name || 'AI').trim().slice(0, 1).toUpperCase())
|
||||
|
||||
Reference in New Issue
Block a user