feat: 搭建后台管理框架
This commit is contained in:
1 parent
6d28bab72b
commit
40546bfd01
238 files changed
+18160
-3837
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
import LoginForm from './LoginForm.vue'
|
||||
import RegisterForm from './RegisterForm.vue'
|
||||
|
||||
export { LoginForm, RegisterForm }
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,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>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
Reference in new issue
Block a user