This commit is contained in:
2026-08-05 15:56:08 +08:00
parent 01729b1e0b
commit 2d9e2376b6
106 changed files with 10007 additions and 253 deletions
+166
View File
@@ -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; }
+4
View File
@@ -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',
+22
View File
@@ -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: '角色管理',
+3 -1
View File
@@ -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()
+2
View File
@@ -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' } }
]
+313
View File
@@ -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>
+3 -3
View File
@@ -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'">
+4 -2
View File
@@ -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())