提交 e0e5fd07 authored 作者: 王鹏飞's avatar 王鹏飞

feat: 新增bbs

上级 4e345fb5
差异被折叠。
...@@ -14,18 +14,18 @@ ...@@ -14,18 +14,18 @@
"cert": "node ./cert.js" "cert": "node ./cert.js"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.0.6", "@element-plus/icons-vue": "^2.0.10",
"@tinymce/tinymce-vue": "^5.0.0", "@tinymce/tinymce-vue": "^5.0.0",
"@types/ua-parser-js": "^0.7.36", "@types/ua-parser-js": "^0.7.36",
"@vueuse/core": "^9.0.2", "@vueuse/core": "^9.0.2",
"axios": "^0.27.2", "axios": "^0.27.2",
"blueimp-md5": "^2.19.0", "blueimp-md5": "^2.19.0",
"dayjs": "^1.11.4", "dayjs": "^1.11.4",
"element-plus": "^2.2.12", "element-plus": "^2.2.18",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"format-duration": "^2.0.0", "format-duration": "^2.0.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"pinia": "^2.0.17", "pinia": "^2.0.23",
"qs": "^6.11.0", "qs": "^6.11.0",
"swiper": "^8.3.2", "swiper": "^8.3.2",
"ua-parser-js": "^1.0.2", "ua-parser-js": "^1.0.2",
......
...@@ -75,3 +75,13 @@ export function collectionResource(data: { ...@@ -75,3 +75,13 @@ export function collectionResource(data: {
}) { }) {
return httpRequest.post('/api/learn/api/v1/collection/resource', data) return httpRequest.post('/api/learn/api/v1/collection/resource', data)
} }
// 获取所有课程列表
export function getCourseList() {
return httpRequest.get('/api/learn/api/v1/course/all')
}
// 获取课程章节列表
export function getCourseChapterList(params: { course_id: string }) {
return httpRequest.get('/api/learn/api/v1/chapter/all', { params })
}
...@@ -97,3 +97,7 @@ textarea:focus { ...@@ -97,3 +97,7 @@ textarea:focus {
.el-tabs__nav-wrap::after { .el-tabs__nav-wrap::after {
height: 1px !important; height: 1px !important;
} }
body {
font-size: 14px;
}
...@@ -8,26 +8,24 @@ interface IRemoteProps { ...@@ -8,26 +8,24 @@ interface IRemoteProps {
callback?: any callback?: any
} }
const props = withDefaults( interface Props {
defineProps<{
remote?: IRemoteProps remote?: IRemoteProps
filters?: any[] filters?: any[]
moreFilters?: any[] filterForm?: any
columns?: any[] columns?: any[]
data?: any[] data?: any[]
hasPagination?: boolean hasPagination?: boolean
limit?: number limit?: number
isLimit?: boolean hasFilterButton?: boolean
}>(), }
{ const props = withDefaults(defineProps<Props>(), {
isLimit: false,
hasPagination: true, hasPagination: true,
hasFilterButton: true,
limit: 10, limit: 10,
data() { filters: () => [],
return [] columns: () => [],
} data: () => []
} })
)
const filterFormRef = ref() const filterFormRef = ref()
const loading = ref(false) const loading = ref(false)
...@@ -36,13 +34,12 @@ const dataList = ref<any[]>([]) ...@@ -36,13 +34,12 @@ const dataList = ref<any[]>([])
const page = reactive({ total: 0, size: props.limit, currentPage: 1 }) const page = reactive({ total: 0, size: props.limit, currentPage: 1 })
const params = reactive({ ...props.remote?.params }) const params = reactive({ ...props.remote?.params })
watch( watchEffect(() => {
() => props.data, Object.assign(params, props.remote?.params)
list => { })
dataList.value = list || [] watchEffect(() => {
}, dataList.value = props.data || []
{ immediate: true } })
)
// 获取数据 // 获取数据
const fetchList = (isReset = false) => { const fetchList = (isReset = false) => {
...@@ -60,19 +57,15 @@ const fetchList = (isReset = false) => { ...@@ -60,19 +57,15 @@ const fetchList = (isReset = false) => {
// 翻页参数设置 // 翻页参数设置
if (props.hasPagination) { if (props.hasPagination) {
requestParams.page = page.currentPage requestParams.page = page.currentPage
if (props.isLimit === true) {
requestParams.limit = page.size requestParams.limit = page.size
} else {
requestParams['per-page'] = page.size
}
} }
// 接口请求之前 // 接口请求之前
if (beforeRequest) { if (beforeRequest) {
requestParams = beforeRequest(requestParams, isReset) requestParams = beforeRequest(requestParams, isReset)
} }
for (const key in params) { for (const key in requestParams) {
if (params[key] === '' || params[key] === undefined || params[key] === undefined) { if (requestParams[key] === '' || requestParams[key] === undefined || requestParams[key] === undefined) {
delete params[key] delete requestParams[key]
} }
} }
loading.value = true loading.value = true
...@@ -131,9 +124,10 @@ defineExpose({ refetch, tableRef }) ...@@ -131,9 +124,10 @@ defineExpose({ refetch, tableRef })
<template> <template>
<div class="table-list"> <div class="table-list">
<div class="table-list-hd"> <div class="table-list-hd">
<slot name="header-prepend" />
<!-- 筛选 --> <!-- 筛选 -->
<div class="table-list-filter" v-if="filters && filters.length"> <div class="table-list-filter" v-if="filters && filters.length">
<el-form :inline="true" :model="params" ref="filterFormRef" @submit.prevent> <el-form :inline="true" :model="params" v-bind="filterForm" ref="filterFormRef" @submit.prevent>
<template v-for="item in filters" :key="item.prop"> <template v-for="item in filters" :key="item.prop">
<el-form-item :label="item.label" :prop="item.prop"> <el-form-item :label="item.label" :prop="item.prop">
<template v-if="item.slots"> <template v-if="item.slots">
...@@ -147,47 +141,44 @@ defineExpose({ refetch, tableRef }) ...@@ -147,47 +141,44 @@ defineExpose({ refetch, tableRef })
clearable clearable
@change="search" @change="search"
style="width: 200px" style="width: 200px"
v-if="item.type === 'input'" v-if="item.type === 'input'" />
/>
<!-- select --> <!-- select -->
<el-select <el-select
v-model="params[item.prop]" v-model="params[item.prop]"
v-bind="item" v-bind="item"
clearable filterable
@change="search" @change="search"
v-if="item.type === 'select'" v-if="item.type === 'select'">
>
<el-option <el-option
:label="option[item.labelKey] || option.label" :label="option[item.labelKey] || option.label"
:value="option[item.valueKey] || option.value" :value="option[item.valueKey] || option.value"
v-for="(option, index) in item.options" v-for="(option, index) in item.options"
:key="index" :key="index" />
/>
</el-select> </el-select>
</template> </template>
</el-form-item> </el-form-item>
</template> </template>
<el-form-item class="filter-buttons"> <el-form-item class="filter-buttons" v-if="hasFilterButton">
<el-button type="primary" :icon="Search" @click="search">搜索</el-button> <el-button type="primary" :icon="Search" @click="search">搜索</el-button>
<el-button :icon="RefreshLeft" @click="reset">重置</el-button> <el-button :icon="RefreshLeft" @click="reset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</div> </div>
<div class="table-list-hd-aside"><slot name="header-aside" /></div> <slot name="header-append" />
</div> </div>
<div class="table-list-buttons"><slot name="header-buttons"></slot></div>
<slot></slot> <slot></slot>
<!-- 主体 --> <!-- 主体 -->
<div class="table-list-bd"> <div class="table-list-bd">
<slot name="body" v-bind="{ data: dataList }"> <slot name="body" v-bind="{ data: dataList }">
<el-table <el-table
stripe
:header-cell-style="{ background: '#ededed' }"
:data="dataList" :data="dataList"
v-loading="loading" v-loading="loading"
v-bind="$attrs" v-bind="$attrs"
style="height: 100%" ref="tableRef">
ref="tableRef" <el-table-column align="center" v-bind="item || {}" v-for="item in columns" :key="item.prop">
:header-cell-style="{ background: '#EFEFEF' }"
>
<el-table-column v-bind="item || {}" v-for="item in columns" :key="item.prop">
<template #default="scope" v-if="item.slots || item.computed"> <template #default="scope" v-if="item.slots || item.computed">
<slot :name="item.slots" v-bind="scope" v-if="item.slots"></slot> <slot :name="item.slots" v-bind="scope" v-if="item.slots"></slot>
<div v-html="item.computed(scope)" v-if="item.computed"></div> <div v-html="item.computed(scope)" v-if="item.computed"></div>
...@@ -212,8 +203,7 @@ defineExpose({ refetch, tableRef }) ...@@ -212,8 +203,7 @@ defineExpose({ refetch, tableRef })
@size-change="pageSizeChange" @size-change="pageSizeChange"
@current-change="fetchList()" @current-change="fetchList()"
:hide-on-single-page="true" :hide-on-single-page="true"
v-if="hasPagination" v-if="hasPagination">
>
</el-pagination> </el-pagination>
</div> </div>
</div> </div>
...@@ -229,10 +219,22 @@ defineExpose({ refetch, tableRef }) ...@@ -229,10 +219,22 @@ defineExpose({ refetch, tableRef })
.table-list-hd { .table-list-hd {
display: flex; display: flex;
margin-bottom: 10px; margin-bottom: 20px;
&:empty {
display: none;
}
} }
.table-list-filter { .table-list-filter {
flex: 1; flex: 1;
// padding: 30px 30px 10px;
// background: #f8f8f8;
// border-radius: 12px;
}
.table-list-buttons {
margin-bottom: 20px;
&:empty {
display: none;
}
} }
// .table-list-bd { // .table-list-bd {
// flex: 1; // flex: 1;
...@@ -249,4 +251,8 @@ defineExpose({ refetch, tableRef }) ...@@ -249,4 +251,8 @@ defineExpose({ refetch, tableRef })
.el-table-column--selection .cell { .el-table-column--selection .cell {
padding: 0 14px !important; padding: 0 14px !important;
} }
.el-button a {
margin: -8px -15px;
padding: 8px 15px;
}
</style> </style>
import { getCourseList, getCourseChapterList } from '@/api/base'
import { val } from 'dom7'
interface Semester {
id: string
name: string
}
interface Course {
id: string
course_id: string
name: string
cover: string
course_alias_name: string
semester: Semester
}
interface Chapter {
id: string
name: string
}
export function useGetCourseList() {
const courseId = ref('')
const courses = ref<Course[]>([])
const chapters = ref<Chapter[]>([])
// 获取课程列表
function getCourses() {
getCourseList().then(res => {
courses.value = res.data.items
})
}
// 获取章节列表
function getChapters() {
if (!courseId.value) {
chapters.value = []
return
}
getCourseChapterList({ course_id: courseId.value }).then(res => {
chapters.value = res.data.items
})
}
getCourses()
watch(courseId, () => {
getChapters()
})
return { courses, courseId, chapters }
}
import httpRequest from '@/utils/axios'
// 获取帖子列表
export function getPostList(params: {
search_by: number
order_by: number
course_id?: string
semester_id?: string
chapter_id?: string
type?: string
page?: number
limit?: number
}) {
return httpRequest.get('/api/learn/api/v1/discussions', { params })
}
// 获取置顶帖子列表
export function getTopPostList(params?: { course_id?: string; semester_id?: string }) {
return httpRequest.get('/api/learn/api/v1/discussion/top-list', { params })
}
// 获取帖子详情
export function getPostAndDiscussList(params: { id: string }) {
return httpRequest.get(`/api/learn/api/v1/discussion/${params.id}/detail-list`, { params })
}
// 获取回复列表
export function getCommitList(params: { floor_id: string; page?: number; limit?: number }) {
return httpRequest.get(`/api/learn/api/v1/discussion/reply-list/${params.floor_id}`, { params })
}
// 创建帖子
export function createPost(data: {
course_id: string
semester_id: string
chapter_id: string
type: string
title: string
content: string
files: string
}) {
return httpRequest.post('/api/learn/api/v1/discussion/post', data, {
headers: { 'Content-Type': 'application/json' }
})
}
// 回复帖子
export function replyToPost(data: {
id: string
reply_type: number
content: string
files?: string
floor_id?: string
reply_id?: string
}) {
return httpRequest.post(`/api/learn/api/v1/discussion/${data.id}/reply`, data, {
headers: { 'Content-Type': 'application/json' }
})
}
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import AppUpload from '@/components/base/AppUpload.vue'
import AppEditor from '@/components/base/AppEditor.vue'
import { replyToPost } from '../api'
interface Props {
id: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update'): void
(e: 'update:modelValue', visible: boolean): void
}>()
const formRef = $ref<FormInstance>()
const form = reactive({
id: props.id,
reply_type: 1,
content: '',
files: []
})
const rules = ref<FormRules>({
content: [{ required: true, message: '请输入回复内容', trigger: 'blur' }]
})
// 提交
function handleSubmit() {
formRef?.validate().then(create)
}
// 修改
const create = () => {
const params = Object.assign({}, form, { files: form.files.length ? JSON.stringify(form.files) : '' })
replyToPost(params).then(() => {
ElMessage({ message: '发布成功', type: 'success' })
emit('update')
emit('update:modelValue', false)
})
}
</script>
<template>
<el-dialog title="发表回复" width="800px" @update:modelValue="$emit('update:modelValue')">
<el-form ref="formRef" :model="form" :rules="rules" hide-required-asterisk label-position="top">
<el-form-item prop="content">
<AppEditor v-model="form.content" :height="300" />
</el-form-item>
<el-form-item prop="files">
<AppUpload v-model="form.files">
<el-button size="default">上传图片/视频附件</el-button>
<template #tip
>支持最多上传10张图片,格式支持jpg,jpeg,png,2MB以内<br />视频最多上传1个,100Mb以内
</template>
</AppUpload>
</el-form-item>
<el-form-item>
<el-button type="primary" auto-insert-space @click="handleSubmit">发表</el-button>
</el-form-item>
</el-form>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import type { DiscussItem } from '../types'
import { ElMessage } from 'element-plus'
import DiscussItemCommentList from './DiscussItemCommentList.vue'
import FileItem from './FileItem.vue'
import { replyToPost } from '../api'
interface Props {
data: DiscussItem
}
const props = defineProps<Props>()
const username = $computed(() => {
const user = props.data.sso_user
return user.realname || user.nickname || user.username
})
const formVisible = $ref(false)
const formRef = $ref<FormInstance>()
const form = reactive({
content: '',
files: []
})
const rules = ref<FormRules>({
content: [{ required: true, message: '请输入回复内容', trigger: 'blur' }]
})
// 发布回复
function handleSubmit() {
formRef?.validate().then(() => {
replyToPost({
id: props.data.discussion_id,
reply_type: 2,
floor_id: props.data.id,
reply_id: props.data.id,
content: form.content
}).then(() => {
ElMessage({ message: '回复成功', type: 'success' })
})
})
}
</script>
<template>
<section class="discuss-item">
<div class="discuss-item__left">
<div class="discuss-item__avatar">
<img :src="data.sso_user.avatar || 'https://webapp-pub.ezijing.com/website/base/images/default.jpg'" />
</div>
<p class="discuss-item__username">{{ username }}</p>
</div>
<div class="discuss-item__right">
<div class="discuss-item__main">
<div class="discuss-item__content" v-html="data.content"></div>
<ul class="discuss-item__files" v-if="data.files.length">
<li v-for="(item, index) in data.files" :key="index"><FileItem :data="item" /></li>
</ul>
</div>
<p class="discuss-item__time">{{ data.updated_time }}</p>
<div class="discuss-item__comment"><p @click="formVisible = !formVisible">回复</p></div>
<div class="discuss-item-form" v-if="formVisible">
<p class="discuss-item-form__title">回复本楼</p>
<el-form ref="formRef" :model="form" :rules="rules" hide-required-asterisk>
<el-form-item prop="content">
<el-input type="textarea" :autosize="{ minRows: 6, maxRows: 6 }" v-model="form.content" />
</el-form-item>
<el-row justify="end">
<el-button round type="primary" @click="handleSubmit">发表回复</el-button>
</el-row>
</el-form>
</div>
<DiscussItemCommentList :data="data" />
</div>
</section>
</template>
<style lang="scss">
.discuss-item {
display: flex;
border-bottom: 4px solid #e8e8e8;
}
.discuss-item__left {
width: 240px;
padding: 40px;
text-align: center;
background-color: #f9f9fc;
box-sizing: border-box;
}
.discuss-item__avatar {
width: 100px;
height: 100px;
margin: 0 auto;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.discuss-item__username {
margin-top: 14px;
font-size: 16px;
color: #333;
}
.discuss-item__right {
flex: 1;
overflow: hidden;
padding: 40px;
> .discuss-item__comment {
margin-right: 20px;
}
}
.discuss-item__main {
min-height: 180px;
}
.discuss-item__content {
font-size: 16px;
color: #666;
}
.discuss-item__time {
margin-top: 20px;
font-size: 14px;
line-height: 1;
color: #b4b4b4;
}
.discuss-item__files {
margin-top: 20px;
display: flex;
}
.discuss-item__comment {
text-align: right;
p {
display: inline-block;
padding-left: 32px;
background: url(@/assets/images/icon_comment.png) no-repeat left center;
background-size: 22px;
font-size: 16px;
line-height: 28px;
color: #9b9b9b;
cursor: pointer;
}
}
.discuss-item-form__title {
font-size: 18px;
color: #333;
line-height: 40px;
}
</style>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import type { DiscussItem, DiscussCommentItem, User } from '../types'
import { ElMessage } from 'element-plus'
import { getCommitList, replyToPost } from '../api'
interface Props {
data: DiscussItem
}
const props = defineProps<Props>()
let page = $ref(1)
let list = $ref<DiscussCommentItem[]>([])
function fetchList() {
getCommitList({ floor_id: props.data.id, page }).then(res => {
list = page === 1 ? res.data.data : list.concat(res.data.data)
page++
})
}
function refetch() {
page = 1
fetchList()
}
// 加载更多
function loadMore() {
fetchList()
}
const currentList = $computed(() => {
if (list.length) return list
return props.data.child_replies
})
let activeComment = $ref<DiscussCommentItem>()
function handleReply(data: DiscussCommentItem) {
activeComment = data
formVisible = true
form.content = `回复@${getUsername(data.sso_user)}:`
}
let formVisible = $ref(false)
const formRef = $ref<FormInstance>()
const form = reactive({
content: '',
files: []
})
const rules = ref<FormRules>({
content: [{ required: true, message: '请输入回复内容', trigger: 'blur' }]
})
// 发布回复
function handleSubmit() {
formRef?.validate().then(() => {
replyToPost({
id: props.data.discussion_id,
reply_type: 2,
floor_id: activeComment.floor_id,
reply_id: activeComment.id,
content: form.content.replace(`回复@${getUsername(activeComment.sso_user)}:`, '')
}).then(() => {
refetch()
ElMessage({ message: '回复成功', type: 'success' })
})
})
}
function getUsername(data: User) {
return data.realname || data.username || data.nickname
}
</script>
<template>
<div class="discuss-comment-list">
<div class="discuss-comment-item" v-for="item in currentList" :key="item.id">
<img
:src="item.sso_user.avatar || 'https://webapp-pub.ezijing.com/website/base/images/default.jpg'"
class="discuss-comment-item__avatar" />
<div class="discuss-comment-item__main">
<div class="discuss-comment-item-hd">
{{ getUsername(item.sso_user) }}
<span v-if="item.reply_sso_user?.id">
回复 <em>{{ getUsername(item.reply_sso_user) }}</em
>
</span>
</div>
<div class="discuss-comment-item-bd">{{ item.content }}</div>
<div class="discuss-comment-item-ft">
<p>来自于{{ item.created_time }}</p>
<div class="discuss-item__comment" @click="handleReply(item)"><p>回复</p></div>
</div>
</div>
</div>
<p class="more"><span @click="loadMore">查看更多</span></p>
<div class="discuss-item-form" v-if="formVisible">
<el-form ref="formRef" :model="form" :rules="rules" hide-required-asterisk>
<el-form-item prop="content">
<el-input type="textarea" :autosize="{ minRows: 6, maxRows: 6 }" v-model="form.content" />
</el-form-item>
<el-row justify="end">
<el-button round type="primary" auto-insert-space @click="handleSubmit">发表</el-button>
</el-row>
</el-form>
</div>
</div>
</template>
<style lang="scss">
.discuss-comment-list {
margin-top: 20px;
padding: 20px;
background-color: #f4f5f8;
border-radius: 6px;
.discuss-item-form {
margin-left: 40px;
}
}
.discuss-comment-item {
display: flex;
margin-bottom: 24px;
}
.discuss-comment-item__main {
flex: 1;
margin-left: 10px;
}
.discuss-comment-item-hd {
font-size: 16px;
color: #666666;
em {
color: #3571e0;
}
}
.discuss-comment-item-bd {
margin-top: 12px;
font-size: 16px;
color: #666666;
}
.discuss-comment-item-ft {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
font-size: 12px;
color: #b4b4b4;
}
.discuss-comment-item__avatar {
width: 30px;
height: 30px;
border-radius: 50%;
overflow: hidden;
object-fit: cover;
}
.more {
padding: 10px;
line-height: 30px;
color: #3571e0;
text-align: center;
span {
cursor: pointer;
}
}
</style>
<script setup lang="ts">
import type { File } from '../types'
interface Props {
data: File
}
const props = defineProps<Props>()
const isVideo = $computed(() => {
return props.data.url.includes('.mp4')
})
</script>
<template>
<div class="file-item">
<video :src="data.url" controls v-if="isVideo"></video>
<img :src="data.url" v-else />
</div>
</template>
<style lang="scss">
.file-item {
img {
width: 160px;
height: 90px;
object-fit: cover;
}
video {
width: 400px;
}
}
</style>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import AppUpload from '@/components/base/AppUpload.vue'
import AppEditor from '@/components/base/AppEditor.vue'
import { createPost } from '../api'
import { useMapStore } from '@/stores/map'
import { useGetCourseList } from '@/composables/useGetCourseList'
const emit = defineEmits<{
(e: 'update'): void
(e: 'update:modelValue', visible: boolean): void
}>()
const { courses, courseId, chapters } = useGetCourseList()
const types = useMapStore().getMapValuesByKey('learning_discussion_type')
const formRef = $ref<FormInstance>()
const form = reactive({
semester_id: '',
course_id: '',
chapter_id: '',
type: '',
title: '',
content: '',
files: []
})
const rules = ref<FormRules>({
course_id: [{ required: true, message: '请选择课程', trigger: 'change' }],
chapter_id: [{ required: true, message: '请选择章节', trigger: 'change' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入正文内容', trigger: 'blur' }]
})
watchEffect(() => {
courseId.value = form.course_id
const course = courses.value.find(item => item.course_id === form.course_id)
form.semester_id = course ? course.semester.id : ''
})
// 提交
function handleSubmit() {
formRef?.validate().then(create)
}
// 修改
const create = () => {
const params = Object.assign({}, form, { files: form.files.length ? JSON.stringify(form.files) : '' })
createPost(params).then(() => {
ElMessage({ message: '发布成功', type: 'success' })
emit('update')
emit('update:modelValue', false)
})
}
</script>
<template>
<el-dialog title="发帖" width="800px" @update:modelValue="$emit('update:modelValue')">
<el-form ref="formRef" :model="form" :rules="rules" hide-required-asterisk label-position="top">
<el-row justify="space-between">
<el-form-item label="相关课程" prop="course_id">
<el-select filterable v-model="form.course_id">
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.course_id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="相关章节" prop="chapter_id">
<el-select filterable v-model="form.chapter_id">
<el-option v-for="item in chapters" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select filterable v-model="form.type">
<el-option v-for="item in types" :key="item.id" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
</el-row>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="正文内容" prop="content">
<AppEditor v-model="form.content" :height="300" />
</el-form-item>
<el-form-item prop="files">
<AppUpload v-model="form.files">
<el-button size="default">上传图片/视频附件</el-button>
<template #tip
>支持最多上传10张图片,格式支持jpg,jpeg,png,2MB以内<br />视频最多上传1个,100Mb以内
</template>
</AppUpload>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit">发布问题</el-button>
</el-form-item>
</el-form>
</el-dialog>
</template>
<script setup lang="ts">
import type { PostItem } from '../types'
import FileItem from './FileItem.vue'
import { useMapStore } from '@/stores/map'
interface Props {
data: PostItem
}
const props = defineProps<Props>()
const types = useMapStore().getMapValuesByKey('learning_discussion_type')
const typeText = $computed(() => {
return types.find(item => parseInt(item.value) === props.data.type)?.label || props.data.type
})
const typeColor = $computed(() => {
const map: Record<number, string> = { 1: '#396DD1', 2: '#6B9357', 3: '#E5A237' }
return map[props.data.type] || '#396DD1'
})
const username = $computed(() => {
const user = props.data.sso_user
return user.realname || user.nickname || user.username
})
</script>
<template>
<section class="post-item">
<div class="post-item-hd">
<p class="post-item__type" :class="`type-${data.type}`">{{ typeText }}</p>
<h2 class="post-item__title">{{ data.title }}</h2>
<p class="post-item__reply_count">{{ data.reply_count }}回帖</p>
</div>
<div class="post-item-bd">
<ul class="post-item__files" v-if="data.reply?.files.length">
<li v-for="(item, index) in data.reply.files" :key="index"><FileItem :data="item" /></li>
</ul>
</div>
<div class="post-item-ft">
<p class="post-item__username">{{ username }}</p>
<p class="post-item__time">{{ data.updated_time }}</p>
</div>
</section>
</template>
<style lang="scss">
.post-item {
padding: 30px 0;
border-bottom: 1px solid #e6e6e6;
}
.post-item-hd {
display: flex;
}
.post-item__type {
--type-color: v-bind(typeColor);
display: inline-block;
padding: 0 4px;
color: var(--type-color);
border: 1px solid var(--type-color);
height: 20px;
font-size: 14px;
line-height: 20px;
border-radius: 2px;
}
.post-item__title {
flex: 1;
margin: 0 14px;
font-size: 16px;
font-weight: 400;
line-height: 20px;
color: #666;
}
.post-item__reply_count {
font-size: 14px;
line-height: 20px;
color: #bcbcbc;
}
.post-item-bd {
margin-top: 16px;
}
.post-item-ft {
margin-top: 16px;
display: flex;
}
.post-item__username {
min-width: 130px;
font-size: 14px;
line-height: 1;
color: #bcbcbc;
}
.post-item__time {
font-size: 14px;
line-height: 1;
color: #bcbcbc;
}
.post-item__files {
display: flex;
}
</style>
<script setup lang="ts">
import type { TopPostItem } from '../types'
import { getTopPostList } from '../api'
let list = $ref<TopPostItem[]>([])
function fetchList() {
getTopPostList().then(res => {
list = res.data.list
})
}
onMounted(fetchList)
</script>
<template>
<section class="pined-post" v-if="list.length">
<section class="pined-post-item" v-for="item in list" :key="item.id">
<p class="t1">置顶</p>
<p class="t2">{{ item.title }}</p>
<p class="t3">
<span>{{ item.type }}</span>
</p>
<p class="t4">{{ item.updated_time }}</p>
</section>
</section>
</template>
<style lang="scss">
.pined-post {
padding-bottom: 16px;
border-bottom: 1px solid #e6e6e6;
}
.pined-post-item {
display: flex;
align-items: center;
padding: 14px 0;
.t1 {
padding: 0 4px;
height: 20px;
color: #fff;
font-size: 14px;
line-height: 20px;
text-align: center;
background: #d38846;
border-radius: 2px;
}
.t2 {
flex: 1;
margin: 0 14px;
font-size: 16px;
color: #666;
line-height: 30px;
}
.t3 {
min-width: 30%;
text-align: center;
span {
display: inline-block;
min-width: 90px;
height: 30px;
line-height: 30px;
color: #ba143e;
background: rgba(253, 235, 240, 0.39);
border: 1px solid #f296ac;
border-radius: 18px;
}
}
.t4 {
flex: 0 0 150px;
font-size: 14px;
color: #bcbcbc;
text-align: right;
}
}
</style>
...@@ -5,6 +5,9 @@ export const routes: Array<RouteRecordRaw> = [ ...@@ -5,6 +5,9 @@ export const routes: Array<RouteRecordRaw> = [
{ {
path: '/bbs', path: '/bbs',
component: AppLayout, component: AppLayout,
children: [{ path: '', component: () => import('./views/Index.vue') }] children: [
{ path: '', component: () => import('./views/Index.vue') },
{ path: ':id', component: () => import('./views/View.vue'), props: true }
]
} }
] ]
export interface Post {
id: string
sso_id: string
sso_type: number
title: string
semester_id: string
class_id: string
course_id: string
chapter_id: string
type: number
is_top: number
top_time: string
is_hot: number
pv: number
uv: number
reply_count: number
created_time: string
updated_time: string
delete_time: string
}
export interface PostItem extends Post {
reply: {
id: string
discussion_id: string
content: string
created_time: string
updated_time: string
files: File[]
}
sso_user: User
}
export type TopPostItem = Pick<Post, 'id' | 'title' | 'type' | 'created_time' | 'updated_time' | 'top_time'>
export interface DiscussItem {
id: string
discussion_id: string
sso_id: string
sso_type: 2
content: string
files: File[]
is_first: number
reply_type: number
floor_id: string
reply_id: string
reply_sso_id: string
reply_sso_type: number
created_time: string
updated_time: string
delete_time: string
sso_user: User
child_replies: DiscussCommentItem[]
}
export interface DiscussCommentItem {
id: string
discussion_id: string
sso_id: string
sso_type: number
content: string
files: File[]
is_first: number
reply_type: number
floor_id: string
reply_id: string
reply_sso_id: string
reply_sso_type: number
created_time: string
updated_time: string
delete_time: string
sso_user: User
reply_sso_user: User
}
export interface User {
id: string
username: string
nickname: string
realname: string
avatar: string
discussion_level: number
}
export interface File {
name: string
url: string
type: string
size: string
upload_time: string
}
<script setup lang="ts"></script> <script setup lang="ts">
import AppList from '@/components/base/AppList.vue'
import PostItem from '../components/PostItem.vue'
import PostPinned from '../components/PostPinned.vue'
import { bbsSearchByList, bbsOrderByList } from '@/utils/dictionary'
import { getPostList } from '../api'
import { useMapStore } from '@/stores/map'
import { useGetCourseList } from '@/composables/useGetCourseList'
<template></template> const PostForm = defineAsyncComponent(() => import('../components/PostForm.vue'))
const { courses, courseId, chapters } = useGetCourseList()
const courseList = $computed(() => {
return [{ value: '', label: '全部课程' }, ...courses.value]
})
const chapterList = $computed(() => {
return [{ value: '', label: '全部章节' }, ...chapters.value]
})
const types = useMapStore().getMapValuesByKey('learning_discussion_type')
const currentTypes = $computed(() => {
return [{ label: '全部类型', value: '' }, ...types]
})
const appList = $ref<InstanceType<typeof AppList> | null>(null)
const params = reactive({ search_by: 0, order_by: 0, course_id: '', semester_id: '', chapter_id: '', type: '' })
// 列表配置
const listOptions = computed(() => {
return {
hasFilterButton: false,
remote: {
httpRequest: getPostList,
params,
beforeRequest(requestParams: any) {
if (params.course_id !== requestParams.course_id) {
requestParams.chapter_id = ''
}
params.course_id = requestParams.course_id || ''
courseId.value = params.course_id
return requestParams
},
callback(res: { total: number; data: any }) {
return { total: res.total, list: res.data }
}
},
filters: [
{
type: 'select',
prop: 'course_id',
placeholder: '请选择',
options: courseList,
labelKey: 'name',
valueKey: 'course_id'
},
{
type: 'select',
prop: 'chapter_id',
placeholder: '请选择',
options: chapterList,
labelKey: 'name',
valueKey: 'id'
},
{ type: 'select', prop: 'search_by', placeholder: '请选择', options: bbsSearchByList },
{ type: 'select', prop: 'type', placeholder: '请选择', options: currentTypes },
{ type: 'select', prop: 'order_by', placeholder: '请选择', options: bbsOrderByList }
]
}
})
// 刷新
function handleRefetch() {
appList?.refetch()
}
const postFormVisible = $ref(false)
</script>
<template>
<AppList v-bind="listOptions" ref="appList">
<template #header-prepend>
<el-button round type="primary" @click="postFormVisible = true">我要发帖</el-button>
</template>
<template #body="{ data }">
<!-- 置顶帖子 -->
<PostPinned></PostPinned>
<template v-if="data.length">
<PostItem :data="item" v-for="item in data" :key="item.id"></PostItem>
</template>
<el-empty description="暂无数据" v-else />
</template>
</AppList>
<PostForm v-model="postFormVisible" @update="handleRefetch" v-if="postFormVisible"></PostForm>
</template>
<style lang="scss" scoped>
:deep(.table-list-hd) {
padding: 30px 0 12px 30px;
background-color: #fff;
border-radius: 6px;
justify-content: space-between;
.table-list-filter {
flex: unset;
}
.el-select {
width: 180px;
}
}
:deep(.table-list-bd) {
padding: 20px;
background-color: #fff;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
:deep(.table-list-ft) {
padding: 20px;
background-color: #fff;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
</style>
<script setup lang="ts">
import type { Post } from '../types'
import AppList from '@/components/base/AppList.vue'
import DiscussItem from '../components/DiscussItem.vue'
import { getPostAndDiscussList } from '../api'
const DiscussForm = defineAsyncComponent(() => import('../components/DiscussForm.vue'))
interface Props {
id: string
}
const props = defineProps<Props>()
let detail = $ref<Post>()
const appList = $ref<InstanceType<typeof AppList> | null>(null)
// 列表配置
const listOptions = computed(() => {
return {
hasFilterButton: false,
remote: {
httpRequest: getPostAndDiscussList,
params: { id: props.id },
callback(res: { total: number; data: any; info: any }) {
detail = res.info
return { total: res.total, list: res.data }
}
}
}
})
// 刷新
function handleRefetch() {
appList?.refetch()
}
const discussFormVisible = $ref(false)
</script>
<template>
<div class="bbs">
<div class="bbs-hd" v-if="detail">
<h1>{{ detail.title }}</h1>
<el-button round auto-insert-space>收藏</el-button>
<el-button round type="primary" auto-insert-space @click="discussFormVisible = true">回复</el-button>
</div>
<AppList v-bind="listOptions" ref="appList">
<template #body="{ data }">
<DiscussItem :data="item" v-for="item in data" :key="item.id"></DiscussItem>
</template>
</AppList>
</div>
<DiscussForm v-model="discussFormVisible" :id="id" @update="handleRefetch" v-if="discussFormVisible"></DiscussForm>
</template>
<style lang="scss">
.bbs {
background-color: #fff;
border: 1px solid #e5e5e5;
border-radius: 6px;
.table-list-ft {
padding: 40px;
}
}
.bbs-hd {
padding: 20px;
display: flex;
align-items: center;
border-bottom: 1px solid #e4e4e4;
h1 {
flex: 1;
}
}
</style>
...@@ -25,3 +25,22 @@ export const liveStatus = { ...@@ -25,3 +25,22 @@ export const liveStatus = {
} }
// 试题类型列表 // 试题类型列表
export const liveStatusList = json2Array(liveStatus, false) export const liveStatusList = json2Array(liveStatus, false)
// 论坛检索范围
export const bbsSearchBy = {
0: '全部帖子',
1: '我发布的帖子',
2: '我回复的帖子'
}
// 论坛检索范围列表
export const bbsSearchByList = json2Array(bbsSearchBy)
// 论坛检索范围
export const bbsOrderBy = {
0: '默认排序',
1: '热度从高-低',
2: '时间由近-远',
3: '时间由远-近'
}
// 论坛检索范围列表
export const bbsOrderByList = json2Array(bbsOrderBy)
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论