feat: 用户管理和数字人授权

This commit is contained in:
2024-08-20 03:36:25 +08:00
parent 777aecd1cb
commit 2bf4bfad81
17 changed files with 989 additions and 464 deletions

View File

@@ -0,0 +1,414 @@
<script lang="ts" setup>
import ModalDigitalHumanSelect from '~/components/ModalDigitalHumanSelect.vue'
const toast = useToast()
const loginState = useLoginState()
const columns = [
{
key: 'id',
label: 'ID',
disabled: true,
},
{
key: 'avatar',
label: '头像',
disabled: true,
},
{
key: 'username',
label: '用户名',
disabled: true,
},
{
key: 'company',
label: '单位',
},
{
key: 'mobile',
label: '手机号',
},
{
key: 'email',
label: '电子邮箱',
},
{
key: 'nickname',
label: '昵称',
},
{
key: 'sex',
label: '性别',
},
{
key: 'auth_code',
label: '角色',
},
{
key: 'actions',
label: '操作',
},
]
const selectedColumns = ref([...columns.filter(row => {
return !['nickname', 'email', 'auth_code'].includes(row.key)
})])
const page = ref(1)
const pageCount = ref(15)
const is_verified = ref(true)
const viewingUser = ref<UserSchema | null>(null)
const isSlideOpen = computed({
get: () => !!viewingUser.value,
set: () => viewingUser.value = null,
})
watch([is_verified, pageCount], () => page.value = 1)
const {
data: usersData,
refresh: refreshUsersData,
status: usersDataStatus,
} = useAsyncData(
'systemUsers',
() => useFetchWrapped<
req.user.UserList & AuthedRequest,
BaseResponse<PagedData<UserSchema>>
>('App.User_User.ListUser', {
token: loginState.token!,
user_id: loginState.user.id!,
page: page.value,
perpage: pageCount.value,
is_verify: is_verified.value,
}),
{
watch: [page, pageCount, is_verified],
},
)
const isDigitalSelectorOpen = ref(false)
const dhPage = ref(1)
const dhPageCount = ref(10)
watch(dhPageCount, () => dhPage.value = 1)
const {
data: digitalHumansData,
refresh: refreshDigitalHumansData,
status: digitalHumansDataStatus,
} = useAsyncData(
'currentUserDigitalHumans',
() => useFetchWrapped<
PagedDataRequest & AuthedRequest,
BaseResponse<PagedData<DigitalHumanItem>>
>('App.User_UserDigital.GetList', {
token: loginState.token!,
user_id: loginState.user.id!,
to_user_id: viewingUser.value?.id || 0,
page: dhPage.value,
perpage: dhPageCount.value,
}),
{
watch: [viewingUser, dhPage, dhPageCount],
},
)
const items = (row: UserSchema) => [
[
{
label: '数字人授权',
icon: 'tabler:user-cog',
click: () => openSlide(row),
disabled: row.auth_code === 0,
},
],
[
{
label: row.auth_code !== 0 ? '停用账号' : '启用账号',
icon: row.auth_code !== 0 ? 'tabler:cancel' : 'tabler:shield-check',
click: () => setUserStatus(row.id, row.auth_code === 0),
},
],
]
const openSlide = (user: UserSchema) => {
viewingUser.value = user
}
const closeSlide = () => {
viewingUser.value = null
}
const onDigitalHumansSelected = (digitalHumans: DigitalHumanItem[]) => {
useFetchWrapped<
{
to_user_id: number
digital_human_array: number[]
} & AuthedRequest,
BaseResponse<{
total: number
success: number
failed: number
}>
>('App.User_UserDigital.CreateConnArr', {
token: loginState.token!,
user_id: loginState.user.id!,
to_user_id: viewingUser.value?.id || 0,
digital_human_array: digitalHumans.map(row => row.id || row.digital_human_id),
}).then(res => {
if (res.ret === 200) {
toast.add({
title: '授权成功',
description: `成功授权 ${ res.data.success } 个数字人${ res.data.failed ? `,失败 ${ res.data.failed }` : '' }`,
color: 'green',
icon: 'tabler:check',
})
refreshDigitalHumansData()
} else {
toast.add({
title: '授权失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'tabler:alert-triangle',
})
}
})
}
const revokeDigitalHuman = (uid: number, digitalHumanId: number) => {
useFetchWrapped<
{
to_user_id: number
digital_human_id: number
} & AuthedRequest,
BaseResponse<{
code: number // 1: success, 0: failed
}>
>('App.User_UserDigital.DeleteConn', {
token: loginState.token!,
user_id: loginState.user.id!,
to_user_id: uid,
digital_human_id: digitalHumanId,
}).then(res => {
if (res.ret === 200 && res.data.code === 1) {
toast.add({
title: '撤销成功',
description: '已撤销数字人授权',
color: 'green',
icon: 'tabler:check',
})
refreshDigitalHumansData()
} else {
toast.add({
title: '撤销失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'tabler:alert-triangle',
})
}
})
}
const setUserStatus = (uid: number, is_verified: boolean) => {
useFetchWrapped<
{
to_user_id: number
is_verify: boolean
} & AuthedRequest,
BaseResponse<{
status: number // 1: success, 0: failed
}>
>('App.User_User.SetUserVerify', {
token: loginState.token!,
user_id: loginState.user.id!,
to_user_id: uid,
is_verify: is_verified,
}).then(res => {
if (res.ret === 200 && res.data.status === 1) {
toast.add({
title: '操作成功',
description: `${ is_verified ? '启用' : '停用' }账号`,
color: 'green',
icon: is_verified ? 'tabler:shield-check' : 'tabler:cancel',
})
refreshUsersData()
} else {
toast.add({
title: '操作失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'tabler:alert-triangle',
})
}
})
}
</script>
<template>
<LoginNeededContent need-admin>
<div class="p-4">
<BubbleTitle bubble-color="amber-500" subtitle="User Management" title="用户管理">
<template #action>
<UButton
color="amber"
icon="tabler:users"
variant="soft"
@click="openSlide(loginState.user)"
>
本账号数字人
</UButton>
<UButton
color="amber"
icon="tabler:reload"
variant="soft"
@click="refreshUsersData"
>
刷新
</UButton>
</template>
</BubbleTitle>
<GradientDivider line-gradient-from="amber" line-gradient-to="amber"/>
<div>
<div class="flex justify-between items-center w-full py-3">
<div class="flex items-center gap-1.5">
<span class="text-sm leading-0">每页显示:</span>
<USelect
v-model="pageCount"
:options="[5, 10, 15, 20]"
class="me-2 w-20"
size="xs"
/>
</div>
<div class="flex gap-1.5 items-center">
<USelectMenu
v-model="is_verified"
:options="[
{label: '正常账号', value: true, icon: 'tabler:user-check'},
{label: '停用账号', value: false, icon: 'tabler:user-cancel'},
]"
:ui-menu="{width: 'w-fit', option: {size: 'text-xs', icon: {base: 'w-4 h-4'}}}"
size="xs"
value-attribute="value"
/>
<USelectMenu
v-model="selectedColumns"
:options="columns.filter(row => !['actions'].includes(row.key))"
:ui-menu="{width: 'w-fit', option: {size: 'text-xs', icon: {base: 'w-4 h-4'}}}"
multiple
>
<UButton
color="gray"
icon="tabler:layout-columns"
size="xs"
>
显示列
</UButton>
</USelectMenu>
</div>
</div>
<UTable
:columns="selectedColumns"
:loading="usersDataStatus === 'pending'"
:progress="{color: 'amber', animation: 'carousel'}"
:rows="usersData?.data.items"
class="border dark:border-neutral-800 rounded-md"
>
<template #avatar-data="{ row }">
<UAvatar :alt="row.username.toUpperCase()" :src="row.avatar" size="sm"/>
</template>
<template #sex-data="{ row }">
{{ row.sex === 0 ? '' : row.sex === 1 ? '男' : '女' }}
</template>
<template #actions-data="{ row }">
<UDropdown :items="items(row)">
<UButton color="gray" icon="tabler:dots" variant="ghost"/>
</UDropdown>
</template>
</UTable>
<div class="flex justify-end py-3.5">
<UPagination
v-if="(usersData?.data.total || -1) > 0"
v-model="page"
:page-count="pageCount"
:total="usersData?.data.total || 0"
/>
</div>
</div>
</div>
<USlideover v-model="isSlideOpen">
<UCard
:ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }"
class="flex flex-col flex-1"
>
<template #header>
<UButton
class="flex absolute end-5 top-5 z-10"
color="gray"
icon="tabler:x"
padded
size="sm"
square
variant="ghost"
@click="closeSlide"
/>
数字人授权管理
<p class="text-sm font-medium text-primary">{{ viewingUser?.username }} (UID:{{ viewingUser?.id }})</p>
</template>
<div class="flex w-full justify-end pb-4">
<UButton
icon="tabler:plus"
size="xs"
@click="isDigitalSelectorOpen = true"
>
新增授权
</UButton>
</div>
<div class="border dark:border-neutral-700 rounded-md">
<UTable
:columns="[
{key: 'name', label: '名称'},
{key: 'digital_human_id', label: '平台ID'},
{key: 'model_id', label: '上游ID'},
{key: 'actions'},
]"
:loading="digitalHumansDataStatus === 'pending'"
:rows="digitalHumansData?.data.items"
>
<template #actions-data="{ row }">
<UButton
color="gray"
icon="tabler:cancel"
size="xs"
variant="ghost"
@click="revokeDigitalHuman(viewingUser?.id || 0, row.digital_human_id)"
>
撤销授权
</UButton>
</template>
</UTable>
</div>
<div class="flex justify-end py-3.5">
<UPagination
v-if="(digitalHumansData?.data.total || -1) > 0"
v-model="dhPage"
:page-count="dhPageCount"
:total="digitalHumansData?.data.total || 0"
/>
</div>
</UCard>
<ModalDigitalHumanSelect
:disabled-digital-human-ids="digitalHumansData?.data.items.map(d => d.model_id)"
:is-open="isDigitalSelectorOpen"
default-tab="system"
multiple
@close="isDigitalSelectorOpen = false"
@select="digitalHumans => onDigitalHumansSelected(digitalHumans as DigitalHumanItem[])"
/>
</USlideover>
</LoginNeededContent>
</template>
<style scoped>
</style>

