feat: 搭建后台管理框架

This commit is contained in:
Vicky committed 2026-10-13 13:58:05 +08:00
1 parent 6d28bab72b
commit 40546bfd01
238 files changed
+18160 -3837

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { Error } from '@/components/Error'
import { usePermissionStore } from '@/store/modules/permission'
import { useRouter } from 'vue-router'
const { push } = useRouter()
const permissionStore = usePermissionStore()
const errorClick = () => {
push(permissionStore.addRouters[0]?.path as string)
}
</script>
<template>
<Error @error-click="errorClick" />
</template>
+274
View File
@@ -0,0 +1,274 @@
<script setup lang="ts">
import { LoginForm, RegisterForm } from './components'
import { ThemeSwitch } from '@/components/ThemeSwitch'
import { LocaleDropdown } from '@/components/LocaleDropdown'
import { useI18n } from '@/hooks/web/useI18n'
import { getCssVar, underlineToHump } from '@/utils'
import { useAppStore } from '@/store/modules/app'
import { computed, ref } from 'vue'
import { ElScrollbar } from 'element-plus'
const appStore = useAppStore()
const { t } = useI18n()
const isLogin = ref(true)
const isDark = computed(() => appStore.getIsDark)
const toolColor = computed(() => (isDark.value ? '#e5eaf3' : '#303133'))
const themeChange = () => {
const color = getCssVar('--el-bg-color')
appStore.setMenuTheme(color)
appStore.setHeaderTheme(color)
}
</script>
<template>
<div class="login-page" :class="{ 'is-dark': isDark }">
<ElScrollbar class="login-scroll">
<div class="login-shell">
<section class="login-hero">
<div class="hero-brand">
<img src="@/assets/imgs/logo.png" alt="" />
<span>{{ underlineToHump(appStore.getTitle) }}</span>
</div>
<div class="hero-copy">
<p class="hero-kicker">ReelShort Studio</p>
<h1>{{ t('login.welcome') }}</h1>
<p>{{ t('login.message') }}</p>
<ul>
<li>{{ t('login.pointDrama') }}</li>
<li>{{ t('login.pointPublish') }}</li>
<li>{{ t('login.pointAccess') }}</li>
</ul>
</div>
</section>
<section class="login-panel">
<div class="login-tools">
<ThemeSwitch @change="themeChange" />
<LocaleDropdown :color="toolColor" />
</div>
<div class="login-card">
<LoginForm v-if="isLogin" @to-register="isLogin = false" />
<RegisterForm v-else @to-login="isLogin = true" />
</div>
</section>
</div>
</ElScrollbar>
</div>
</template>
<style lang="less" scoped>
.login-page {
height: 100%;
color: var(--el-text-color-primary);
background: #eef3f9;
}
.login-scroll {
height: 100%;
}
.login-shell {
display: flex;
min-height: 100vh;
}
.login-hero {
position: relative;
display: flex;
padding: 40px 56px 56px;
overflow: hidden;
color: #fff;
background:
radial-gradient(circle at 18% 18%, rgb(255 255 255 / 16%), transparent 28%),
radial-gradient(circle at 82% 78%, rgb(64 158 255 / 45%), transparent 32%),
linear-gradient(155deg, #0d2344 0%, #1a4f96 52%, #409eff 100%);
flex: 1.05;
flex-direction: column;
justify-content: space-between;
&::after {
position: absolute;
right: -80px;
bottom: -120px;
width: 360px;
height: 360px;
border: 1px solid rgb(255 255 255 / 16%);
border-radius: 50%;
content: '';
}
}
.hero-brand {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 12px;
font-size: 20px;
font-weight: 700;
img {
width: 42px;
height: 42px;
}
}
.hero-copy {
position: relative;
z-index: 1;
max-width: 460px;
h1 {
margin: 10px 0 12px;
font-size: 40px;
font-weight: 700;
line-height: 1.2;
color: #fff;
}
p {
margin: 0;
font-size: 15px;
line-height: 1.7;
color: rgb(255 255 255 / 82%);
}
}
.hero-kicker {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.08em;
color: #d9ecff !important;
}
.hero-copy ul {
display: flex;
padding: 0;
margin: 28px 0 0;
list-style: none;
flex-wrap: wrap;
gap: 10px;
li {
padding: 8px 14px;
font-size: 13px;
color: #fff;
background: rgb(255 255 255 / 12%);
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 999px;
}
}
.login-panel {
position: relative;
display: flex;
flex: 0.95;
align-items: center;
justify-content: center;
padding: 88px 32px 48px;
background: #f4f7fb;
}
.login-tools {
position: absolute;
top: 22px;
right: 24px;
display: flex;
gap: 12px;
align-items: center;
}
.login-card {
width: min(420px, 100%);
padding: 36px 32px 28px;
background: #fff;
border: 1px solid #e6edf5;
border-radius: 18px;
box-shadow: 0 18px 50px rgb(15 39 68 / 8%);
}
.is-dark {
background: #0e1116;
.login-hero {
background:
radial-gradient(circle at 18% 18%, rgb(64 158 255 / 18%), transparent 30%),
linear-gradient(155deg, #07111f 0%, #0e2744 58%, #163e73 100%);
}
.login-panel {
background: #0e1116;
}
.login-card {
background: #161b22;
border-color: #2a313c;
box-shadow: none;
}
}
@media (width <= 960px) {
.login-shell {
flex-direction: column;
}
.login-hero {
flex: none;
min-height: 280px;
padding: 28px 24px 32px;
}
.hero-copy h1 {
font-size: 30px;
}
.login-panel {
padding: 72px 16px 32px;
}
}
</style>
<style lang="less">
.login-heading {
margin-bottom: 8px;
h2 {
margin: 0;
font-size: 28px;
font-weight: 700;
line-height: 1.2;
color: var(--el-text-color-primary);
}
p {
margin: 8px 0 0;
font-size: 14px;
color: var(--el-text-color-secondary);
}
}
.login-register {
display: flex;
margin-top: 16px;
font-size: 14px;
color: var(--el-text-color-secondary);
gap: 6px;
align-items: center;
justify-content: center;
button {
padding: 0;
font: inherit;
color: var(--el-color-primary);
cursor: pointer;
background: none;
border: 0;
}
}
body:has(.login-page) {
min-width: 0;
background: transparent;
}
</style>
+251
View File
@@ -0,0 +1,251 @@
<script setup lang="tsx">
import { reactive, ref, watch, onMounted, unref } from 'vue'
import { Form, FormSchema } from '@/components/Form'
import { useI18n } from '@/hooks/web/useI18n'
import { ElCheckbox, ElLink } from 'element-plus'
import { useForm } from '@/hooks/web/useForm'
import { loginApi, getTestRoleApi, getAdminRoleApi } from '@/api/login'
import { useAppStore } from '@/store/modules/app'
import { usePermissionStore } from '@/store/modules/permission'
import { useRouter } from 'vue-router'
import type { RouteLocationNormalizedLoaded, RouteRecordRaw } from 'vue-router'
import { UserType } from '@/api/login/types'
import { useValidator } from '@/hooks/web/useValidator'
import { useUserStore } from '@/store/modules/user'
import { BaseButton } from '@/components/Button'
const { required } = useValidator()
const emit = defineEmits(['to-register'])
const appStore = useAppStore()
const userStore = useUserStore()
const permissionStore = usePermissionStore()
const { currentRoute, addRoute, push } = useRouter()
const { t } = useI18n()
const rules = {
username: [required()],
password: [required()]
}
const schema = reactive<FormSchema[]>([
{
field: 'title',
colProps: {
span: 24
},
formItemProps: {
slots: {
default: () => {
return (
<div class="login-heading">
<h2>{t('login.login')}</h2>
<p>{t('login.hint')}</p>
</div>
)
}
}
}
},
{
field: 'username',
label: t('login.username'),
// value: 'admin',
component: 'Input',
colProps: {
span: 24
},
componentProps: {
placeholder: 'admin'
}
},
{
field: 'password',
label: t('login.password'),
// value: 'admin',
component: 'InputPassword',
colProps: {
span: 24
},
componentProps: {
style: {
width: '100%'
},
placeholder: 'admin123'
}
},
{
field: 'tool',
colProps: {
span: 24
},
formItemProps: {
slots: {
default: () => {
return (
<>
<div class="flex justify-between items-center w-[100%]">
<ElCheckbox v-model={remember.value} label={t('login.remember')} size="small" />
<ElLink type="primary" underline={false}>
{t('login.forgetPassword')}
</ElLink>
</div>
</>
)
}
}
}
},
{
field: 'login',
colProps: {
span: 24
},
formItemProps: {
slots: {
default: () => {
return (
<>
<div class="w-[100%]">
<BaseButton
loading={loading.value}
type="primary"
class="w-[100%]"
onClick={signIn}
>
{t('login.login')}
</BaseButton>
</div>
<div class="login-register">
<span>{t('login.noAccount')}</span>
<button type="button" onClick={toRegister}>
{t('login.register')}
</button>
</div>
</>
)
}
}
}
}
])
const remember = ref(userStore.getRememberMe)
const initLoginInfo = () => {
const loginInfo = userStore.getLoginInfo
if (loginInfo) {
const { username, password } = loginInfo
setValues({ username, password })
}
}
onMounted(() => {
initLoginInfo()
})
const { formRegister, formMethods } = useForm()
const { getFormData, getElFormExpose, setValues } = formMethods
const loading = ref(false)
const redirect = ref<string>('')
watch(
() => currentRoute.value,
(route: RouteLocationNormalizedLoaded) => {
redirect.value = route?.query?.redirect as string
},
{
immediate: true
}
)
// 登录
const signIn = async () => {
const formRef = await getElFormExpose()
await formRef?.validate(async (isValid) => {
if (isValid) {
loading.value = true
const formData = await getFormData<UserType>()
try {
const res = await loginApi(formData)
if (res) {
// 是否记住我
if (unref(remember)) {
userStore.setLoginInfo({
username: formData.username,
password: formData.password
})
} else {
userStore.setLoginInfo(undefined)
}
userStore.setRememberMe(unref(remember))
userStore.setUserInfo(res.data)
// 是否使用动态路由
if (appStore.getDynamicRouter) {
getRole()
} else {
await permissionStore.generateRoutes('static').catch(() => {})
permissionStore.getAddRouters.forEach((route) => {
addRoute(route as RouteRecordRaw) // 动态添加可访问路由表
})
permissionStore.setIsAddRouters(true)
push({ path: redirect.value || permissionStore.addRouters[0].path })
}
}
} finally {
loading.value = false
}
}
})
}
// 获取角色信息
const getRole = async () => {
const formData = await getFormData<UserType>()
const params = {
roleName: formData.username
}
const res =
appStore.getDynamicRouter && appStore.getServerDynamicRouter
? await getAdminRoleApi(params)
: await getTestRoleApi(params)
if (res) {
const routers = res.data || []
userStore.setRoleRouters(routers)
appStore.getDynamicRouter && appStore.getServerDynamicRouter
? await permissionStore.generateRoutes('server', routers).catch(() => {})
: await permissionStore.generateRoutes('frontEnd', routers).catch(() => {})
permissionStore.getAddRouters.forEach((route) => {
addRoute(route as RouteRecordRaw) // 动态添加可访问路由表
})
permissionStore.setIsAddRouters(true)
push({ path: redirect.value || permissionStore.addRouters[0].path })
}
}
// 去注册页面
const toRegister = () => {
emit('to-register')
}
</script>
<template>
<Form
:schema="schema"
:rules="rules"
label-position="top"
hide-required-asterisk
size="large"
class="login-form"
@register="formRegister"
/>
</template>
+170
View File
@@ -0,0 +1,170 @@
<script setup lang="tsx">
import { Form, FormSchema } from '@/components/Form'
import { reactive, ref } from 'vue'
import { useI18n } from '@/hooks/web/useI18n'
import { useForm } from '@/hooks/web/useForm'
import { ElButton, ElInput, FormRules } from 'element-plus'
import { useValidator } from '@/hooks/web/useValidator'
const emit = defineEmits(['to-login'])
const { formRegister, formMethods } = useForm()
const { getElFormExpose } = formMethods
const { t } = useI18n()
const { required } = useValidator()
const schema = reactive<FormSchema[]>([
{
field: 'title',
colProps: {
span: 24
},
formItemProps: {
slots: {
default: () => {
return (
<div class="login-heading">
<h2>{t('login.register')}</h2>
<p>{t('login.message')}</p>
</div>
)
}
}
}
},
{
field: 'username',
label: t('login.username'),
value: '',
component: 'Input',
colProps: {
span: 24
},
componentProps: {
placeholder: t('login.usernamePlaceholder')
}
},
{
field: 'password',
label: t('login.password'),
value: '',
component: 'InputPassword',
colProps: {
span: 24
},
componentProps: {
style: {
width: '100%'
},
strength: true,
placeholder: t('login.passwordPlaceholder')
}
},
{
field: 'check_password',
label: t('login.checkPassword'),
value: '',
component: 'InputPassword',
colProps: {
span: 24
},
componentProps: {
style: {
width: '100%'
},
strength: true,
placeholder: t('login.passwordPlaceholder')
}
},
{
field: 'code',
label: t('login.code'),
colProps: {
span: 24
},
formItemProps: {
slots: {
default: (formData) => {
return (
<div class="w-[100%] flex">
<ElInput v-model={formData.code} placeholder={t('login.codePlaceholder')} />
</div>
)
}
}
}
},
{
field: 'register',
colProps: {
span: 24
},
formItemProps: {
slots: {
default: () => {
return (
<>
<div class="w-[100%]">
<ElButton
type="primary"
class="w-[100%]"
loading={loading.value}
onClick={loginRegister}
>
{t('login.register')}
</ElButton>
</div>
<div class="w-[100%] mt-15px">
<ElButton class="w-[100%]" onClick={toLogin}>
{t('login.hasUser')}
</ElButton>
</div>
</>
)
}
}
}
}
])
const rules: FormRules = {
username: [required()],
password: [required()],
check_password: [required()],
code: [required()]
}
const toLogin = () => {
emit('to-login')
}
const loading = ref(false)
const loginRegister = async () => {
const formRef = await getElFormExpose()
formRef?.validate(async (valid) => {
if (valid) {
try {
loading.value = true
toLogin()
} finally {
loading.value = false
}
}
})
}
</script>
<template>
<Form
:schema="schema"
:rules="rules"
label-position="top"
hide-required-asterisk
size="large"
class="login-form"
@register="formRegister"
/>
</template>
+4
View File
@@ -0,0 +1,4 @@
import LoginForm from './LoginForm.vue'
import RegisterForm from './RegisterForm.vue'
export { LoginForm, RegisterForm }
+30
View File
@@ -0,0 +1,30 @@
<template>
<div></div>
</template>
<script setup lang="ts">
import { unref } from 'vue'
import { useRouter } from 'vue-router'
const { currentRoute, replace } = useRouter()
const { params, query } = unref(currentRoute)
const { path, _redirect_type = 'path' } = params
Reflect.deleteProperty(params, '_redirect_type')
Reflect.deleteProperty(params, 'path')
const _path = Array.isArray(path) ? path.join('/') : path
if (_redirect_type === 'name') {
replace({
name: _path,
query,
params
})
} else {
replace({
path: _path.startsWith('/') ? _path : '/' + _path,
query
})
}
</script>
+64 -7
View File
@@ -10,14 +10,71 @@ 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="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 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 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="scope">{{ scope?.row?.episodes }} 集</template></el-table-column
><el-table-column prop="views" label="播放量" width="140" /><el-table-column
label="状态"
width="130"
><template #default="scope"
><el-tag
v-if="scope?.row"
:class="['status-badge', statusClass(scope.row.status)]"
effect="light"
>{{ scope.row.status }}</el-tag
></template
></el-table-column
><el-table-column prop="updatedAt" label="更新时间" width="160"
/></el-table>
</section>
</template>
-73
View File
@@ -1,73 +0,0 @@
<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>
+2 -2
View File
@@ -2,7 +2,7 @@
defineProps({
eyebrow: { type: String, default: '' },
title: { type: String, required: true },
description: { type: String, default: '' },
description: { type: String, default: '' }
})
</script>
@@ -13,6 +13,6 @@ defineProps({
<h1>{{ title }}</h1>
<p v-if="description">{{ description }}</p>
</div>
<slot />
<slot></slot>
</div>
</template>
@@ -14,9 +14,19 @@ 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 })
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: '' })
@@ -24,14 +34,20 @@ function openCreate() {
}
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 || '' })
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)
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 })
@@ -41,7 +57,9 @@ function saveDrama() {
ElMessage.success(editingId.value ? '短剧信息已更新' : '短剧草稿已保存')
editingId.value = null
}
function uploadCover(file) { if (can('上传封面素材')) form.cover = URL.createObjectURL(file.raw) }
function uploadCover(file) {
if (can('上传封面素材')) form.cover = URL.createObjectURL(file.raw)
}
async function confirmDelete(item) {
try {
await ElMessageBox.confirm(`确定删除短剧「${item.title}」吗?删除后无法恢复。`, '删除确认', {
@@ -49,11 +67,12 @@ async function confirmDelete(item) {
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger',
distinguishCancelAndClose: true,
distinguishCancelAndClose: true
})
const index = dramas.findIndex(drama => drama.id === item.id)
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)
if (currentPage.value > Math.ceil(filtered.value.length / pageSize.value))
currentPage.value = Math.max(1, currentPage.value - 1)
ElMessage.success('短剧已删除')
} catch {
// 用户取消或关闭确认框时不执行删除。
@@ -62,19 +81,134 @@ async function confirmDelete(item) {
</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>
<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>
<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 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 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 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-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>
<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>
<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>
@@ -13,12 +13,30 @@ 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 }
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 })
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
@@ -27,18 +45,92 @@ function saveEpisode() {
</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>
<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>
<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-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>
<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>
+7 -2
View File
@@ -4,10 +4,15 @@ import { useRoute } from 'vue-router'
import { customMenus } from '@/data/customMenus'
const route = useRoute()
const menu = computed(() => customMenus.find(item => item.key === route.meta.menuKey))
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>
<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>
+25 -4
View File
@@ -4,11 +4,25 @@ 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()
const {
selectedKey,
selectedParent,
form,
menuTree,
parentOptions,
resetForm,
selectNode,
saveMenu,
deleteMenu
} = useMenuManagement()
</script>
<template>
<PageHeading eyebrow="后台管理" title="菜单管理" description="维护系统菜单结构、路由和按钮权限。" />
<PageHeading
eyebrow="后台管理"
title="菜单管理"
description="维护系统菜单结构、路由和按钮权限。"
/>
<div class="menu-editor-layout">
<section class="panel menu-tree-panel">
@@ -30,7 +44,9 @@ const { selectedKey, selectedParent, form, menuTree, parentOptions, resetForm, s
<section class="panel menu-form-panel">
<div class="panel-heading">
<div><h2>{{ selectedKey ? '编辑菜单权限' : '新增菜单权限' }}</h2></div>
<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">
@@ -64,7 +80,12 @@ const { selectedKey, selectedParent, form, menuTree, parentOptions, resetForm, s
</el-select>
</el-form-item>
<el-form-item label="使用方法">
<el-input v-model="form.method" type="textarea" :rows="3" placeholder="可填写按钮权限对应的操作说明" />
<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" />
+22 -3
View File
@@ -3,11 +3,24 @@ 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()
const {
dialogVisible,
name,
editingId,
tableData,
openCreateDialog,
editCategory,
saveCategory,
deleteCategory
} = useRoleCategories()
</script>
<template>
<PageHeading eyebrow="后台管理" title="角色分类" description="维护角色分类,方便按职责整理权限角色。">
<PageHeading
eyebrow="后台管理"
title="角色分类"
description="维护角色分类,方便按职责整理权限角色。"
>
<el-button type="primary" class="primary-button" @click="openCreateDialog">
<el-icon><Plus /></el-icon>
新增分类
@@ -15,7 +28,13 @@ const { dialogVisible, name, editingId, tableData, openCreateDialog, editCategor
</PageHeading>
<section class="panel">
<el-table :data="tableData" row-key="id" border class="drama-el-table" empty-text="暂无角色分类">
<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" />
+20 -6
View File
@@ -5,7 +5,6 @@ import PageHeading from '@/views/components/PageHeading.vue'
const {
roleData,
rolePermissions,
selectedRole,
selectedRoleRecord,
canEditSelectedRole,
@@ -15,12 +14,16 @@ const {
newRoleName,
selectRole,
handleCheck,
addRole,
addRole
} = useRolePermissionEditor()
</script>
<template>
<PageHeading eyebrow="后台管理" title="角色权限配置" description="角色等级数字越小权限越高,低等级角色不能修改高等级角色。">
<PageHeading
eyebrow="后台管理"
title="角色权限配置"
description="角色等级数字越小权限越高,低等级角色不能修改高等级角色。"
>
<el-button type="primary" class="primary-button" @click="dialogVisible = true">
<el-icon><Plus /></el-icon>
新增角色
@@ -30,7 +33,9 @@ const {
<div class="role-permission-layout">
<section class="panel role-table-panel">
<div class="panel-heading">
<div><h2>角色列表({{ roleData.length }})</h2></div>
<div
><h2>角色列表({{ roleData.length }})</h2></div
>
</div>
<el-table
:data="roleData"
@@ -80,9 +85,18 @@ const {
<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-input
v-model="newRoleName"
maxlength="30"
placeholder="请输入角色名称"
@keyup.enter="addRole"
/>
</el-form-item>
<el-alert title="新角色等级将低于当前账号,等级数字越大权限越低。" type="info" :closable="false" />
<el-alert
title="新角色等级将低于当前账号,等级数字越大权限越低。"
type="info"
:closable="false"
/>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
@@ -5,38 +5,102 @@ 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: '角色分类管理' },
] },
{
id: 'builtin-content',
label: '内容管理',
children: [
{
id: 'builtin-dramas',
label: '短剧管理',
route: '/content/dramas',
permission: '查看短剧列表'
},
{
id: 'builtin-episodes',
label: '剧集管理',
route: '/content/episodes',
permission: '查看剧集列表'
}
]
},
{
id: 'builtin-admin',
label: '后台管理',
children: [
{
id: 'builtin-access',
label: '角色权限配置',
route: '/system/access',
permission: '角色与权限配置'
},
{
id: 'builtin-users',
label: '管理员用户列表',
route: '/system/admin-users',
permission: '管理员用户列表'
},
{ id: 'builtin-menus', label: '菜单管理', route: '/system/menus', permission: '菜单管理' },
{
id: 'builtin-categories',
label: '角色分类',
route: '/system/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),
}))
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)])
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 })
Object.assign(form, {
key: '',
title: '',
route: '',
permission: '',
parent,
type: '页面',
icon: 'Document',
method: '',
sort: 1
})
}
function selectNode(node) {
if (node.menu) {
@@ -48,7 +112,7 @@ export function useMenuManagement() {
permission: node.menu.permission || `访问菜单:${node.menu.title}`,
type: node.menu.type || '页面',
method: node.menu.method || '',
sort: node.menu.sort || 1,
sort: node.menu.sort || 1
})
} else if (node.children) {
selectedParent.value = node.label
@@ -58,11 +122,13 @@ export function useMenuManagement() {
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)
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 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}`)
@@ -70,26 +136,40 @@ export function useMenuManagement() {
removePermission(existingMenu.permission)
createPermission(permission)
}
Object.assign(existingMenu, { ...form, key, title: form.title.trim(), permission, route: form.route.trim(), sort: Number(form.sort) || 1 })
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 })
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)
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)
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',
confirmButtonClass: 'el-button--danger'
})
customMenus.splice(customMenus.indexOf(menu), 1)
removePermission(menu.permission)
@@ -102,5 +182,15 @@ export function useMenuManagement() {
}
}
return { selectedKey, selectedParent, form, menuTree, parentOptions, resetForm, selectNode, saveMenu, deleteMenu }
return {
selectedKey,
selectedParent,
form,
menuTree,
parentOptions,
resetForm,
selectNode,
saveMenu,
deleteMenu
}
}
@@ -9,12 +9,14 @@ export function useRoleCategories() {
const categories = ref([
{ id: 1, name: '系统管理员', description: '负责账号、权限和菜单配置' },
{ id: 2, name: '内容管理', description: '负责短剧与剧集内容维护' },
{ id: 3, name: '数据分析', description: '负责查看运营数据' },
{ id: 3, name: '数据分析', description: '负责查看运营数据' }
])
const tableData = computed(() => categories.value.map(category => ({
...category,
roleCount: roleData.filter(role => role.category === category.name).length,
})))
const tableData = computed(() =>
categories.value.map((category) => ({
...category,
roleCount: roleData.filter((role) => role.category === category.name).length
}))
)
function openCreateDialog() {
editingId.value = null
@@ -29,14 +31,20 @@ export function useRoleCategories() {
function saveCategory() {
const value = name.value.trim()
if (!value) return ElMessage.warning('请输入分类名称')
if (categories.value.some(category => category.name === value && category.id !== editingId.value)) {
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 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 })
roleData.forEach((role) => {
if (role.category === oldName) role.category = value
})
} else {
categories.value.push({ id: Date.now(), name: value, description: '自定义角色分类' })
}
@@ -49,14 +57,23 @@ export function useRoleCategories() {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger',
confirmButtonClass: 'el-button--danger'
})
categories.value = categories.value.filter(item => item.id !== category.id)
categories.value = categories.value.filter((item) => item.id !== category.id)
ElMessage.success('分类已删除')
} catch {
// 用户取消时不删除分类。
}
}
return { dialogVisible, name, editingId, tableData, openCreateDialog, editCategory, saveCategory, deleteCategory }
return {
dialogVisible,
name,
editingId,
tableData,
openCreateDialog,
editCategory,
saveCategory,
deleteCategory
}
}
@@ -15,33 +15,49 @@ export function useRolePermissionEditor() {
const treeRef = ref()
const syncingTree = ref(false)
const permissionTree = computed(() => {
const groups = permissionGroups.map(group => ({
const groups = permissionGroups.map((group) => ({
id: `group-${group.name}`,
label: group.name,
children: group.items.map(permission => ({ id: permission, label: permission, permission })),
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 })),
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)
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 })
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 selectRole(row) {
selectedRole.value = row.name
}
function handleCheck(data, checked) {
if (!syncingTree.value && data.permission && canEditSelectedRole.value) {
updateRolePermission(selectedRole.value, data.permission, checked)
@@ -50,10 +66,15 @@ export function useRolePermissionEditor() {
function addRole() {
const name = newRoleName.value.trim()
if (!name) return ElMessage.warning('请输入角色名称')
if (roleData.some(role => role.name === 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: '启用',
id: Date.now(),
name,
description: '自定义权限角色',
memberCount: 0,
category: '自定义角色',
level: currentLevel.value + 1,
status: '启用'
})
createRole(name)
selectedRole.value = name
@@ -63,7 +84,17 @@ export function useRolePermissionEditor() {
}
return {
roleData, rolePermissions, selectedRole, selectedRoleRecord, canEditSelectedRole,
permissionTree, treeRef, dialogVisible, newRoleName, selectRole, handleCheck, addRole,
roleData,
rolePermissions,
selectedRole,
selectedRoleRecord,
canEditSelectedRole,
permissionTree,
treeRef,
dialogVisible,
newRoleName,
selectRole,
handleCheck,
addRole
}
}
+15 -2
View File
@@ -1,4 +1,17 @@
<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>
<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>
+7 -8
View File
@@ -15,12 +15,16 @@ const {
paginated,
queryUsers,
toggleStatus,
addUser,
addUser
} = useAdminUsers()
</script>
<template>
<PageHeading eyebrow="后台管理" title="管理员用户列表" description="查询和维护后台管理员账号及权限角色。" />
<PageHeading
eyebrow="后台管理"
title="管理员用户列表"
description="查询和维护后台管理员账号及权限角色。"
/>
<section class="panel admin-users-panel">
<div class="admin-user-toolbar">
@@ -32,12 +36,7 @@ const {
@keyup.enter="queryUsers"
@clear="queryUsers"
/>
<el-select
v-model="roleFilter"
clearable
placeholder="搜索权限角色"
@change="queryUsers"
>
<el-select v-model="roleFilter" clearable placeholder="搜索权限角色" @change="queryUsers">
<el-option
v-for="role in roleData"
:key="role.name"
@@ -10,21 +10,34 @@ export function useAdminUsers() {
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))
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 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)
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,
id: Math.max(0, ...users.value.map((user) => user.id)) + 1,
username: form.username.trim(),
name: form.name.trim(),
roleId: role?.id || 0,
@@ -33,7 +46,7 @@ export function useAdminUsers() {
status: '启用',
twoFactor: '未开启',
createdAt: new Date().toLocaleString('sv-SE'),
lastLogin: '—',
lastLogin: '—'
})
Object.assign(form, { username: '', name: '', role: '', ipWhitelist: '' })
dialogVisible.value = false
@@ -41,5 +54,18 @@ export function useAdminUsers() {
ElMessage.success('管理员已添加')
}
return { keyword, roleFilter, currentPage, pageSize, dialogVisible, form, users, filtered, paginated, queryUsers, toggleStatus, addUser }
return {
keyword,
roleFilter,
currentPage,
pageSize,
dialogVisible,
form,
users,
filtered,
paginated,
queryUsers,
toggleStatus,
addUser
}
}
+60 -5
View File
@@ -1,7 +1,62 @@
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' },
{
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'
}
]