first commit

This commit is contained in:
Vicky committed 2026-10-12 17:35:07 +08:00
commit db15eb3ee0
42 files changed
+4620

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.DS_Store
.env
.env.*
!.env.example
+38
View File
@@ -0,0 +1,38 @@
# ReelShort Studio Console
Frontend-only short-drama content management console built with Vue 3, Vite, Vue Router, and Element Plus.
## Development
```sh
npm install
npm run dev
```
## Production build
```sh
npm run build
npm run preview
```
## Demo access
- Username: `admin`
- Password: `admin123`
Data and RBAC changes are local demo state. No backend service is connected.
## Source layout
- `src/views/common/` contains shared route pages such as login and dashboard.
- `src/views/contentManagement/` contains short-drama and episode pages.
- `src/views/permission/` contains role, menu, and role-category pages plus their composables.
- `src/views/userManagement/` contains administrator pages and their page data/composables.
- `src/views/systemManagement/` contains system settings pages.
- `src/views/components/` contains reusable page-level UI components.
- `src/data/` contains shared domain records used across views.
- `src/layouts/` contains the authenticated application shell.
- `src/router/` contains routes and access guards.
- `src/stores/` contains auth, RBAC, and theme state.
- `src/permissions/` contains permission definitions and role defaults.
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="theme-color" content="#f6f7fb" /><title>ReelShort · 内容管理</title></head>
<body><div id="app"></div><script type="module" src="/src/main.js"></script></body>
</html>
+1565
View File
File diff suppressed because it is too large. Load diff
+13
View File
@@ -0,0 +1,13 @@
{
"name": "reelshort-admin",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": { "@element-plus/icons-vue": "^2.3.1", "element-plus": "^2.9.1", "vue": "^3.5.13", "vue-router": "^4.5.0" },
"devDependencies": { "@vitejs/plugin-vue": "^5.2.1", "vite": "^6.0.7" }
}
+5
View File
@@ -0,0 +1,5 @@
<template><RouterView /></template>
<script setup>
import { RouterView } from 'vue-router'
</script>
+85
View File
@@ -0,0 +1,85 @@
html.dark {
color-scheme:dark;
--el-bg-color:#242830;
--el-bg-color-page:#1b1e24;
--el-bg-color-overlay:#2a2f38;
--el-text-color-primary:#e5e8ed;
--el-text-color-regular:#c0c6cf;
--el-text-color-secondary:#9ba3ae;
--el-border-color:#414751;
--el-border-color-light:#373d47;
--el-border-color-lighter:#303640;
--el-fill-color-blank:#242830;
--el-fill-color-light:#303640;
--el-fill-color-lighter:#2a2f38;
--el-fill-color:#343a44;
--el-color-primary:#82a9dc;
--el-color-success:#66b587;
--el-color-warning:#d5a348;
--el-color-danger:#d27676}
html.dark body,html.dark .app-shell,html.dark .login-screen {
background:#1b1e24;
color:#e5e8ed}
html.dark .sidebar,html.dark .topbar,html.dark .page-tabs,html.dark .panel,html.dark .stat-card,html.dark .cover-card,html.dark .add-cover,html.dark .login-card {
background:#242830;
border-color:#3b414b}
html.dark .brand {
border-color:#3b414b}
html.dark .brand b,html.dark .page-heading h1,html.dark .panel-heading h2,html.dark .stat-value,html.dark .drama-cell b,html.dark .episode-title b,html.dark .role-copy b,html.dark .login-heading h1 {
color:#e5e8ed}
html.dark .brand small,html.dark .page-heading p,html.dark .panel-heading p,html.dark .stat-top,html.dark .breadcrumb,html.dark .drama-table td,html.dark .role-copy small,html.dark .role-copy span,html.dark .episode-title small,html.dark .login-heading p {
color:#a6aeb9}
html.dark .side-menu,html.dark .side-menu>.el-menu-item,html.dark .side-menu .el-sub-menu__title {
background:transparent;
color:#c0c6cf}
html.dark .side-menu .el-menu-item:hover,html.dark .side-menu .el-sub-menu__title:hover {
background:#2e3540;
color:#e5e8ed}
html.dark .side-menu .el-menu-item.is-active {
background:#303d50;
color:#a8c7f0}
html.dark .drama-table th {
background:#2b3038;
color:#b1b8c2}
html.dark .drama-table td,html.dark .episode-row,html.dark .role-card,html.dark .page-tabs {
border-color:#3b414b}
html.dark .episode-row,html.dark .role-card {
background:#242830}
html.dark .role-active {
background:#2d3540;
border-color:#607998}
html.dark .episode-thumb,html.dark .drama-cover,html.dark .cover-placeholder {
background:#353b45!important;
color:#b9c1cb}
html.dark .permission-note,html.dark .library-toolbar {
background:#2b3038;
border-color:#3b414b}
html.dark .page-tabs .el-tabs__item {
color:#b8c0ca}
html.dark .page-tabs .el-tabs__item.is-active {
background:#2b3340;
color:#a8c7f0;
border-color:#3b414b!important}
html.dark .cover-card-info b,html.dark .cover-card-info small,html.dark .selected-file {
color:#d3d8e0}
html.dark .login-card {
box-shadow:0 8px 30px #0003}
html.dark .demo-credentials {
border-color:#3b414b;
color:#aab2bc}
html.dark .el-dialog {
background:#242830}
html.dark .el-dialog__title,html.dark .el-form-item__label {
color:#e1e5eb}
html.dark .el-upload-dragger {
background:#2b3038;
border-color:#4a515c}
html.dark .fullscreen-button,html.dark .theme-button,html.dark .logout-button,html.dark .role-switch {
color:#c5ccd5!important}
.theme-button {
font-size:18px!important;
color:#56616e!important;
padding:7px!important}
.theme-button:hover {
color:#355f97!important;
background:#f1f4f7!important}
+12
View File
@@ -0,0 +1,12 @@
import { reactive } from 'vue'
const storageKey = 'reelshort-custom-menus'
let savedMenus = []
try { savedMenus = JSON.parse(localStorage.getItem(storageKey) || '[]') } catch { savedMenus = [] }
export const customMenus = reactive(Array.isArray(savedMenus) ? savedMenus : [])
export const menuIconNames = ['Document', 'DataAnalysis', 'Picture', 'User', 'Setting', 'VideoCamera']
export function saveCustomMenus() {
localStorage.setItem(storageKey, JSON.stringify(customMenus))
}
+9
View File
@@ -0,0 +1,9 @@
import { reactive } from 'vue'
export const dramas = reactive([
{ id: 1, title: '替嫁新娘:总裁的掌心宠', genre: '都市情感', status: '已上线', episodes: 68, views: '2.4M', updatedAt: '2025-06-12', description: '一场意外替嫁,让她成为冷面总裁的契约新娘。', cover: '' },
{ id: 2, title: '重生之我在豪门当千金', genre: '重生逆袭', status: '审核中', episodes: 42, views: '—', updatedAt: '2025-06-18', description: '重活一世,她要拿回属于自己的一切。', cover: '' },
{ id: 3, title: '心动信号:请和我恋爱', genre: '甜宠爱情', status: '已上线', episodes: 56, views: '1.8M', updatedAt: '2025-05-29', description: '恋爱观察室里,心动正在悄悄发生。', cover: '' },
{ id: 4, title: '她从深渊中走来', genre: '悬疑复仇', status: '草稿', episodes: 24, views: '—', updatedAt: '2025-06-20', description: '真相只有一个,而她亲手揭开迷雾。', cover: '' },
{ id: 5, title: '闪婚后,傅先生每天都在撒糖', genre: '都市情感', status: '已上线', episodes: 72, views: '3.1M', updatedAt: '2025-05-21', description: '一纸婚约,开启了甜蜜又心动的同居生活。', cover: '' },
])
+8
View File
@@ -0,0 +1,8 @@
import { reactive } from 'vue'
export const episodeData = reactive([
{ id: 1, dramaId: 1, number: 1, title: '命运的相遇', duration: '02:18', status: '已上线', views: '48,201' },
{ id: 2, dramaId: 1, number: 2, title: '意外的婚约', duration: '01:56', status: '已上线', views: '42,817' },
{ id: 3, dramaId: 1, number: 3, title: '心动的开始', duration: '02:04', status: '已上线', views: '39,506' },
{ id: 4, dramaId: 1, number: 4, title: '他的秘密', duration: '02:31', status: '待发布', views: '—' },
])
+8
View File
@@ -0,0 +1,8 @@
import { reactive } from 'vue'
export const roleData = reactive([
{ id: 1, name: '超级管理员', description: '拥有系统全部管理权限', memberCount: 2, category: '系统管理员', level: 1, status: '启用' },
{ id: 2, name: '内容运营', description: '管理短剧、剧集及封面素材', memberCount: 8, category: '内容管理', level: 2, status: '启用' },
{ id: 3, name: '审核专员', description: '审核短剧内容与发布状态', memberCount: 5, category: '内容管理', level: 3, status: '启用' },
{ id: 4, name: '数据分析师', description: '查看内容数据与运营报表', memberCount: 3, category: '数据分析', level: 4, status: '启用' },
])
+252
View File
@@ -0,0 +1,252 @@
.library-toolbar {
justify-content:flex-start;
gap:16px;
min-height:64px;
padding:12px 0!important}
.drama-filters {
display:flex;
align-items:center;
gap:10px;
flex:0 0 auto}
.drama-filters>.el-input {
width:300px!important;
flex:0 0 300px!important}
.drama-filters>.el-select {
width:160px!important;
flex:0 0 160px!important}
.table-result-count {
margin-left:auto;
color:#858b95;
font-size:13px;
white-space:nowrap}
.table-actions {
display:flex;
align-items:center;
gap:8px;
white-space:nowrap}
.table-actions .el-button {
font-size:13px!important;
font-weight:400!important;
padding:5px 4px!important}
.table-actions .danger-action {
color:#a66a6a!important}
.table-actions .danger-action:hover {
color:#b54848!important}
.drama-cell .drama-cover,.drama-cell .drama-cover-image {
width:44px;
height:56px;
flex:0 0 44px;
border-radius:4px;
object-fit:cover}
.drama-cell .drama-cover {
display:grid;
place-content:center;
text-align:center;
font-size:10px;
line-height:1.3;
color:#7e8791;
background:#f0f2f4!important}
.dark .table-result-count {
color:#a6aeb9}
.dark .drama-cell .drama-cover {
background:#353b45!important;
color:#b9c1cb}
@media(max-width:800px) {
.drama-filters>.el-input {
width:min(300px,45vw)!important;
flex-basis:min(300px,45vw)!important}
.drama-filters>.el-select {
width:150px!important;
flex-basis:150px!important}
.table-result-count {
display:none}
}
.drama-el-table,.dashboard-el-table {
width:100%;
font-size:13px;
--el-table-header-bg-color:#f7f8fa;
--el-table-border-color:#eaecf0;
--el-table-row-hover-bg-color:#f8fafc}
.drama-el-table .el-table__cell,.dashboard-el-table .el-table__cell {
padding:14px 0}
.drama-el-table th.el-table__cell,.dashboard-el-table th.el-table__cell {
font-size:12px;
font-weight:600;
color:#737b85}
.drama-el-table td.el-table__cell,.dashboard-el-table td.el-table__cell {
font-size:13px;
color:#505862}
.drama-el-table .cell,.dashboard-el-table .cell {
line-height:1.5}
.drama-el-table .el-table__inner-wrapper:before,.dashboard-el-table .el-table__inner-wrapper:before {
height:0}
.dark .drama-el-table,.dark .dashboard-el-table {
--el-table-header-bg-color:#2b3038;
--el-table-tr-bg-color:#242830;
--el-table-bg-color:#242830;
--el-table-border-color:#3b414b;
--el-table-row-hover-bg-color:#2e3540;
--el-table-text-color:#c0c6cf;
--el-table-header-text-color:#b1b8c2}
.dark .drama-el-table td.el-table__cell,.dark .dashboard-el-table td.el-table__cell {
color:#c0c6cf}
.table-actions .el-button {
font-size:13px!important;
font-weight:600!important;
padding:7px 11px!important}
.table-actions .el-button--primary {
--el-button-bg-color:#456fae;
--el-button-border-color:#456fae;
--el-button-hover-bg-color:#385f97;
--el-button-hover-border-color:#385f97}
.table-actions .el-button--success {
--el-button-bg-color:#4d866a;
--el-button-border-color:#4d866a;
--el-button-hover-bg-color:#3c7358;
--el-button-hover-border-color:#3c7358}
.table-actions .el-button--danger {
--el-button-bg-color:#bd5b5b;
--el-button-border-color:#bd5b5b;
--el-button-hover-bg-color:#a94b4b;
--el-button-hover-border-color:#a94b4b}
.pagination {
display:flex;
justify-content:flex-end;
align-items:center;
padding:18px 0 4px!important;
overflow-x:auto}
.pagination .el-pagination {
font-size:13px!important;
--el-pagination-font-size:13px;
--el-pagination-button-bg-color:#f1f3f6;
--el-pagination-hover-color:#456fae;
--el-pagination-button-color:#4c5561;
gap:5px}
.pagination .el-pagination__sizes {
margin-right:8px}
.pagination .el-select {
width:112px}
.pagination .el-pagination__jump {
margin-left:8px;
white-space:nowrap}
.dark .pagination .el-pagination {
--el-pagination-button-bg-color:#353b45;
--el-pagination-button-color:#c3cad3}
.drama-el-table td.el-table__cell,.drama-el-table th.el-table__cell {
font-size:14px}
.drama-cell b {
font-size:15px}
.drama-cell small {
font-size:13px}
.table-result-count {
font-size:14px}
.table-actions .el-button {
font-size:14px!important}
.library-toolbar .el-input__inner,.library-toolbar .el-select__placeholder {
font-size:14px}
.pagination .el-pagination {
font-size:14px!important;
--el-pagination-font-size:14px}
.el-message-box__title,.el-message-box__message {
font-size:15px}
.el-message-box__btns .el-button {
font-size:14px}
.episode-toolbar {
margin-bottom:16px}
.episode-el-table {
margin-top:4px}
.episode-table-title b,.episode-table-title small {
display:block}
.episode-table-title b {
font-size:14px;
color:#343a43}
.episode-table-title small {
margin-top:4px;
font-size:13px;
color:#858b95}
.dark .episode-table-title b {
color:#e5e8ed}
.dark .episode-table-title small {
color:#a6aeb9}
.menu-management-panel {
margin-top:20px}
.menu-management-panel .panel-heading {
display:flex;
align-items:center;
justify-content:space-between;
gap:16px}
.menu-management-panel .el-alert {
margin-top:4px}
.menu-management-panel .el-select {
width:100%}
.admin-user-search {
width:min(440px,65vw)}
.muted-text {
font-size:13px;
color:#9298a1}
.role-permission-layout {
display:grid;
grid-template-columns:minmax(520px,1.25fr) minmax(340px,.9fr);
gap:18px;
align-items:start}
.role-table-panel,.permission-tree-panel {
min-width:0}
.role-config-table .el-table__row {
cursor:pointer}
.permission-tree-panel .el-tree {
max-height:68vh;
overflow:auto;
padding:5px 8px}
.permission-tree-panel .el-tree-node__label {
font-size:14px}
.permission-warning {
color:#d66b4f!important}
.admin-users-panel {
overflow:hidden}
.admin-user-toolbar {
display:flex;
align-items:center;
gap:10px;
flex-wrap:wrap;
padding:0 0 16px}
.admin-user-toolbar>.el-input {
width:230px}
.admin-user-toolbar>.el-select {
width:190px}
.admin-users-table {
min-width:1250px}
.menu-editor-layout {
display:grid;
grid-template-columns:minmax(270px,.7fr) minmax(520px,1.5fr);
gap:18px;
align-items:start}
.menu-tree-panel,.menu-form-panel {
min-width:0}
.menu-tree-panel .el-tree {
max-height:70vh;
overflow:auto}
.menu-form-panel .panel-heading {
display:flex;
justify-content:space-between;
align-items:center}
.menu-editor-form {
max-width:680px;
padding:16px 12px 0}
.menu-editor-form .el-input,.menu-editor-form .el-select {
max-width:360px;
width:100%}
.menu-editor-form .el-textarea {
max-width:360px}
.menu-editor-form .el-input-number {
width:180px}
.menu-form-footer {
padding:12px 0 4px 122px;
border-top:1px solid #eceef0}
.dark .menu-form-footer {
border-color:#3b414b}
@media(max-width:1150px) {
.role-permission-layout,.menu-editor-layout {
grid-template-columns:1fr}
.permission-tree-panel .el-tree,.menu-tree-panel .el-tree {
max-height:45vh}
}
+94
View File
@@ -0,0 +1,94 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { House, VideoCamera, Film, Lock, Setting, SwitchButton, ArrowDown, FullScreen, Close, Moon, Sunny, Document, DataAnalysis, Picture, User } from '@element-plus/icons-vue'
import { useAuth } from '../stores/auth'
import { useRbac } from '../stores/rbac'
import { roleData } from '../data/roles'
import { useTheme } from '../stores/theme'
import { customMenus } from '../data/customMenus'
const route = useRoute()
const router = useRouter()
const { currentRole, logout, setRole } = useAuth()
const { can } = useRbac()
const customIconMap = { Document, DataAnalysis, Picture, User, Setting, VideoCamera }
const { isDark, toggleTheme } = useTheme()
const tabs = ref([{ name: 'dashboard', title: '工作台' }])
const isFullscreen = ref(Boolean(document.fullscreenElement))
const activeTab = computed({
get: () => String(route.name || 'dashboard'),
set: name => router.push({ name }),
})
watch(() => route.name, name => {
if (!name || name === 'login') return
const title = String(route.meta.title || '工作台')
if (!tabs.value.some(tab => tab.name === name)) tabs.value.push({ name, title })
})
function syncFullscreen() { isFullscreen.value = Boolean(document.fullscreenElement) }
async function toggleFullscreen() {
try {
if (document.fullscreenElement) await document.exitFullscreen()
else await document.documentElement.requestFullscreen()
} catch {
// Fullscreen may be unavailable in embedded previews or restricted browsers.
}
}
onMounted(() => document.addEventListener('fullscreenchange', syncFullscreen))
onUnmounted(() => document.removeEventListener('fullscreenchange', syncFullscreen))
function closeTab(name) {
if (name === 'dashboard') return
const index = tabs.value.findIndex(tab => tab.name === name)
tabs.value = tabs.value.filter(tab => tab.name !== name)
if (String(route.name) === name) router.push({ name: tabs.value[Math.max(0, index - 1)]?.name || 'dashboard' })
}
function chooseRole(role) {
setRole(role)
router.replace({ name: 'dashboard' })
}
function handleLogout() {
logout()
tabs.value = [{ name: 'dashboard', title: '工作台' }]
router.replace({ name: 'login' })
}
</script>
<template>
<div class="app-shell">
<aside class="sidebar">
<div class="brand"><div class="brand-mark"><span></span><span></span><span></span></div><div><b>reelshort</b><small>STUDIO CONSOLE</small></div></div>
<el-menu :default-active="String(route.name)" :default-openeds="['content','system']" class="side-menu" router>
<el-menu-item index="dashboard" :route="{name:'dashboard'}"><el-icon><House /></el-icon><span>工作台</span></el-menu-item>
<el-sub-menu index="content">
<template #title><el-icon><VideoCamera /></el-icon><span>内容管理</span></template>
<el-menu-item v-if="can('查看短剧列表')" index="dramas" :route="{name:'dramas'}">短剧管理</el-menu-item>
<el-menu-item v-if="can('查看剧集列表')" index="episodes" :route="{name:'episodes'}">剧集管理</el-menu-item>
</el-sub-menu>
<el-sub-menu v-if="can('角色与权限配置') || can('管理员用户列表') || can('菜单管理') || can('角色分类管理')" index="system">
<template #title><el-icon><Setting /></el-icon><span>后台管理</span></template>
<el-menu-item v-if="can('角色与权限配置')" index="access" :route="{name:'access'}">角色权限配置</el-menu-item>
<el-menu-item v-if="can('管理员用户列表')" index="admin-users" :route="{name:'admin-users'}">管理员用户列表</el-menu-item>
<el-menu-item v-if="can('菜单管理')" index="menus" :route="{name:'menus'}">菜单管理</el-menu-item>
<el-menu-item v-if="can('角色分类管理')" index="role-categories" :route="{name:'role-categories'}">角色分类</el-menu-item>
<el-menu-item v-if="can('角色与权限配置')" index="settings" :route="{name:'settings'}">系统设置</el-menu-item>
</el-sub-menu>
<el-sub-menu v-if="customMenus.some(menu => can(menu.permission))" index="custom-menus">
<template #title><el-icon><Document /></el-icon><span>自定义菜单</span></template>
<el-menu-item v-for="menu in customMenus.filter(item => item.type !== '按钮' && can(item.permission))" :key="menu.key" :index="`custom-menu-${menu.key}`" :route="{name:`custom-menu-${menu.key}`}"><el-icon><component :is="customIconMap[menu.icon] || Document" /></el-icon>{{menu.title}}</el-menu-item>
</el-sub-menu>
</el-menu>
</aside>
<main class="main-area">
<header class="topbar"><div class="breadcrumb"><span>ReelShort Studio</span><span class="slash">/</span><b>{{route.meta.title || '工作台'}}</b></div><div class="top-actions"><span class="current-user">林小满 · {{currentRole}}</span><el-dropdown v-if="currentRole==='超级管理员'" trigger="click" @command="chooseRole"><el-button text class="role-switch">切换角色<el-icon><ArrowDown /></el-icon></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-for="role in roleData" :key="role.name" :command="role.name">{{role.name}}</el-dropdown-item></el-dropdown-menu></template></el-dropdown><el-tooltip :content="isDark?'切换浅色主题':'切换深色主题'"><el-button text class="theme-button" :aria-label="isDark?'切换浅色主题':'切换深色主题'" @click="toggleTheme"><el-icon><component :is="isDark ? Sunny : Moon" /></el-icon></el-button></el-tooltip><el-tooltip :content="isFullscreen?'退出全屏':'全屏显示'"><el-button text class="fullscreen-button" :aria-label="isFullscreen?'退出全屏':'全屏显示'" @click="toggleFullscreen"><el-icon><component :is="isFullscreen ? Close : FullScreen" /></el-icon></el-button></el-tooltip><el-button text class="logout-button" @click="handleLogout"><el-icon><SwitchButton /></el-icon>退出登录</el-button></div></header>
<el-tabs v-model="activeTab" type="card" class="page-tabs" @tab-remove="closeTab">
<el-tab-pane v-for="tab in tabs" :key="tab.name" :name="tab.name" :label="tab.title" :closable="tab.name!=='dashboard'" />
</el-tabs>
<section class="page-content"><RouterView /></section>
</main>
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import './style.css'
import './theme.css'
import './typography.css'
import './navigation.css'
import './dark-theme.css'
import './drama-management.css'
import './stores/theme.js'
import App from './App.vue'
import router from './router'
createApp(App).use(ElementPlus, { locale: zhCn }).use(router).mount('#app')
+138
View File
@@ -0,0 +1,138 @@
.sidebar {
padding:22px 12px;
background:#fff}
.brand {
padding:0 10px 22px;
margin-bottom:8px;
border-bottom:1px solid #eceef0}
.side-menu {
border-right:0!important}
.side-menu>.el-menu-item,.side-menu>.el-sub-menu>.el-sub-menu__title {
height:46px;
line-height:46px;
font-size:14px;
border-radius:4px;
margin:3px 0}
.side-menu .el-sub-menu .el-menu-item {
min-width:0;
height:42px;
line-height:42px;
font-size:13px;
padding-left:48px!important}
.side-menu .el-sub-menu .el-menu-item {
padding-left:48px!important}
.main-area {
margin-left:250px}
.topbar {
height:62px;
padding:0 28px}
.top-actions {
gap:12px}
.current-user {
font-size:13px;
color:#535b66}
.role-switch,.logout-button {
font-size:13px!important;
color:#56616e!important}
.logout-button {
border-left:1px solid #e7e9ec;
border-radius:0!important;
padding-left:16px!important}
.logout-button .el-icon {
margin-right:5px}
.page-tabs {
height:43px;
background:#fff;
border-bottom:1px solid #e7e9ec;
padding:0 24px}
.page-tabs .el-tabs__header {
margin:0;
border-bottom:0;
height:43px}
.page-tabs .el-tabs__nav {
border:0!important;
height:43px}
.page-tabs .el-tabs__item {
height:43px;
line-height:43px;
padding:0 17px!important;
font-size:13px;
color:#68717c;
border:0!important;
background:transparent}
.page-tabs .el-tabs__item.is-active {
color:#365f96;
border-bottom:2px solid #456fae!important;
background:#f8f9fb}
.page-tabs .el-tabs__item .is-icon-close {
font-size:13px}
.page-tabs .el-tabs__content {
display:none}
.page-content {
padding-top:26px}
.brand-mark {
width:30px;
height:30px}
.sidebar>.side-menu {
margin-top:4px}
.el-sub-menu__title:hover,.side-menu .el-menu-item:hover {
background:#f4f6f8}
.side-menu .el-menu-item.is-active {
background:#edf2f8;
color:#355f97}
.page-heading {
margin-bottom:20px}
@media(max-width:1200px) {
.topbar {
padding:0 18px}
.page-tabs {
padding:0 14px}
.page-content {
padding:24px 24px 40px}
}
.side-menu>.el-menu-item,.side-menu>.el-sub-menu>.el-sub-menu__title {
box-sizing:border-box;
padding-left:20px!important;
display:flex;
align-items:center}
.side-menu>.el-menu-item>.el-icon,.side-menu>.el-sub-menu>.el-sub-menu__title>.el-icon {
width:18px;
min-width:18px;
margin-right:10px}
.side-menu .el-sub-menu .el-sub-menu__title .el-icon {
margin-right:10px}
.topbar {
position:sticky;
top:0;
z-index:20}
.page-tabs {
position:sticky;
top:62px;
z-index:19}
.fullscreen-button {
font-size:18px!important;
color:#56616e!important;
padding:7px!important}
.fullscreen-button:hover {
color:#355f97!important;
background:#f1f4f7!important}
.status-badge {
border:0!important;
font-weight:500}
.status-badge.is-online {
background:#eaf5ee!important;
color:#32724b!important}
.status-badge.is-reviewing {
background:#fff3df!important;
color:#946000!important}
.status-badge.is-draft {
background:#eef0f2!important;
color:#616a74!important}
.status-badge.is-pending {
background:#eaf2fb!important;
color:#35689d!important}
.status-badge.is-offline {
background:#fbeaea!important;
color:#a74444!important}
.side-menu>.el-menu-item>.el-icon,.side-menu>.el-sub-menu>.el-sub-menu__title>.el-icon {
font-size:16px}
+15
View File
@@ -0,0 +1,15 @@
export const permissionGroups = [
{ name: '短剧管理', items: ['查看短剧列表', '创建短剧', '编辑短剧信息', '删除短剧'] },
{ name: '剧集管理', items: ['查看剧集列表', '上传剧集视频', '管理剧集排序'] },
{ name: '封面管理', items: ['上传封面素材'] },
{ name: '审核与系统', items: ['审核并发布', '查看数据报表'] },
{ name: '后台管理', items: ['角色与权限配置', '管理员用户列表', '菜单管理', '角色分类管理'] },
]
export const allPermissions = permissionGroups.flatMap(group => group.items)
export const defaultRolePermissions = {
超级管理员: [...allPermissions],
内容运营: ['查看短剧列表', '创建短剧', '编辑短剧信息', '删除短剧', '查看剧集列表', '上传剧集视频', '管理剧集排序', '上传封面素材'],
审核专员: ['查看短剧列表', '查看剧集列表', '审核并发布', '查看数据报表'],
数据分析师: ['查看短剧列表', '查看数据报表'],
}
+47
View File
@@ -0,0 +1,47 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuth } from '../stores/auth'
import { useRbac } from '../stores/rbac'
import { customMenus } from '../data/customMenus'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: () => import('@/views/common/LoginView.vue'), meta: { public: true } },
{ path: '/', name: 'admin', component: () => import('@/layouts/AdminLayout.vue'), children: [
{ path: '', name: 'dashboard', component: () => import('@/views/common/DashboardView.vue'), meta: { title: '工作台', permission: '查看短剧列表' } },
{ path: 'dramas', name: 'dramas', component: () => import('@/views/contentManagement/DramaManagementView.vue'), meta: { title: '短剧管理', permission: '查看短剧列表' } },
{ path: 'episodes', name: 'episodes', component: () => import('@/views/contentManagement/EpisodeManagementView.vue'), meta: { title: '剧集管理', permission: '查看剧集列表' } },
{ path: 'access', name: 'access', component: () => import('@/views/permission/RoleManagementView.vue'), meta: { title: '角色权限配置', permission: '角色与权限配置' } },
{ path: 'admin-users', name: 'admin-users', component: () => import('@/views/userManagement/AdminUsersView.vue'), meta: { title: '管理员用户列表', permission: '管理员用户列表' } },
{ path: 'menus', name: 'menus', component: () => import('@/views/permission/MenuManagementView.vue'), meta: { title: '菜单管理', permission: '菜单管理' } },
{ path: 'role-categories', name: 'role-categories', component: () => import('@/views/permission/RoleCategoriesView.vue'), meta: { title: '角色分类', permission: '角色分类管理' } },
{ path: 'settings', name: 'settings', component: () => import('@/views/systemManagement/SettingsView.vue'), meta: { title: '系统设置', permission: '角色与权限配置' } },
] },
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
export function registerCustomMenu(menu) {
const name = `custom-menu-${menu.key}`
if (router.hasRoute(name)) return
if (menu.type === '按钮') return
const path = String(menu.route || `custom/${menu.key}`).replace(/^\/+/, '')
router.addRoute('admin', {
path,
name,
component: () => import('@/views/permission/CustomMenuView.vue'),
meta: { title: menu.title, permission: menu.permission, menuKey: menu.key },
})
}
customMenus.forEach(registerCustomMenu)
router.beforeEach(to => {
const { isAuthenticated } = useAuth()
const { can } = useRbac()
if (!to.meta.public && !isAuthenticated.value) return { name: 'login' }
if (to.name === 'login' && isAuthenticated.value) return { name: 'dashboard' }
if (to.meta.permission && !can(to.meta.permission)) return { name: 'dashboard' }
})
export default router
+22
View File
@@ -0,0 +1,22 @@
import { computed, ref } from 'vue'
const authenticated = ref(localStorage.getItem('reelshort-auth') === '1')
const currentRole = ref('超级管理员')
export function useAuth() {
const isAuthenticated = computed(() => authenticated.value)
function login(username, password) {
if (username !== 'admin' || password !== 'admin123') return false
localStorage.setItem('reelshort-auth', '1')
currentRole.value = '超级管理员'
authenticated.value = true
return true
}
function logout() {
localStorage.removeItem('reelshort-auth')
currentRole.value = '超级管理员'
authenticated.value = false
}
function setRole(role) { currentRole.value = role }
return { isAuthenticated, currentRole, login, logout, setRole }
}
+25
View File
@@ -0,0 +1,25 @@
import { reactive, computed } from 'vue'
import { defaultRolePermissions } from '../permissions/permissions'
import { useAuth } from './auth'
import { customMenus } from '../data/customMenus'
const rolePermissions = reactive(Object.fromEntries(Object.entries(defaultRolePermissions).map(([role, list]) => [role, [...list]])))
rolePermissions['超级管理员'].push(...customMenus.map(menu => menu.permission))
export function useRbac() {
const { currentRole } = useAuth()
const permissions = computed(() => rolePermissions[currentRole.value] || [])
const can = permission => permissions.value.includes(permission)
function updateRolePermission(role, permission, enabled) {
const current = rolePermissions[role] || (rolePermissions[role] = [])
rolePermissions[role] = enabled ? [...new Set([...current, permission])] : current.filter(item => item !== permission)
}
function createRole(name) { if (!rolePermissions[name]) rolePermissions[name] = [] }
function createPermission(permission) {
rolePermissions['超级管理员'] = [...new Set([...(rolePermissions['超级管理员'] || []), permission])]
}
function removePermission(permission) {
for (const role of Object.keys(rolePermissions)) rolePermissions[role] = rolePermissions[role].filter(item => item !== permission)
}
return { permissions, can, rolePermissions, updateRolePermission, createRole, createPermission, removePermission }
}
+25
View File
@@ -0,0 +1,25 @@
import { ref } from 'vue'
const storageKey = 'reelshort-theme'
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
const storedTheme = localStorage.getItem(storageKey)
const isDark = ref(storedTheme ? storedTheme === 'dark' : systemTheme.matches)
function applyTheme(value) {
isDark.value = value
document.documentElement.classList.toggle('dark', value)
}
applyTheme(isDark.value)
systemTheme.addEventListener?.('change', event => {
if (!localStorage.getItem(storageKey)) applyTheme(event.matches)
})
export function useTheme() {
function toggleTheme() {
const next = !isDark.value
localStorage.setItem(storageKey, next ? 'dark' : 'light')
applyTheme(next)
}
return { isDark, toggleTheme }
}
+1062
View File
File diff suppressed because it is too large. Load diff
+231
View File
@@ -0,0 +1,231 @@
:root {
font-family:Arial,'Noto Sans SC',sans-serif;
color:#30343b;
background:#f5f6f8;
--purple:#4b76b8;
--muted:#858b95;
--line:#e8eaed}
body {
background:#f5f6f8}
.sidebar {
background:#fff;
border-right-color:#e7e9ec;
padding-top:22px}
.brand {
padding-bottom:23px}
.brand-mark {
background:#456fae;
border-radius:7px;
transform:none}
.brand b {
font:700 16px Arial,sans-serif;
letter-spacing:-.4px;
color:#29313b}
.brand small {
letter-spacing:.9px}
.workspace {
border-radius:5px;
border-color:#e5e7eb;
margin-bottom:22px}
.workspace-avatar,.profile-avatar,.top-avatar {
background:#edf1f6;
color:#4b6689;
border-radius:5px}
.nav-label {
color:#9298a1;
font-size:10px}
.side-menu .el-menu-item {
border-radius:5px;
color:#616873}
.side-menu .el-menu-item:hover {
background:#f5f7f9;
color:#355f97}
.side-menu .el-menu-item.is-active {
background:#edf2f8;
color:#355f97}
.menu-count {
background:#eef1f4;
color:#747d88}
.help-card {
background:#f7f8fa;
border-color:#eceef0;
border-radius:5px}
.help-icon {
background:#e9edf2;
color:#617b9c;
border-radius:5px}
.help-card a {
color:#416a9f}
.topbar {
background:#fff}
.environment {
border-radius:4px}
.page-content {
padding-top:30px}
.eyebrow {
letter-spacing:.7px;
color:#9298a1}
.page-heading h1 {
font:600 23px Arial,'Noto Sans SC',sans-serif;
letter-spacing:0;
color:#2f343c}
.page-heading p {
color:#818791}
.primary-button {
background:#456fae!important;
border-color:#456fae!important;
border-radius:5px!important;
box-shadow:none!important}
.primary-button:hover {
background:#385f97!important}
.stats-grid {
gap:12px}
.stat-card,.panel {
border-color:#e6e8eb;
border-radius:6px;
box-shadow:none}
.stat-card {
min-height:116px;
padding:14px 16px}
.stat-top {
color:#727983}
.stat-icon {
border-radius:5px;
background:#f0f2f4!important;
color:#697584!important}
.stat-value {
font-family:Arial,sans-serif;
color:#303640}
.stat-foot .positive {
color:#4d866a;
background:#eff5f1}
.sparkline {
opacity:.42}
.positive {
color:#4d866a;
background:#eff5f1}
.review-count,.muted-count {
background:#eef1f4;
color:#61738a}
.chart-svg path:first-of-type {
fill:#dce5f0}
.chart-svg path:last-of-type {
stroke:#587eae}
.view-all,.row-action {
color:#456fae!important}
.review-art {
background:#f1f3f5!important;
border-radius:5px;
font-size:0}
.review-art:after {
content:'—';
font-size:14px;
color:#818a95}
.review-row .el-tag,.drama-table .el-tag,.episode-row .el-tag {
border-radius:4px;
background:#f0f2f4!important;
color:#606b78!important}
.drama-cover {
border-radius:4px;
box-shadow:none;
font-size:14px;
background:#eceff2!important}
.drama-cover.rose,.drama-cover.purple,.drama-cover.peach,.drama-cover.blue,.drama-cover.gold {
background:#eceff2!important}
.drama-table th {
background:#f7f8fa;
color:#7d848e}
.drama-table td {
color:#656c76}
.drama-cell b {
color:#343a43}
.library-panel {
padding-left:18px;
padding-right:18px}
.library-toolbar .el-tabs__item,.cover-toolbar .el-tabs__item {
font-size:11px}
.library-toolbar .el-tabs__item.is-active,.cover-toolbar .el-tabs__item.is-active {
color:#456fae}
.library-toolbar .el-tabs__active-bar,.cover-toolbar .el-tabs__active-bar {
background:#456fae}
.toolbar-filters .el-input__wrapper,.toolbar-filters .el-select__wrapper,.drama-select .el-input__wrapper {
border-radius:5px}
.episode-panel {
padding-left:20px;
padding-right:20px}
.episode-row {
border-color:#e7e9ec;
border-radius:5px}
.episode-thumb {
border-radius:4px;
background:#e9ecef;
color:#7d8792}
.episode-thumb .el-icon {
color:#606a75}
.episode-actions .el-button {
color:#767e88}
.cover-grid {
grid-template-columns:repeat(auto-fill,minmax(145px,1fr))}
.cover-card,.add-cover {
border-color:#e6e8eb;
border-radius:5px}
.poster-art {
background:#e9ecef!important;
filter:grayscale(.72)}
.poster-art.rose,.poster-art.purple,.poster-art.peach,.poster-art.blue,.poster-art.gold {
background:#e9ecef!important}
.poster-glow,.poster-stamp {
display:none}
.poster-emoji {
font-size:48px;
filter:grayscale(1);
opacity:.55}
.poster-copy {
text-shadow:none;
color:#303640}
.poster-copy small,.poster-copy span {
color:#606873;
opacity:1}
.cover-card-info b {
color:#383e46}
.add-cover {
min-height:276px;
background:#fafbfc;
color:#656d77}
.add-cover>span {
border-radius:5px;
background:#edf0f3;
color:#68798d}
.role-card {
border-radius:5px;
border-color:#e7e9ec}
.role-active {
border-color:#aebed1;
background:#f7f9fb}
.role-icon {
border-radius:5px;
background:#eef1f4!important;
color:#65758a!important}
.role-badge {
border-radius:4px;
background:#eef1f4;
color:#65758a}
.permission-group h3 .el-icon {
color:#617b9b}
.permission-group .el-checkbox__input.is-checked .el-checkbox__inner {
background:#456fae;
border-color:#456fae}
.permission-note {
background:#f6f7f8;
border-radius:4px}
.el-dialog {
border-radius:6px!important}
.el-dialog__header,.el-dialog__footer {
border-color:#e8eaed}
.upload-area .el-upload-dragger {
border-radius:5px;
background:#fafbfc}
.upload-icon,.upload-area .el-upload__text em {
color:#456fae}
.el-tabs__item,.el-input__inner,.el-select__placeholder {
font-family:Arial,'Noto Sans SC',sans-serif}
+9
View File
@@ -0,0 +1,9 @@
.stat-top>.stat-icon {
width:30px;
height:30px;
flex:0 0 30px;
display:grid;
place-items:center;
border-radius:5px}
.stat-top>.stat-icon .el-icon {
font-size:16px}
+11
View File
@@ -0,0 +1,11 @@
const statusClasses = {
已上线: 'is-online',
审核中: 'is-reviewing',
草稿: 'is-draft',
待发布: 'is-pending',
已下线: 'is-offline',
}
export function statusClass(status) {
return statusClasses[status] || 'is-draft'
}
+23
View File
@@ -0,0 +1,23 @@
<script setup>
import { useRouter } from 'vue-router'
import { VideoCamera, User, Clock, CaretRight, Plus } from '@element-plus/icons-vue'
import { dramas } from '@/data/dramas'
import { useRbac } from '@/stores/rbac'
import { statusClass } from '@/utils/status'
const router = useRouter()
const { can } = useRbac()
</script>
<template>
<div class="page-heading"><div><div class="eyebrow">内容概览</div><h1>工作台</h1><p>查看短剧内容与近期处理事项。</p></div><el-button v-if="can('创建短剧')" type="primary" class="primary-button" @click="router.push({name:'dramas'})"><el-icon><Plus /></el-icon>上传新短剧</el-button></div>
<div class="stats-grid">
<div class="stat-card"><div class="stat-top"><span>短剧总数</span><span class="stat-icon"><el-icon><VideoCamera /></el-icon></span></div><div class="stat-value">{{dramas.length}} <small>部</small></div></div>
<div class="stat-card"><div class="stat-top"><span>累计播放量</span><span class="stat-icon"><el-icon><CaretRight /></el-icon></span></div><div class="stat-value">8.64 <small>千万</small></div></div>
<div class="stat-card"><div class="stat-top"><span>待审核内容</span><span class="stat-icon"><el-icon><Clock /></el-icon></span></div><div class="stat-value">06 <small>项</small></div></div>
<div class="stat-card"><div class="stat-top"><span>本月新增用户</span><span class="stat-icon"><el-icon><User /></el-icon></span></div><div class="stat-value">12,486 <small>人</small></div></div>
</div>
<section class="panel recent-panel"><div class="panel-heading"><div><h2>最近更新</h2><p>短剧内容与发布状态</p></div><el-button text class="view-all" @click="router.push({name:'dramas'})">短剧管理</el-button></div>
<el-table :data="dramas.slice(0,5)" row-key="id" class="dashboard-el-table"><el-table-column prop="title" label="短剧名称" min-width="280"/><el-table-column prop="genre" label="类型" width="150"/><el-table-column label="集数" width="120"><template #default="{row}">{{row.episodes}} 集</template></el-table-column><el-table-column prop="views" label="播放量" width="140"/><el-table-column label="状态" width="130"><template #default="{row}"><el-tag :class="['status-badge',statusClass(row.status)]" effect="light">{{row.status}}</el-tag></template></el-table-column><el-table-column prop="updatedAt" label="更新时间" width="160"/></el-table>
</section>
</template>
+73
View File
@@ -0,0 +1,73 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuth } from '@/stores/auth'
const router = useRouter()
const { login } = useAuth()
const form = ref({ username: 'admin', password: '' })
const error = ref('')
function submit() {
if (!login(form.value.username, form.value.password)) {
error.value = '账号或密码不正确。'
return
}
error.value = ''
ElMessage.success('登录成功')
router.replace({ name: 'dashboard' })
}
</script>
<template>
<main class="login-screen">
<section class="login-cover">
<div class="cover-decoration decoration-one"></div><div class="cover-decoration decoration-two"></div>
<div class="cover-logo"><div class="brand-mark"><span></span><span></span><span></span></div><span>reelshort</span><small>STUDIO CONSOLE</small></div>
<div class="cover-copy"><span>REELSHORT STUDIO</span><h2>每一个故事<br>都值得被看见</h2><p>短剧内容创作与运营管理平台</p></div>
<div class="cover-bottom">SHORT DRAMA · CONTENT STUDIO</div>
</section>
<section class="login-panel">
<div class="login-content">
<div class="login-heading"><h1>登录管理后台</h1><p>使用工作空间账号继续</p></div>
<el-form label-position="top" @submit.prevent="submit">
<el-form-item label="账号"><el-input v-model="form.username" placeholder="请输入账号" autocomplete="username" /></el-form-item>
<el-form-item label="密码"><el-input v-model="form.password" type="password" placeholder="请输入密码" autocomplete="current-password" show-password @keyup.enter="submit" /></el-form-item>
<div v-if="error" class="login-error">{{error}}</div>
<el-button type="primary" class="login-submit primary-button" @click="submit">登录</el-button>
</el-form>
<div class="demo-credentials">演示账号:admin <i></i> 密码:admin123</div>
</div>
<footer class="login-footer">© 2026 ReelShort Studio</footer>
</section>
</main>
</template>
<style scoped>
.login-screen { min-height:100vh; display:grid; grid-template-columns:1.05fr .95fr; overflow:hidden; background:#f8f9fb; }
.login-cover { position:relative; min-height:100vh; overflow:hidden; padding:42px 7vw; color:#fff; background:radial-gradient(ellipse at 72% 42%,rgba(196,167,150,.42),transparent 31%),radial-gradient(ellipse at 77% 44%,rgba(224,191,166,.62),transparent 16%),linear-gradient(160deg,#263d59 0%,#344d6c 44%,#1c2d44 100%); }
.login-cover::after { content:''; position:absolute; inset:0; background:linear-gradient(180deg,rgba(22,37,58,.05) 30%,rgba(16,27,42,.72)); }
.cover-logo,.cover-copy,.cover-bottom { position:relative; z-index:1; }
.cover-logo { display:flex; align-items:center; gap:8px; width:max-content; font:800 16px Manrope,'Noto Sans SC',sans-serif; letter-spacing:-.5px; }
.cover-logo small { margin-left:6px; padding-left:14px; border-left:1px solid rgba(255,255,255,.32); color:rgba(255,255,255,.66); font:600 9px Manrope,sans-serif; letter-spacing:1.7px; }
.brand-mark { width:28px; height:28px; border-radius:8px; background:rgba(255,255,255,.2); display:flex; align-items:center; justify-content:center; gap:2px; transform:rotate(-8deg); }
.brand-mark span { width:3px; border-radius:5px; background:#fff; transform:skew(-15deg); }
.brand-mark span:nth-child(1) { height:11px; opacity:.75; }.brand-mark span:nth-child(2) { height:17px; }.brand-mark span:nth-child(3) { height:13px; opacity:.85; }
.cover-copy { position:absolute; left:7vw; bottom:15%; }
.cover-copy span { color:#d9b49d; font:600 10px Manrope,sans-serif; letter-spacing:2.4px; }
.cover-copy h2 { margin:15px 0 12px; color:#fff; font-size:clamp(34px,4.1vw,56px); font-weight:700; line-height:1.35; letter-spacing:2px; }
.cover-copy p { margin:0; color:rgba(255,255,255,.7); font-size:15px; letter-spacing:1px; }
.cover-bottom { position:absolute; left:7vw; bottom:38px; color:rgba(255,255,255,.4); font:600 9px Manrope,sans-serif; letter-spacing:2px; }
.cover-decoration { position:absolute; pointer-events:none; width:min(39vw,520px); aspect-ratio:.72; right:7%; top:14%; border:1px solid rgba(255,255,255,.13); border-radius:48% 48% 18px 18px; transform:rotate(7deg); background:linear-gradient(150deg,rgba(231,190,163,.14),rgba(25,42,64,.2) 62%); }
.decoration-two { width:min(32vw,430px); right:12%; top:18%; transform:rotate(-7deg); opacity:.8; }
.login-panel { position:relative; min-height:100vh; display:flex; flex-direction:column; justify-content:center; padding:64px clamp(42px,7vw,110px); background:#f8f9fb; }
.login-content { width:min(100%,390px); align-self:center; }
.login-heading { margin:0 0 27px; }.login-heading h1 { margin:0; color:#252d38; font-size:24px; font-weight:700; letter-spacing:-.5px; }.login-heading p { margin:8px 0 0; color:#7f8998; font-size:13px; }
.login-panel :deep(.el-form-item) { margin-bottom:19px; }.login-panel :deep(.el-form-item__label) { padding-bottom:8px; color:#424c5a; font-size:14px; font-weight:600; }
.login-panel :deep(.el-input__wrapper) { min-height:44px; border-radius:7px; background:#fff; box-shadow:0 0 0 1px #dce2e9 inset; }.login-panel :deep(.el-input__wrapper.is-focus) { box-shadow:0 0 0 1px #4773b3 inset; }.login-panel :deep(.el-input__inner) { font-size:15px; }
.login-error { margin:-7px 0 14px; color:#d94b4b; font-size:13px; }.login-submit { width:100%; height:46px; margin-top:3px; border-radius:7px; background:#4773b3; border-color:#4773b3; font-size:15px; font-weight:600; }.login-submit:hover { background:#3d669f; border-color:#3d669f; }
.demo-credentials { display:flex; align-items:center; justify-content:center; gap:8px; margin-top:18px; color:#8791a0; font-size:12px; }.demo-credentials i { width:3px; height:3px; border-radius:50%; background:#b6bec8; }
.login-footer { position:absolute; left:clamp(42px,7vw,110px); bottom:32px; color:#a5acb7; font-size:11px; }
@media(max-width:700px) { .login-screen { grid-template-columns:1fr; }.login-cover { min-height:250px; padding:24px 26px; }.cover-copy { left:26px; bottom:38px; }.cover-copy h2 { margin:9px 0 5px; font-size:30px; }.cover-copy p { font-size:12px; }.cover-bottom { display:none; }.cover-decoration { width:160px; right:10%; top:8%; }.decoration-two { width:135px; right:14%; top:12%; }.login-panel { min-height:auto; padding:42px 28px 72px; }.login-footer { left:28px; bottom:25px; } }
@media(max-width:480px) { .login-cover { min-height:220px; padding:20px 22px; }.cover-copy { left:22px; bottom:30px; }.cover-copy h2 { font-size:26px; }.cover-logo small { display:none; }.login-panel { padding:34px 22px 68px; }.login-footer { left:22px; } }
</style>
+18
View File
@@ -0,0 +1,18 @@
<script setup>
defineProps({
eyebrow: { type: String, default: '' },
title: { type: String, required: true },
description: { type: String, default: '' },
})
</script>
<template>
<div class="page-heading">
<div>
<div v-if="eyebrow" class="eyebrow">{{ eyebrow }}</div>
<h1>{{ title }}</h1>
<p v-if="description">{{ description }}</p>
</div>
<slot />
</div>
</template>
@@ -0,0 +1,80 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { dramas } from '@/data/dramas'
import { useRbac } from '@/stores/rbac'
import { statusClass } from '@/utils/status'
const { can } = useRbac()
const search = ref('')
const status = ref('全部状态')
const currentPage = ref(1)
const pageSize = ref(10)
const dialogVisible = ref(false)
const editingId = ref(null)
const form = reactive({ title: '', genre: '', description: '', episodes: 1, cover: '' })
const filtered = computed(() => dramas.filter(item => item.title.includes(search.value) && (status.value === '全部状态' || item.status === status.value)))
const paginated = computed(() => filtered.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value))
watch([search, status], () => { currentPage.value = 1 })
function openCreate() {
editingId.value = null
Object.assign(form, { title: '', genre: '', description: '', episodes: 1, cover: '' })
dialogVisible.value = true
}
function openEdit(item) {
editingId.value = item.id
Object.assign(form, { title: item.title, genre: item.genre, description: item.description, episodes: item.episodes, cover: item.cover || '' })
dialogVisible.value = true
}
function saveDrama() {
if (!form.title || !form.genre) return ElMessage.warning('请填写短剧名称和类型')
const updatedAt = new Date().toISOString().slice(0, 10)
if (editingId.value) {
const drama = dramas.find(item => item.id === editingId.value)
if (drama) Object.assign(drama, { ...form, updatedAt })
} else {
dramas.unshift({ id: Date.now(), ...form, status: '草稿', views: '—', updatedAt })
}
Object.assign(form, { title: '', genre: '', description: '', episodes: 1, cover: '' })
dialogVisible.value = false
ElMessage.success(editingId.value ? '短剧信息已更新' : '短剧草稿已保存')
editingId.value = null
}
function uploadCover(file) { if (can('上传封面素材')) form.cover = URL.createObjectURL(file.raw) }
async function confirmDelete(item) {
try {
await ElMessageBox.confirm(`确定删除短剧「${item.title}」吗?删除后无法恢复。`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger',
distinguishCancelAndClose: true,
})
const index = dramas.findIndex(drama => drama.id === item.id)
if (index !== -1) dramas.splice(index, 1)
if (currentPage.value > Math.ceil(filtered.value.length / pageSize.value)) currentPage.value = Math.max(1, currentPage.value - 1)
ElMessage.success('短剧已删除')
} catch {
// 用户取消或关闭确认框时不执行删除。
}
}
</script>
<template>
<div class="page-heading"><div><div class="eyebrow">内容管理</div><h1>短剧管理</h1><p>管理短剧信息、封面、剧集和发布状态。</p></div><el-button v-if="can('创建短剧')" type="primary" class="primary-button" @click="openCreate"><el-icon><Plus /></el-icon>新建短剧</el-button></div>
<section class="panel library-panel">
<div class="library-toolbar"><div class="drama-filters"><el-input v-model="search" placeholder="搜索短剧名称" :prefix-icon="Search" clearable /><el-select v-model="status"><el-option v-for="item in ['全部状态','已上线','审核中','草稿']" :key="item" :label="item" :value="item" /></el-select></div><span class="table-result-count">共 {{filtered.length}} 部短剧</span></div>
<el-table :data="paginated" row-key="id" empty-text="没有符合条件的短剧" class="drama-el-table">
<el-table-column label="短剧名称" min-width="340"><template #default="{row}"><div class="drama-cell"><img v-if="row.cover" :src="row.cover" class="drama-cover-image"/><div v-else class="drama-cover">暂无封面</div><div><b>{{row.title}}</b><small>{{row.description}}</small></div></div></template></el-table-column>
<el-table-column prop="genre" label="类型" width="140" />
<el-table-column label="集数" width="110"><template #default="{row}">{{row.episodes}} 集</template></el-table-column>
<el-table-column prop="views" label="播放量" width="120" />
<el-table-column label="状态" width="120"><template #default="{row}"><el-tag :class="['status-badge',statusClass(row.status)]" effect="light">{{row.status}}</el-tag></template></el-table-column>
<el-table-column prop="updatedAt" label="更新时间" width="150" />
<el-table-column label="操作" width="240" fixed="right"><template #default="{row}"><div class="table-actions"><el-button v-if="can('编辑短剧信息')" type="primary" size="small" @click="openEdit(row)">编辑</el-button><el-button v-if="can('查看剧集列表')" type="success" size="small" @click="$router.push({name:'episodes',query:{drama:row.id}})">剧集</el-button><el-button v-if="can('删除短剧')" type="danger" size="small" @click="confirmDelete(row)">删除</el-button></div></template></el-table-column>
</el-table>
<div class="pagination"><el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[5,10,20,50]" :total="filtered.length" :pager-count="7" layout="total, sizes, prev, pager, next, jumper" background /></div>
</section>
<el-dialog v-model="dialogVisible" :title="editingId?'编辑短剧':'新建短剧'" width="600px"><el-form label-position="top"><div class="form-row"><el-form-item label="短剧名称" required><el-input v-model="form.title" placeholder="请输入短剧名称" /></el-form-item><el-form-item label="类型" required><el-select v-model="form.genre" placeholder="请选择"><el-option v-for="genre in ['都市情感','甜宠爱情','重生逆袭','悬疑复仇','古装言情']" :key="genre" :label="genre" :value="genre" /></el-select></el-form-item></div><el-form-item label="简介"><el-input v-model="form.description" type="textarea" :rows="3" maxlength="200" show-word-limit /></el-form-item><div class="form-row"><el-form-item label="预计集数"><el-input-number v-model="form.episodes" :min="1" :max="200" /></el-form-item><el-form-item v-if="can('上传封面素材')" label="短剧封面"><el-upload action="#" :auto-upload="false" :show-file-list="false" accept="image/*" :on-change="uploadCover"><el-button size="small">{{form.cover?'更换封面':'选择封面'}}</el-button></el-upload></el-form-item></div><img v-if="form.cover" :src="form.cover" class="form-cover-preview" /></el-form><template #footer><el-button @click="dialogVisible=false">取消</el-button><el-button type="primary" @click="saveDrama">{{editingId?'保存修改':'保存草稿'}}</el-button></template></el-dialog>
</template>
@@ -0,0 +1,44 @@
<script setup>
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { dramas } from '@/data/dramas'
import { episodeData } from '@/data/episodes'
import { useRbac } from '@/stores/rbac'
import { statusClass } from '@/utils/status'
const route = useRoute()
const { can } = useRbac()
const selectedDramaId = ref(Number(route.query.drama) || dramas[0]?.id)
const dialogVisible = ref(false)
const form = ref({ title: '', fileName: '' })
const selectedDrama = computed(() => dramas.find(item => item.id === selectedDramaId.value) || dramas[0])
const episodes = computed(() => episodeData.filter(item => item.dramaId === selectedDramaId.value).sort((a,b) => a.number-b.number))
function pickVideo(file) { form.value.fileName = file.name }
function saveEpisode() {
if (!form.value.title || !form.value.fileName) return ElMessage.warning('请填写标题并选择视频文件')
episodeData.push({ id: Date.now(), dramaId: selectedDramaId.value, number: episodes.value.length + 1, title: form.value.title, duration: '待处理', status: '待发布', views: '—', fileName: form.value.fileName })
selectedDrama.value.episodes += 1
form.value = { title: '', fileName: '' }
dialogVisible.value = false
ElMessage.success('剧集已加入本地上传队列')
}
</script>
<template>
<div class="page-heading"><div><div class="eyebrow">内容管理</div><h1>剧集管理</h1><p>为短剧添加视频剧集并维护播放顺序。</p></div><el-button v-if="can('上传剧集视频')" type="primary" class="primary-button" @click="dialogVisible=true"><el-icon><Plus /></el-icon>上传剧集</el-button></div>
<section class="panel episode-panel">
<div class="episode-context"><div class="episode-context-copy"><span>选择短剧</span><b>{{selectedDrama?.title}}</b></div><el-select v-model="selectedDramaId" class="drama-select"><el-option v-for="item in dramas" :key="item.id" :label="item.title" :value="item.id" /></el-select></div>
<div class="episode-toolbar"><div><h2>剧集列表({{episodes.length}})</h2><p>按剧集编号顺序播放</p></div></div>
<el-table :data="episodes" row-key="id" empty-text="该短剧暂无剧集" class="drama-el-table episode-el-table">
<el-table-column label="集数" width="100"><template #default="{row}">{{String(row.number).padStart(2,'0')}}</template></el-table-column>
<el-table-column label="剧集名称" min-width="280"><template #default="{row}"><div class="episode-table-title"><b>{{row.title}}</b><small v-if="row.fileName">{{row.fileName}}</small></div></template></el-table-column>
<el-table-column prop="duration" label="时长" width="140" />
<el-table-column prop="views" label="播放量" width="180"><template #default="{row}">{{row.views}}<span v-if="row.views !== '—'"> 次</span></template></el-table-column>
<el-table-column label="状态" width="150"><template #default="{row}"><el-tag :class="['status-badge',statusClass(row.status)]" effect="light">{{row.status}}</el-tag></template></el-table-column>
<el-table-column label="操作" width="150" fixed="right"><template #default><el-button v-if="can('管理剧集排序')" type="primary" link>调整顺序</el-button></template></el-table-column>
</el-table>
</section>
<el-dialog v-model="dialogVisible" title="上传剧集" width="560px"><el-form label-position="top"><el-form-item label="剧集标题" required><el-input v-model="form.title" placeholder="例如:命运的相遇" /></el-form-item><el-form-item label="视频文件" required><el-upload drag action="#" :auto-upload="false" accept="video/mp4,video/quicktime" :on-change="pickVideo"><div class="upload-copy">拖放视频文件到此处,或点击选择<br/><small>支持 MP4、MOV</small></div></el-upload></el-form-item><p v-if="form.fileName" class="selected-file">已选择:{{form.fileName}}</p></el-form><template #footer><el-button @click="dialogVisible=false">取消</el-button><el-button type="primary" @click="saveEpisode">加入上传队列</el-button></template></el-dialog>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { customMenus } from '@/data/customMenus'
const route = useRoute()
const menu = computed(() => customMenus.find(item => item.key === route.meta.menuKey))
</script>
<template>
<div class="page-heading"><div><div class="eyebrow">自定义菜单</div><h1>{{menu?.title || '菜单页面'}}</h1><p>这是「{{menu?.title}}」菜单的前端演示页面。</p></div></div>
<section class="panel"><el-empty description="菜单已创建,可以继续配置页面内容。" /></section>
</template>
@@ -0,0 +1,78 @@
<script setup>
import { Plus } from '@element-plus/icons-vue'
import { menuIconNames } from '@/data/customMenus'
import { useMenuManagement } from '@/views/permission/composables/useMenuManagement'
import PageHeading from '@/views/components/PageHeading.vue'
const { selectedKey, selectedParent, form, menuTree, parentOptions, resetForm, selectNode, saveMenu, deleteMenu } = useMenuManagement()
</script>
<template>
<PageHeading eyebrow="后台管理" title="菜单管理" description="维护系统菜单结构、路由和按钮权限。" />
<div class="menu-editor-layout">
<section class="panel menu-tree-panel">
<div class="panel-heading">
<div><h2>系统菜单</h2></div>
<el-button type="primary" @click="resetForm(selectedParent)">
<el-icon><Plus /></el-icon>
新增菜单
</el-button>
</div>
<el-tree
:data="menuTree"
node-key="id"
default-expand-all
:expand-on-click-node="false"
@node-click="selectNode"
/>
</section>
<section class="panel menu-form-panel">
<div class="panel-heading">
<div><h2>{{ selectedKey ? '编辑菜单权限' : '新增菜单权限' }}</h2></div>
<el-button v-if="selectedKey" type="danger" plain @click="deleteMenu">删除菜单</el-button>
</div>
<el-form label-width="110px" label-position="right" class="menu-editor-form">
<el-form-item label="菜单名称" required>
<el-input v-model="form.title" maxlength="30" />
</el-form-item>
<el-form-item label="对应路由" :required="form.type !== '按钮'">
<el-input v-model="form.route" placeholder="例如 /reports" />
</el-form-item>
<el-form-item label="菜单标识" required>
<el-input
v-model="form.permission"
:placeholder="`自动生成:访问菜单:${form.title || '菜单名'}`"
/>
</el-form-item>
<el-form-item label="上级菜单">
<el-select v-model="form.parent">
<el-option v-for="item in parentOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="菜单类型">
<el-radio-group v-model="form.type">
<el-radio value="路径">路径</el-radio>
<el-radio value="页面">页面</el-radio>
<el-radio value="按钮">按钮</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="菜单图标">
<el-select v-model="form.icon" clearable placeholder="请选择">
<el-option v-for="icon in menuIconNames" :key="icon" :label="icon" :value="icon" />
</el-select>
</el-form-item>
<el-form-item label="使用方法">
<el-input v-model="form.method" type="textarea" :rows="3" placeholder="可填写按钮权限对应的操作说明" />
</el-form-item>
<el-form-item label="菜单排序">
<el-input-number v-model="form.sort" :min="1" :max="999" />
</el-form-item>
</el-form>
<div class="menu-form-footer">
<el-button type="primary" @click="saveMenu">保存菜单</el-button>
</div>
</section>
</div>
</template>
@@ -0,0 +1,51 @@
<script setup>
import { Plus } from '@element-plus/icons-vue'
import { useRoleCategories } from '@/views/permission/composables/useRoleCategories'
import PageHeading from '@/views/components/PageHeading.vue'
const { dialogVisible, name, editingId, tableData, openCreateDialog, editCategory, saveCategory, deleteCategory } = useRoleCategories()
</script>
<template>
<PageHeading eyebrow="后台管理" title="角色分类" description="维护角色分类,方便按职责整理权限角色。">
<el-button type="primary" class="primary-button" @click="openCreateDialog">
<el-icon><Plus /></el-icon>
新增分类
</el-button>
</PageHeading>
<section class="panel">
<el-table :data="tableData" row-key="id" border class="drama-el-table" empty-text="暂无角色分类">
<el-table-column type="index" label="序号" width="100" />
<el-table-column prop="id" label="ID" width="140" />
<el-table-column prop="name" label="分类名称" min-width="260" />
<el-table-column label="操作" width="230">
<template #default="{ row }">
<el-button type="primary" @click="editCategory(row)">编辑</el-button>
<el-button type="danger" @click="deleteCategory(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</section>
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑角色分类' : '新增角色分类'"
width="440px"
>
<el-form label-position="top">
<el-form-item label="分类名称" required>
<el-input
v-model="name"
maxlength="30"
placeholder="请输入分类名称"
@keyup.enter="saveCategory"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveCategory">保存</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,92 @@
<script setup>
import { Plus } from '@element-plus/icons-vue'
import { useRolePermissionEditor } from '@/views/permission/composables/useRolePermissionEditor'
import PageHeading from '@/views/components/PageHeading.vue'
const {
roleData,
rolePermissions,
selectedRole,
selectedRoleRecord,
canEditSelectedRole,
permissionTree,
treeRef,
dialogVisible,
newRoleName,
selectRole,
handleCheck,
addRole,
} = useRolePermissionEditor()
</script>
<template>
<PageHeading eyebrow="后台管理" title="角色权限配置" description="角色等级数字越小权限越高,低等级角色不能修改高等级角色。">
<el-button type="primary" class="primary-button" @click="dialogVisible = true">
<el-icon><Plus /></el-icon>
新增角色
</el-button>
</PageHeading>
<div class="role-permission-layout">
<section class="panel role-table-panel">
<div class="panel-heading">
<div><h2>角色列表({{ roleData.length }})</h2></div>
</div>
<el-table
:data="roleData"
row-key="id"
border
class="drama-el-table role-config-table"
highlight-current-row
:current-row-key="selectedRoleRecord?.id"
@row-click="selectRole"
>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="category" label="角色分类" min-width="120" />
<el-table-column prop="name" label="角色名称" min-width="120" />
<el-table-column prop="description" label="描述" min-width="180" />
<el-table-column prop="level" label="角色等级" width="100" sortable />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag type="success" effect="light">{{ row.status }}</el-tag>
</template>
</el-table-column>
</el-table>
</section>
<section class="panel permission-tree-panel">
<div class="panel-heading">
<div>
<h2>编辑:{{ selectedRole }} 菜单权限</h2>
<p v-if="!canEditSelectedRole" class="permission-warning">
当前账号等级较低,不能修改高等级角色。
</p>
<p v-else>勾选菜单权限以配置角色可访问的功能。</p>
</div>
</div>
<el-tree
ref="treeRef"
:data="permissionTree"
node-key="id"
show-checkbox
check-strictly
:default-expand-all="true"
:expand-on-click-node="false"
:props="{ label: 'label', children: 'children', disabled: () => !canEditSelectedRole }"
@check-change="handleCheck"
/>
</section>
</div>
<el-dialog v-model="dialogVisible" title="新增角色" width="440px">
<el-form label-position="top">
<el-form-item label="角色名称" required>
<el-input v-model="newRoleName" maxlength="30" placeholder="请输入角色名称" @keyup.enter="addRole" />
</el-form-item>
<el-alert title="新角色等级将低于当前账号,等级数字越大权限越低。" type="info" :closable="false" />
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="addRole">创建角色</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,106 @@
import { computed, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { customMenus, saveCustomMenus } from '../../../data/customMenus'
import { useRbac } from '../../../stores/rbac'
import router, { registerCustomMenu } from '../../../router'
const builtInMenus = [
{ id: 'builtin-content', label: '内容管理', children: [
{ id: 'builtin-dramas', label: '短剧管理', route: '/dramas', permission: '查看短剧列表' },
{ id: 'builtin-episodes', label: '剧集管理', route: '/episodes', permission: '查看剧集列表' },
] },
{ id: 'builtin-admin', label: '后台管理', children: [
{ id: 'builtin-access', label: '角色权限配置', route: '/access', permission: '角色与权限配置' },
{ id: 'builtin-users', label: '管理员用户列表', route: '/admin-users', permission: '管理员用户列表' },
{ id: 'builtin-menus', label: '菜单管理', route: '/menus', permission: '菜单管理' },
{ id: 'builtin-categories', label: '角色分类', route: '/role-categories', permission: '角色分类管理' },
] },
]
function buildCustomNodes(parent) {
return customMenus.filter(menu => (menu.parent || '后台管理') === parent).map(menu => ({
id: menu.key,
label: menu.title,
menu,
children: buildCustomNodes(menu.title),
}))
}
export function useMenuManagement() {
const { createPermission, removePermission } = useRbac()
const selectedKey = ref('')
const selectedParent = ref('后台管理')
const form = reactive({ key: '', title: '', route: '', permission: '', parent: '后台管理', type: '页面', icon: 'Document', method: '', sort: 1 })
const menuTree = computed(() => builtInMenus.map(group => ({ ...group, children: [...group.children, ...buildCustomNodes(group.label)] })))
const parentOptions = computed(() => ['内容管理', '后台管理', ...customMenus.filter(menu => menu.type !== '按钮').map(menu => menu.title)])
function resetForm(parent = selectedParent.value) {
selectedKey.value = ''
Object.assign(form, { key: '', title: '', route: '', permission: '', parent, type: '页面', icon: 'Document', method: '', sort: 1 })
}
function selectNode(node) {
if (node.menu) {
selectedKey.value = node.menu.key
selectedParent.value = node.menu.parent || '后台管理'
Object.assign(form, {
...node.menu,
route: node.menu.route || `/custom/${node.menu.key}`,
permission: node.menu.permission || `访问菜单:${node.menu.title}`,
type: node.menu.type || '页面',
method: node.menu.method || '',
sort: node.menu.sort || 1,
})
} else if (node.children) {
selectedParent.value = node.label
resetForm(node.label)
}
}
function saveMenu() {
if (!form.title.trim()) return ElMessage.warning('请填写菜单名称')
if (form.type !== '按钮' && !form.route.trim()) return ElMessage.warning('请填写对应路由')
const duplicate = customMenus.some(menu => menu.title === form.title.trim() && menu.key !== selectedKey.value)
if (duplicate) return ElMessage.warning('菜单名称已存在')
const isNew = !selectedKey.value
const key = selectedKey.value || `${Date.now()}`
const existingMenu = customMenus.find(menu => menu.key === key)
const permission = form.permission.trim() || `访问菜单:${form.title.trim()}`
if (existingMenu) {
if (router.hasRoute(`custom-menu-${key}`)) router.removeRoute(`custom-menu-${key}`)
if (existingMenu.permission !== permission) {
removePermission(existingMenu.permission)
createPermission(permission)
}
Object.assign(existingMenu, { ...form, key, title: form.title.trim(), permission, route: form.route.trim(), sort: Number(form.sort) || 1 })
} else {
customMenus.push({ ...form, key, title: form.title.trim(), permission, route: form.route.trim(), sort: Number(form.sort) || 1 })
createPermission(permission)
}
const savedMenu = customMenus.find(menu => menu.key === key)
if (savedMenu?.type !== '按钮') registerCustomMenu(savedMenu)
saveCustomMenus()
selectedKey.value = key
ElMessage.success(isNew ? '菜单已添加' : '菜单已保存')
}
async function deleteMenu() {
const menu = customMenus.find(item => item.key === selectedKey.value)
if (!menu) return ElMessage.warning('请先在左侧选择一个自定义菜单')
try {
await ElMessageBox.confirm(`确认删除「${menu.title}」菜单吗?`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger',
})
customMenus.splice(customMenus.indexOf(menu), 1)
removePermission(menu.permission)
if (router.hasRoute(`custom-menu-${menu.key}`)) router.removeRoute(`custom-menu-${menu.key}`)
saveCustomMenus()
resetForm()
ElMessage.success('菜单已删除')
} catch {
// 用户取消时不删除菜单。
}
}
return { selectedKey, selectedParent, form, menuTree, parentOptions, resetForm, selectNode, saveMenu, deleteMenu }
}
@@ -0,0 +1,62 @@
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { roleData } from '../../../data/roles'
export function useRoleCategories() {
const dialogVisible = ref(false)
const name = ref('')
const editingId = ref(null)
const categories = ref([
{ id: 1, name: '系统管理员', description: '负责账号、权限和菜单配置' },
{ id: 2, name: '内容管理', description: '负责短剧与剧集内容维护' },
{ id: 3, name: '数据分析', description: '负责查看运营数据' },
])
const tableData = computed(() => categories.value.map(category => ({
...category,
roleCount: roleData.filter(role => role.category === category.name).length,
})))
function openCreateDialog() {
editingId.value = null
name.value = ''
dialogVisible.value = true
}
function editCategory(category) {
editingId.value = category.id
name.value = category.name
dialogVisible.value = true
}
function saveCategory() {
const value = name.value.trim()
if (!value) return ElMessage.warning('请输入分类名称')
if (categories.value.some(category => category.name === value && category.id !== editingId.value)) {
return ElMessage.warning('分类名称已存在')
}
if (editingId.value) {
const category = categories.value.find(item => item.id === editingId.value)
const oldName = category?.name
if (category) category.name = value
roleData.forEach(role => { if (role.category === oldName) role.category = value })
} else {
categories.value.push({ id: Date.now(), name: value, description: '自定义角色分类' })
}
dialogVisible.value = false
ElMessage.success('角色分类已保存')
}
async function deleteCategory(category) {
try {
await ElMessageBox.confirm(`确定删除「${category.name}」分类吗?`, '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger',
})
categories.value = categories.value.filter(item => item.id !== category.id)
ElMessage.success('分类已删除')
} catch {
// 用户取消时不删除分类。
}
}
return { dialogVisible, name, editingId, tableData, openCreateDialog, editCategory, saveCategory, deleteCategory }
}
@@ -0,0 +1,69 @@
import { computed, nextTick, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { roleData } from '../../../data/roles'
import { customMenus } from '../../../data/customMenus'
import { permissionGroups } from '../../../permissions/permissions'
import { useAuth } from '../../../stores/auth'
import { useRbac } from '../../../stores/rbac'
export function useRolePermissionEditor() {
const { rolePermissions, updateRolePermission, createRole } = useRbac()
const { currentRole } = useAuth()
const selectedRole = ref(roleData[0].name)
const dialogVisible = ref(false)
const newRoleName = ref('')
const treeRef = ref()
const syncingTree = ref(false)
const permissionTree = computed(() => {
const groups = permissionGroups.map(group => ({
id: `group-${group.name}`,
label: group.name,
children: group.items.map(permission => ({ id: permission, label: permission, permission })),
}))
if (customMenus.length) {
groups.push({
id: 'group-custom-menus',
label: '自定义菜单',
children: customMenus.map(menu => ({ id: menu.permission, label: menu.title, permission: menu.permission })),
})
}
return groups
})
const currentLevel = computed(() => roleData.find(role => role.name === currentRole.value)?.level || 1)
const selectedRoleRecord = computed(() => roleData.find(role => role.name === selectedRole.value))
const canEditSelectedRole = computed(() => (selectedRoleRecord.value?.level || 1) >= currentLevel.value)
watch(selectedRole, async role => {
syncingTree.value = true
await nextTick()
treeRef.value?.setCheckedKeys(rolePermissions[role] || [])
await nextTick()
syncingTree.value = false
}, { immediate: true })
function selectRole(row) { selectedRole.value = row.name }
function handleCheck(data, checked) {
if (!syncingTree.value && data.permission && canEditSelectedRole.value) {
updateRolePermission(selectedRole.value, data.permission, checked)
}
}
function addRole() {
const name = newRoleName.value.trim()
if (!name) return ElMessage.warning('请输入角色名称')
if (roleData.some(role => role.name === name)) return ElMessage.warning('角色名称已存在')
roleData.push({
id: Date.now(), name, description: '自定义权限角色', memberCount: 0,
category: '自定义角色', level: currentLevel.value + 1, status: '启用',
})
createRole(name)
selectedRole.value = name
newRoleName.value = ''
dialogVisible.value = false
ElMessage.success('角色已创建')
}
return {
roleData, rolePermissions, selectedRole, selectedRoleRecord, canEditSelectedRole,
permissionTree, treeRef, dialogVisible, newRoleName, selectRole, handleCheck, addRole,
}
}
@@ -0,0 +1,4 @@
<template>
<div class="page-heading"><div><div class="eyebrow">系统管理</div><h1>系统设置</h1><p>工作空间基础设置。</p></div></div>
<section class="panel settings-panel"><h2>工作空间</h2><el-form label-position="top" class="settings-form"><el-form-item label="空间名称"><el-input model-value="ReelShort Studio" /></el-form-item><el-form-item label="内容默认状态"><el-select model-value="草稿"><el-option label="草稿" value="草稿" /><el-option label="待审核" value="待审核" /></el-select></el-form-item><el-button type="primary" disabled>保存设置</el-button></el-form></section>
</template>
+131
View File
@@ -0,0 +1,131 @@
<script setup>
import { Plus, Search } from '@element-plus/icons-vue'
import { roleData } from '@/data/roles'
import { useAdminUsers } from '@/views/userManagement/composables/useAdminUsers'
import PageHeading from '@/views/components/PageHeading.vue'
const {
keyword,
roleFilter,
currentPage,
pageSize,
dialogVisible,
form,
filtered,
paginated,
queryUsers,
toggleStatus,
addUser,
} = useAdminUsers()
</script>
<template>
<PageHeading eyebrow="后台管理" title="管理员用户列表" description="查询和维护后台管理员账号及权限角色。" />
<section class="panel admin-users-panel">
<div class="admin-user-toolbar">
<el-input
v-model="keyword"
:prefix-icon="Search"
clearable
placeholder="搜索用户ID或用户名"
@keyup.enter="queryUsers"
@clear="queryUsers"
/>
<el-select
v-model="roleFilter"
clearable
placeholder="搜索权限角色"
@change="queryUsers"
>
<el-option
v-for="role in roleData"
:key="role.name"
:label="role.name"
:value="role.name"
/>
</el-select>
<el-button type="primary" @click="queryUsers">查询</el-button>
<el-button type="primary" @click="dialogVisible = true">
<el-icon><Plus /></el-icon>
添加用户
</el-button>
</div>
<el-table
:data="paginated"
row-key="id"
border
class="drama-el-table admin-users-table"
empty-text="没有符合条件的管理员"
>
<el-table-column
type="index"
label="序号"
width="75"
:index="(currentPage - 1) * pageSize + 1"
/>
<el-table-column prop="id" label="ID" width="90" />
<el-table-column prop="username" label="用户名" min-width="140" />
<el-table-column prop="name" label="姓名" min-width="130" />
<el-table-column prop="roleId" label="权限ID" width="110" />
<el-table-column prop="role" label="权限角色" min-width="150" />
<el-table-column prop="ipWhitelist" label="IP白名单" min-width="140" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-button link type="primary" @click="toggleStatus(row)">
{{ row.status }}
</el-button>
</template>
</el-table-column>
<el-table-column label="2FA状态" width="120">
<template #default="{ row }">
<el-tag type="info" effect="plain">{{ row.twoFactor }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="185" />
<el-table-column prop="lastLogin" label="最后登录" min-width="185" />
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="filtered.length"
layout="total, prev, pager, next, sizes, jumper"
background
/>
</div>
</section>
<el-dialog v-model="dialogVisible" title="添加管理员" width="520px">
<el-form label-position="top">
<div class="form-row">
<el-form-item label="用户名" required>
<el-input v-model="form.username" />
</el-form-item>
<el-form-item label="姓名" required>
<el-input v-model="form.name" />
</el-form-item>
</div>
<el-form-item label="权限角色" required>
<el-select v-model="form.role" placeholder="请选择角色">
<el-option
v-for="role in roleData"
:key="role.name"
:label="role.name"
:value="role.name"
/>
</el-select>
</el-form-item>
<el-form-item label="IP白名单">
<el-input v-model="form.ipWhitelist" placeholder="选填,多个 IP 用逗号分隔" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="addUser">确认添加</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,45 @@
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { roleData } from '../../../data/roles'
import { demoAdminUsers } from '../data/adminUsers'
export function useAdminUsers() {
const keyword = ref('')
const roleFilter = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const dialogVisible = ref(false)
const form = reactive({ username: '', name: '', role: '', ipWhitelist: '' })
const users = ref(demoAdminUsers.map(user => ({ ...user })))
const filtered = computed(() => users.value.filter(user => {
const query = keyword.value.trim().toLowerCase()
const matchesKeyword = !query || `${user.id} ${user.username} ${user.name}`.toLowerCase().includes(query)
return matchesKeyword && (!roleFilter.value || user.role === roleFilter.value)
}))
const paginated = computed(() => filtered.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value))
function queryUsers() { currentPage.value = 1 }
function toggleStatus(user) { user.status = user.status === '启用' ? '停用' : '启用' }
function addUser() {
if (!form.username.trim() || !form.name.trim() || !form.role) return ElMessage.warning('请填写用户名、姓名并选择权限角色')
const role = roleData.find(item => item.name === form.role)
users.value.unshift({
id: Math.max(0, ...users.value.map(user => user.id)) + 1,
username: form.username.trim(),
name: form.name.trim(),
roleId: role?.id || 0,
role: form.role,
ipWhitelist: form.ipWhitelist.trim() || '不限',
status: '启用',
twoFactor: '未开启',
createdAt: new Date().toLocaleString('sv-SE'),
lastLogin: '—',
})
Object.assign(form, { username: '', name: '', role: '', ipWhitelist: '' })
dialogVisible.value = false
currentPage.value = 1
ElMessage.success('管理员已添加')
}
return { keyword, roleFilter, currentPage, pageSize, dialogVisible, form, users, filtered, paginated, queryUsers, toggleStatus, addUser }
}
@@ -0,0 +1,7 @@
export const demoAdminUsers = [
{ id: 1001, username: 'lin.xiaoman', name: '林小满', roleId: 1, role: '超级管理员', ipWhitelist: '不限', status: '启用', twoFactor: '已开启', createdAt: '2026-09-18 09:30:00', lastLogin: '2026-10-12 09:41:22' },
{ id: 1002, username: 'content.editor01', name: '陈一诺', roleId: 2, role: '内容运营', ipWhitelist: '不限', status: '启用', twoFactor: '未开启', createdAt: '2026-09-21 10:15:00', lastLogin: '2026-10-11 16:20:15' },
{ id: 1003, username: 'content.editor02', name: '周子瑜', roleId: 2, role: '内容运营', ipWhitelist: '不限', status: '启用', twoFactor: '未开启', createdAt: '2026-09-25 14:20:00', lastLogin: '2026-10-11 13:34:10' },
{ id: 1004, username: 'content.reviewer01', name: '林晓彤', roleId: 3, role: '审核专员', ipWhitelist: '不限', status: '启用', twoFactor: '已开启', createdAt: '2026-10-02 11:05:00', lastLogin: '2026-10-10 11:20:08' },
{ id: 1005, username: 'data.analyst01', name: '程予安', roleId: 4, role: '数据分析师', ipWhitelist: '不限', status: '启用', twoFactor: '未开启', createdAt: '2026-10-05 13:02:00', lastLogin: '2026-10-09 14:10:02' },
]
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})