151
pages/generation/course.vue Normal file
View File

@@ -0,0 +1,151 @@
<script lang="ts" setup>
import CGTaskCard from '~/components/aigc/generation/CGTaskCard.vue'
import ModalAuthentication from '~/components/ModalAuthentication.vue'
import SlideCreateCourse from '~/components/SlideCreateCourse.vue'
import { useFetchWrapped } from '~/composables/useFetchWrapped'
const toast = useToast()
const modal = useModal()
const slide = useSlideover()
const loginState = useLoginState()
const deletePending = ref(false)
const page = ref(1)
const {
data: courseList,
refresh: refreshCourseList,
} = useAsyncData(
() => useFetchWrapped<
req.gen.CourseGenList & AuthedRequest,
BaseResponse<PagedData<resp.gen.CourseGenItem>>
>('App.Digital_Convert.GetList', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: loginState.user.id,
page: page.value,
perpage: 16,
}), {
watch: [page],
},
)
const onCreateCourseClick = () => {
slide.open(SlideCreateCourse, {
onSuccess: () => {
refreshCourseList()
},
})
}
const onCourseDelete = (task_id: string) => {
if (!task_id) return
deletePending.value = true
useFetchWrapped<
req.gen.CourseGenDelete & AuthedRequest,
BaseResponse<resp.gen.CourseGenDelete>
>('App.Digital_Convert.Delete', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: loginState.user.id,
task_id,
}).then(res => {
if (res.ret === 200) {
toast.add({
title: '删除成功',
description: '已删除任务记录',
color: 'green',
icon: 'i-tabler-check',
})
} else {
toast.add({
title: '删除失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
}).finally(() => {
deletePending.value = false
refreshCourseList()
})
}
const beforeLeave = (el: any) => {
el.style.width = `${ el.offsetWidth }px`
el.style.height = `${ el.offsetHeight }px`
}
const leave = (el: any, done: Function) => {
el.style.position = 'absolute'
el.style.transition = 'none' // 取消过渡动画
el.style.opacity = 0 // 立即隐藏元素
done()
}
onMounted(() => {
const i = setInterval(refreshCourseList, 1000 * 5)
onBeforeUnmount(() => clearInterval(i))
})
</script>
<template>
<div>
<div class="p-4 pb-0">
<BubbleTitle subtitle="VIDEOS" title="我的微课视频">
<template #action>
<UButton
:trailing="false"
color="primary"
icon="i-tabler-plus"
label="新建微课"
size="md"
variant="solid"
@click="() => {
if (!loginState.is_logged_in) {
modal.open(ModalAuthentication)
return
}
onCreateCourseClick()
}"
/>
</template>
</BubbleTitle>
<GradientDivider/>
</div>
<Transition name="loading-screen">
<div
v-if="courseList?.data.items.length === 0"
class="w-full py-20 flex flex-col justify-center items-center gap-2"
>
<Icon class="text-7xl text-neutral-300 dark:text-neutral-700" name="i-tabler-photo-hexagon"/>
<p class="text-sm text-neutral-500 dark:text-neutral-400">
没有记录
</p>
</div>
<div v-else class="p-4">
<div class="relative grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 fhd:grid-cols-5 gap-4">
<TransitionGroup
name="card"
@beforeLeave="beforeLeave"
@leave="leave"
>
<CGTaskCard
v-for="(course, index) in courseList?.data.items"
:key="course.task_id || 'unknown' + index"
:course="course"
@delete="task_id => onCourseDelete(task_id)"
/>
</TransitionGroup>
</div>
<div class="flex justify-end mt-4">
<UPagination v-model="page" :max="9" :page-count="16" :total="courseList?.data.total || 0"/>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,207 @@
<script lang="ts" setup>
import { useFetchWrapped } from '~/composables/useFetchWrapped'
import GBTaskCard from '~/components/aigc/generation/GBTaskCard.vue'
import { useTourState } from '~/composables/useTourState'
import SlideCreateCourseGreen from '~/components/SlideCreateCourseGreen.vue'
const route = useRoute()
const slide = useSlideover()
const toast = useToast()
const loginState = useLoginState()
const tourState = useTourState()
const page = ref(1)
const pageCount = ref(15)
const searchInput = ref('')
const debounceSearch = refDebounced(searchInput, 1000)
watch(debounceSearch, () => page.value = 1)
const {
data: videoList,
refresh: refreshVideoList,
} = useAsyncData(
() => useFetchWrapped<
req.gen.GBVideoList & AuthedRequest,
BaseResponse<PagedData<GBVideoItem>>
>('App.Digital_VideoTask.GetList', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: loginState.user.id,
page: page.value,
perpage: pageCount.value,
title: debounceSearch.value,
}), {
watch: [page, pageCount, debounceSearch],
},
)
const onCreateCourseGreenClick = () => {
slide.open(SlideCreateCourseGreen, {
onSuccess: () => {
refreshVideoList()
},
})
}
const onCourseGreenDelete = (task: GBVideoItem) => {
if (!task.task_id) return
useFetchWrapped<
req.gen.GBVideoDelete & AuthedRequest,
BaseResponse<resp.gen.GBVideoDelete>
>('App.Digital_VideoTask.Delete', {
token: loginState.token!,
user_id: loginState.user.id,
task_id: task.task_id,
}).then(res => {
if (res.data.code === 1) {
refreshVideoList()
toast.add({
title: '删除成功',
description: '已删除任务记录',
color: 'green',
icon: 'i-tabler-check',
})
} else {
toast.add({
title: '删除失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
})
}
const beforeLeave = (el: any) => {
el.style.width = `${ el.offsetWidth }px`
el.style.height = `${ el.offsetHeight }px`
}
const leave = (el: any, done: Function) => {
el.style.position = 'absolute'
el.style.transition = 'none' // 取消过渡动画
el.style.opacity = 0 // 立即隐藏元素
done()
}
onMounted(() => {
const i = setInterval(refreshVideoList, 1000 * 5)
onBeforeUnmount(() => clearInterval(i))
const driver = useDriver({
showProgress: true,
animate: true,
smoothScroll: true,
disableActiveInteraction: true,
popoverOffset: 12,
progressText: '{{current}} / {{total}}',
prevBtnText: '上一步',
nextBtnText: '下一步',
doneBtnText: '完成',
steps: [
{
element: '#button-create',
popover: {
title: '新建视频',
description: '点击这里开始新建绿幕视频',
},
},
{
element: '#input-search',
popover: {
title: '搜索生成记录',
description: '在这里输入视频标题,可以搜索符合条件的生成记录',
},
},
],
})
tourState.autoDriveTour(route.fullPath, driver)
})
</script>
<template>
<div class="h-full">
<div class="p-4 pb-0">
<BubbleTitle
:subtitle="!debounceSearch ? 'GB VIDEOS' : 'SEARCH...'"
:title="!debounceSearch ? '我的绿幕视频' : `标题搜索:${debounceSearch.toLocaleUpperCase()}`"
>
<template #action>
<UButtonGroup size="md">
<UInput
id="input-search"
v-model="searchInput"
:autofocus="false"
:ui="{ icon: { trailing: { pointer: '' } } }"
autocomplete="off"
placeholder="标题搜索"
variant="outline"
>
<template #trailing>
<UButton
v-show="searchInput !== ''"
:padded="false"
color="gray"
icon="i-tabler-x"
variant="link"
@click="searchInput = ''"
/>
</template>
</UInput>
</UButtonGroup>
<UButton
id="button-create"
:trailing="false"
color="primary"
icon="i-tabler-plus"
label="新建"
size="md"
variant="solid"
@click="onCreateCourseGreenClick"
/>
</template>
</BubbleTitle>
<GradientDivider/>
</div>
<Transition name="loading-screen">
<div
v-if="videoList?.data.items.length === 0"
class="w-full py-20 flex flex-col justify-center items-center gap-2"
>
<Icon class="text-7xl text-neutral-300 dark:text-neutral-700" name="i-tabler-photo-hexagon"/>
<p class="text-sm text-neutral-500 dark:text-neutral-400">
没有记录
</p>
</div>
<div v-else>
<div class="p-4">
<div class="relative grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3 fhd:grid-cols-5 gap-4">
<TransitionGroup
name="card"
@beforeLeave="beforeLeave"
@leave="leave"
>
<GBTaskCard
v-for="(v, i) in videoList?.data.items"
:key="v.task_id"
:video="v"
@delete="v => onCourseGreenDelete(v)"
/>
</TransitionGroup>
</div>
<div class="flex justify-end mt-4">
<UPagination v-model="page" :max="9" :page-count="pageCount" :total="videoList?.data.total || 0"/>
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
</style>