feat: 新增单页面通知公告编辑与查看

This commit is contained in:
秋帆
2024-08-03 17:24:54 +08:00
parent bad6e30e41
commit 90693cb25d
9 changed files with 574 additions and 8 deletions

View File

@@ -1,10 +1,11 @@
# 环境变量 (命名必须以 VITE_ 开头)
# 接口前缀
VITE_API_PREFIX = '/api'
# 接口地址
VITE_API_BASE_URL = 'http://localhost:8000'
# 接口地址 (WebSocket)
VITE_API_WS_URL = 'ws://localhost:8000'
# 地址前缀

View File

@@ -24,6 +24,7 @@
"@vueuse/core": "^10.5.0",
"@wangeditor/editor": "^5.1.1",
"@wangeditor/editor-for-vue": "^5.1.12",
"aieditor": "^1.0.13",
"animate.css": "^4.1.1",
"axios": "^0.27.2",
"codemirror": "^6.0.1",
@@ -82,7 +83,7 @@
"vue-tsc": "^2.0.6"
},
"simple-git-hooks": {
"pre-commit": "npm lint-staged"
"pre-commit": "pnpm lint-staged"
},
"lint-staged": {
"*": "eslint --fix"

View File

@@ -2,7 +2,7 @@
<div v-if="isDesktop" class="asider" :class="{ 'app-menu-dark': appStore.menuDark }"
:style="appStore.menuDark ? appStore.themeCSSVar : undefined">
<Logo :collapsed="appStore.menuCollapse"></Logo>
<a-layout-sider class="menu" collapsible breakpoint="xl" hide-trigger width="auto"
<a-layout-sider class="menu" collapsible breakpoint="xl" hide-trigger style="width: auto;"
:collapsed="appStore.menuCollapse" @collapse="handleCollapse">
<a-scrollbar outer-class="h-full" style="height: 100%; overflow: auto">
<Menu></Menu>

View File

@@ -1,6 +1,22 @@
/* 全局样式 */
@import './var.scss';
// 全局滚动条
::-webkit-scrollbar-thumb {
background-color: var(--color-bg-3);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-bg-3);
border-radius: 5px;
}
::-webkit-scrollbar {
width:8px;
height: 5px;
}
::-webkit-scrollbar-track-piece {
background-color: rgba(0, 0, 0, 0);
border-radius: 0;
}
// 通用外边距
.gi_margin {
margin: $margin;
@@ -228,7 +244,25 @@
max-height: 100%;
overflow: hidden;
}
.detail{
height: 100%;
display: flex;
flex-direction: column;
&_header{
background: var(--color-bg-1);
}
&_content{
position: relative;
flex: 1;
// height: 100%;
overflow: auto;
display: flex;
flex-direction: column;
padding: $padding;
margin: $margin;
background: var(--color-bg-1);
}
}
.gi_card_title {
.arco-card-header-title::before {
content: '';

View File

@@ -0,0 +1,123 @@
<!-- 未完善 -->
<template>
<div ref="divRef" class="container">
<div class="aie-container">
<div class="aie-header-panel" style="display: none;">
<div class="aie-container-header"></div>
</div>
<div class="aie-main">
<div class="aie-container-panel">
<div class="aie-container-main"></div>
</div>
</div>
<div class="aie-container-footer" style="display: none;"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { AiEditor, type AiEditorOptions } from 'aieditor'
import 'aieditor/dist/style.css'
import { useAppStore } from '@/stores'
defineOptions({ name: 'AiEditor' })
const props = defineProps<{
modelValue: string
options?: AiEditorOptions
}>()
const aieditor = ref<AiEditor | null>(null)
const appStore = useAppStore()
const divRef = ref<any>()
const editorConfig = reactive<AiEditorOptions>({
element: '',
theme: appStore.theme,
placeholder: '请输入内容',
content: '',
editable: false
})
const init = () => {
aieditor.value?.destroy()
aieditor.value = new AiEditor(editorConfig)
}
watch(() => props.modelValue, (value) => {
if (value !== aieditor.value?.getHtml()) {
editorConfig.content = value
init()
}
})
watch(() => appStore.theme, (value) => {
editorConfig.theme = value
init()
})
// 挂载阶段
onMounted(() => {
editorConfig.element = divRef.value
init()
})
// 销毁阶段
onUnmounted(() => {
aieditor.value?.destroy()
})
</script>
<style lang="scss" scoped>
.container {
height: 100%;
width: 100%;
box-sizing: border-box;
}
.aie-header-panel {
position: sticky;
// top: 51px;
z-index: 1;
}
.aie-header-panel aie-header>div {
align-items: center;
justify-content: center;
padding: 10px 0;
}
.aie-container {
border: none !important;
}
.aie-container-panel {
width: calc(100% - 2rem - 2px);
max-width: 826.77px;
margin: 0rem auto;
border: 1px solid var(--color-border-1);
background-color: var() rgba($color: var(--color-bg-1), $alpha: 1.0);
height: 100%;
padding: 1rem;
z-index: 99;
overflow: auto;
box-sizing: border-box;
}
.aie-main {
position: relative;
overflow: hidden;
flex: 1;
box-sizing: border-box;
padding: 1rem 0px;
background-color: var(--color-bg-1);
}
.aie-directory {
position: absolute;
top: 30px;
left: 10px;
width: 260px;
z-index: 0;
}
.aie-title1 {
font-size: 14px;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,209 @@
<!-- 未完善 -->
<template>
<div ref="divRef" class="container">
<div class="aie-container">
<div class="aie-header-panel">
<div class="aie-container-header"></div>
</div>
<div class="aie-main">
<div class="aie-directory-content">
<div class="aie-directory">
<h5>目录</h5>
<div id="outline">
</div>
</div>
</div>
<div class="aie-container-panel">
<div class="aie-container-main"></div>
</div>
</div>
<div class="aie-container-footer"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { AiEditor, type AiEditorOptions } from 'aieditor'
import 'aieditor/dist/style.css'
import { useAppStore } from '@/stores'
defineOptions({ name: 'AiEditor' })
const props = defineProps<{
modelValue: string
options?: AiEditorOptions
}>()
const emit = defineEmits<(e: 'update:modelValue', value: string) => void>()
const appStore = useAppStore()
const divRef = ref<any>()
const aieditor = ref<AiEditor | null>(null)
const updateOutLine = (editor: AiEditor) => {
const outlineContainer = document.querySelector('#outline')
while (outlineContainer?.firstChild) {
outlineContainer.removeChild(outlineContainer.firstChild)
}
const outlines = editor.getOutline()
for (const outline of outlines) {
const child = document.createElement('div')
child.classList.add(`aie-title${outline.level}`)
child.style.marginLeft = `${14 * (outline.level - 1)}px`
child.style.padding = `4px 0`
child.innerHTML = `<a href="#${outline.id}">${outline.text}</a>`
child.addEventListener('click', (e) => {
e.preventDefault()
const el = editor.innerEditor.view.dom.querySelector(`#${outline.id}`) as HTMLElement
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
setTimeout(() => {
editor.focusPos(outline.pos + outline.size - 1)
}, 1000)
})
outlineContainer?.appendChild(child)
}
}
const editorConfig = reactive<AiEditorOptions>({
element: '',
theme: appStore.theme,
placeholder: '请输入内容',
content: '',
draggable: false,
onChange: (editor: AiEditor) => {
emit('update:modelValue', editor.getHtml())
updateOutLine(editor)
},
onCreated: (editor: AiEditor) => {
updateOutLine(editor)
}
})
watch(() => props.modelValue, (value) => {
if (value !== aieditor.value?.getHtml()) {
aieditor.value?.destroy()
editorConfig.content = value
aieditor.value = new AiEditor(editorConfig)
}
})
const init = () => {
editorConfig.element = divRef.value
aieditor.value = new AiEditor(editorConfig)
}
// 挂载阶段
onMounted(() => {
init()
})
// 销毁阶段
onUnmounted(() => {
aieditor.value?.destroy()
})
</script>
<style lang="scss" scoped>
.container {
height: 100%;
width: 100%;
box-sizing: border-box;
}
.aie-header-panel {
position: sticky;
// top: 51px;
z-index: 1;
}
.aie-header-panel aie-header>div {
align-items: center;
justify-content: center;
padding: 10px 0;
}
.aie-container {
border: none !important;
}
.aie-container-panel {
width: calc(100% - 2rem - 2px);
max-width: 826.77px;
margin: 0rem auto;
border: 1px solid var(--color-border-1);
background-color: var() rgba($color: var(--color-bg-1), $alpha: 1.0);
height: 100%;
padding: 1rem;
z-index: 99;
overflow: auto;
box-sizing: border-box;
color: black;
}
.aie-main {
position: relative;
overflow: hidden;
flex: 1;
box-sizing: border-box;
padding: 1rem 0px;
background-color: var(--color-bg-2);
}
.aie-directory {
position: absolute;
top: 30px;
left: 10px;
width: 260px;
z-index: 0;
}
.aie-directory h5 {
// color: #000000c4;
font-size: 16px;
text-indent: 4px;
line-height: 32px;
}
.aie-directory a {
height: 30px;
font-size: 14px;
// color: #000000a3;
text-indent: 4px;
line-height: 30px;
text-decoration: none;
width: 100%;
display: inline-block;
margin: 0;
padding: 0;
white-space: nowrap;
overflow: hidden;
-o-text-overflow: ellipsis;
text-overflow: ellipsis;
}
.aie-directory a:hover {
cursor: pointer;
// background-color: #334d660f;
border-radius: 4px;
}
.aie-title1 {
font-size: 14px;
font-weight: 500;
}
#outline {
text-indent: 2rem;
}
.aie-directory-content {
position: sticky;
top: 0px
}
@media screen and (max-width: 1280px) {
.aie-directory {
display: none;
}
}
@media screen and (max-width: 1400px) {
.aie-directory {
width: 200px;
}
}
</style>

View File

@@ -67,6 +67,7 @@ defineOptions({ name: 'SystemNotice' })
const { notice_type, notice_status_enum } = useDict('notice_type', 'notice_status_enum')
const router = useRouter()
const queryForm = reactive<NoticeQuery>({
sort: ['createTime,desc']
})
@@ -121,18 +122,21 @@ const onDelete = (record: NoticeResp) => {
const NoticeAddModalRef = ref<InstanceType<typeof NoticeAddModal>>()
// 新增
const onAdd = () => {
NoticeAddModalRef.value?.onAdd()
// NoticeAddModalRef.value?.onAdd()
router.push({ path: '/system/notice/add' })
}
// 修改
const onUpdate = (record: NoticeResp) => {
NoticeAddModalRef.value?.onUpdate(record.id)
// NoticeAddModalRef.value?.onUpdate(record.id)
router.push({ path: '/system/notice/add', query: { id: record.id, type: 'edit' } })
}
const NoticeDetailModalRef = ref<InstanceType<typeof NoticeDetailModal>>()
// 详情
const onDetail = (record: NoticeResp) => {
NoticeDetailModalRef.value?.onDetail(record.id)
// NoticeDetailModalRef.value?.onDetail(record.id)
router.push({ path: '/system/notice/detail', query: { id: record.id } })
}
</script>

View File

@@ -0,0 +1,116 @@
<template>
<div ref="containerRef" class="detail">
<div class="detail_header">
<a-affix :target="(containerRef as HTMLElement)">
<a-page-header title="通知公告" :subtitle="type === 'edit' ? '修改' : '新增'" @back="onBack">
<template #extra>
<a-button type="primary" @click="onReleased">{{ type === 'edit' ? '修改' : '发布' }}</a-button>
</template>
</a-page-header>
</a-affix>
</div>
<div class="detail_content" style="display: flex; flex-direction: column;">
<GiForm ref="formRef" v-model="form" :options="options" :columns="columns" />
<div style="flex: 1;">
<AiEditor v-model="form.content" />
</div>
</div>
</div>
</template>
<script setup lang="tsx">
import { Message } from '@arco-design/web-vue'
import AiEditor from '../components/edit/index.vue'
import { useTabsStore } from '@/stores'
import { type Columns, GiForm, type Options } from '@/components/GiForm'
import { addNotice, getNotice, updateNotice } from '@/apis'
import { useForm } from '@/hooks'
import { useDict } from '@/hooks/app'
const { notice_type } = useDict('notice_type')
const containerRef = ref<HTMLElement | null>()
const tabsStore = useTabsStore()
const route = useRoute()
const formRef = ref<InstanceType<typeof GiForm>>()
const { id, type } = route.query
const { form, resetForm } = useForm({
title: '',
type: '',
effectiveTime: '',
terminateTime: '',
content: ''
})
const options: Options = {
form: {},
col: { xs: 24, sm: 24, md: 12, lg: 12, xl: 12, xxl: 12 },
btns: { hide: true }
}
const columns: Columns = reactive([
{
label: '标题',
field: 'title',
type: 'input',
rules: [{ required: true, message: '请输入标题' }]
},
{
label: '类型',
field: 'type',
type: 'select',
options: notice_type,
rules: [{ required: true, message: '请输入类型' }]
},
{
label: '生效时间',
field: 'effectiveTime',
type: 'date-picker',
props: {
showTime: true
}
},
{
label: '终止时间',
field: 'terminateTime',
type: 'date-picker',
props: {
showTime: true
}
}
])
// 修改
const onUpdate = async (id: string) => {
resetForm()
const res = await getNotice(id)
Object.assign(form, res.data)
}
const onBack = () => {
tabsStore.closeCurrent(route.path)
}
const onReleased = async () => {
const isInvalid = await formRef.value?.formRef?.validate()
if (isInvalid) return false
try {
if (type === 'edit') {
await updateNotice(form, id as string)
Message.success('修改成功')
} else {
await addNotice(form)
Message.success('新增成功')
}
onBack()
return true
} catch (error) {
console.error(error)
return false
}
}
onMounted(() => {
// 当id存在与type为edit时执行修改操作
if (id && type === 'edit') {
onUpdate(id as string)
}
})
</script>
<style scoped lang="less"></style>

View File

@@ -0,0 +1,78 @@
<template>
<div ref="containerRef" class="detail">
<div class="detail_header">
<a-affix :target="(containerRef as HTMLElement)">
<a-page-header title="通知公告" subtitle="查看" @back="onBack">
</a-page-header>
</a-affix>
</div>
<div class="detail_content" style="display: flex; flex-direction: column;">
<h1 class="title">{{ form?.title }}</h1>
<div class="info">
<a-space>
<span>
<icon-user class="icon" />
<span class="label">发布人</span>
<span>{{ form?.createUserString }}</span>
</span>
<a-divider direction="vertical" />
<span>
<icon-history class="icon" />
<span class="label">发布时间</span>
<span>{{ form?.effectiveTime ? form?.effectiveTime : form?.createTime
}}</span>
</span>
</a-space>
</div>
<div style="flex: 1;">
<AiEditor v-model="form.content" />
</div>
</div>
</div>
</template>
<script setup lang="tsx">
import AiEditor from '../components/detail/index.vue'
import { useTabsStore } from '@/stores'
import { getNotice } from '@/apis'
import { useForm } from '@/hooks'
const containerRef = ref<HTMLElement | null>()
const tabsStore = useTabsStore()
const route = useRoute()
const { id } = route.query
const { form, resetForm } = useForm({
title: '',
createUserString: '',
effectiveTime: '',
createTime: '',
content: ''
})
// 修改
const onDetail = async (id: string) => {
resetForm()
const res = await getNotice(id)
Object.assign(form, res.data)
}
const onBack = () => {
tabsStore.closeCurrent(route.path)
}
onMounted(() => {
onDetail(id as string)
})
</script>
<style scoped lang="scss">
.detail_content {
.title {
text-align: center;
}
.info {
text-align: right;
padding: 20px;
}
}
</style>