first commit
This commit is contained in:
commit
db15eb3ee0
42 files changed
+4620
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
Generated
+1565
File diff suppressed because it is too large.
Load diff
@@ -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" }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<template><RouterView /></template>
|
||||
|
||||
<script setup>
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
@@ -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}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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: '' },
|
||||
])
|
||||
@@ -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: '—' },
|
||||
])
|
||||
@@ -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: '启用' },
|
||||
])
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
@@ -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}
|
||||
@@ -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],
|
||||
内容运营: ['查看短剧列表', '创建短剧', '编辑短剧信息', '删除短剧', '查看剧集列表', '上传剧集视频', '管理剧集排序', '上传封面素材'],
|
||||
审核专员: ['查看短剧列表', '查看剧集列表', '审核并发布', '查看数据报表'],
|
||||
数据分析师: ['查看短剧列表', '查看数据报表'],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+231
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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' },
|
||||
]
|
||||
@@ -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)),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user