增加通知公告功能

This commit is contained in:
2026-09-08 23:22:49 +08:00
parent beaeb4a234
commit 672c22d9b6
12 changed files with 1416 additions and 6 deletions
+67
View File
@@ -0,0 +1,67 @@
import request from '@/utils/request'
// OA 通知公告
// 响应拦截器只返回 {code, data, msg} 包装,调用方需自行取 res.data
export function getNoticeList(params) {
return request({
url: '/backend/oa/notice/list',
method: 'get',
params
})
}
// 工作台/仪表盘用:已发布公告,置顶优先
export function getNoticePortal(params) {
return request({
url: '/backend/oa/notice/portal',
method: 'get',
params
})
}
export function getNoticeDetail(id) {
return request({
url: `/backend/oa/notice/detail/${id}`,
method: 'get'
})
}
export function createNotice(data) {
return request({
url: '/backend/oa/notice/create',
method: 'post',
data
})
}
export function updateNotice(id, data) {
return request({
url: `/backend/oa/notice/update/${id}`,
method: 'post',
data
})
}
export function deleteNotice(id) {
return request({
url: `/backend/oa/notice/delete/${id}`,
method: 'delete'
})
}
// 发布 / 下架切换
export function publishNotice(id) {
return request({
url: `/backend/oa/notice/publish/${id}`,
method: 'post'
})
}
// 置顶 / 取消置顶切换
export function topNotice(id) {
return request({
url: `/backend/oa/notice/top/${id}`,
method: 'post'
})
}
+209 -1
View File
@@ -93,8 +93,42 @@
</div>
</div>
<!-- 右侧 40%:工作日程提醒 -->
<!-- 右侧 30%:通知公告 + 工作日程提醒 -->
<div class="col-right">
<div class="chart-card notice-card">
<div class="chart-title notice-title">
<span>通知公告</span>
<el-button link type="primary" @click="goNotice"
>查看全部</el-button
>
</div>
<div v-loading="noticeLoading" class="notice-list">
<div
v-for="item in notices"
:key="item.id"
class="notice-item"
@click="openNoticeDetail(item)"
>
<el-tag
:type="noticeTypeTag(item.notice_type)"
size="small"
effect="light"
class="notice-type"
>
{{ noticeTypeLabel(item.notice_type) }}
</el-tag>
<span v-if="item.is_top === 1" class="notice-top">置顶</span>
<span class="notice-text">{{ item.title }}</span>
<span class="notice-time">{{ noticeTime(item) }}</span>
</div>
<el-empty
v-if="!notices.length && !noticeLoading"
description="暂无通知公告"
:image-size="60"
/>
</div>
</div>
<div class="chart-card schedule-card">
<div class="chart-title schedule-title">
<span>工作日程提醒</span>
@@ -179,6 +213,35 @@
</div>
</div>
<!-- 通知公告详情 -->
<el-dialog
v-model="noticeDetailVisible"
:title="noticeDetailItem?.title"
width="620px"
>
<div v-if="noticeDetailItem" class="notice-detail-body">
<div class="notice-detail-meta">
<el-tag
:type="noticeTypeTag(noticeDetailItem.notice_type)"
size="small"
effect="light"
>
{{ noticeTypeLabel(noticeDetailItem.notice_type) }}
</el-tag>
<span class="notice-detail-text"
>{{ noticeDetailItem.publisher_name }} ·
{{ formatDateTime(noticeDetailItem.publish_time) }}</span
>
<span class="notice-detail-text"
>阅读 {{ noticeDetailItem.read_count || 0 }}</span
>
</div>
<div class="notice-detail-content">
{{ noticeDetailItem.content || "暂无内容" }}
</div>
</div>
</el-dialog>
<!-- 日程详情抽屉:点击任务后由用户明确选择延期 / 已完成 / 反审核 -->
<ScheduleDetail
v-model="scheduleDetailVisible"
@@ -205,6 +268,10 @@ import { ElMessage } from "element-plus";
import * as echarts from "echarts";
import { Refresh } from "@element-plus/icons-vue";
import { getReimbursementDashboard } from "@/api/reimburse";
import {
getNoticeDetail,
getNoticePortal
} from "@/api/oaNotice";
import { finishSchedule, getScheduleList } from "@/api/oaSchedule";
import ScheduleDetail from "../schedule/components/detail.vue";
import SchedulePostpone from "../schedule/components/postponeDialog.vue";
@@ -459,6 +526,62 @@ const editSchedule = (item) => {
router.push("/apps/oa/schedule");
};
// ---------- 通知公告 ----------
const notices = ref([]);
const noticeLoading = ref(false);
const noticeDetailVisible = ref(false);
const noticeDetailItem = ref(null);
const noticeTypeLabel = (type) => ({ 1: "公告", 2: "活动" }[type] || "通知");
const noticeTypeTag = (type) => ({ 1: "primary", 2: "success" }[type] || "info");
const formatDateTime = (value) => {
if (!value) return "-";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(
d.getHours()
)}:${pad2(d.getMinutes())}`;
};
// 列表右侧时间:今天显示 HH:mm,本年显示 M/D,跨年显示 YYYY-M-D
const noticeTime = (item) => {
const d = item.publish_time ? new Date(item.publish_time) : null;
if (!d || Number.isNaN(d.getTime())) return "";
const now = new Date();
if (fmtDate(d) === fmtDate(now)) {
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
if (d.getFullYear() === now.getFullYear()) {
return `${d.getMonth() + 1}/${d.getDate()}`;
}
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
};
const loadNotices = async () => {
noticeLoading.value = true;
try {
const res = await getNoticePortal({ limit: 6 });
notices.value = res?.data?.list || [];
} catch (error) {
console.warn("加载通知公告失败:", error?.message);
} finally {
noticeLoading.value = false;
}
};
const openNoticeDetail = async (item) => {
const res = await getNoticeDetail(item.id);
if (res?.code === 200) {
noticeDetailItem.value = res.data;
noticeDetailVisible.value = true;
} else {
ElMessage.error(res?.msg || "加载公告失败");
}
};
const goNotice = () => router.push("/apps/oa/notice");
const loadDashboard = async () => {
try {
const data = responseData(await getReimbursementDashboard());
@@ -473,6 +596,7 @@ const loadDashboard = async () => {
}
renderTrendChart();
loadScheduleReminders();
loadNotices();
};
const handleResize = () => trendChart?.resize();
@@ -629,6 +753,90 @@ onBeforeUnmount(() => {
margin-bottom: 0;
}
.notice-card {
margin-bottom: 16px;
.notice-title {
display: flex;
align-items: center;
justify-content: space-between;
}
}
.notice-list {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 80px;
max-height: 240px;
overflow-y: auto;
}
.notice-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
background: #fafbfc;
&:hover {
background: #eef2f7;
}
.notice-type {
flex-shrink: 0;
}
.notice-top {
flex-shrink: 0;
font-size: 11px;
color: #f56c6c;
}
.notice-text {
flex: 1;
min-width: 0;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notice-time {
flex-shrink: 0;
color: #c0c4cc;
font-size: 12px;
}
}
.notice-detail-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding-bottom: 12px;
border-bottom: 1px solid #ebeef5;
}
.notice-detail-text {
font-size: 12px;
color: #909399;
}
.notice-detail-content {
margin-top: 12px;
font-size: 14px;
line-height: 1.8;
color: #303133;
white-space: pre-wrap;
word-break: break-word;
max-height: 420px;
overflow-y: auto;
}
.schedule-card {
display: flex;
flex-direction: column;
@@ -0,0 +1,161 @@
<template>
<el-drawer
v-model="visible"
:title="isEdit ? '编辑公告' : '发布公告'"
size="560px"
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="公告标题" prop="title">
<el-input
v-model="form.title"
maxlength="100"
show-word-limit
placeholder="请输入公告标题"
/>
</el-form-item>
<el-form-item label="类型">
<el-radio-group v-model="form.notice_type">
<el-radio-button :value="0">通知</el-radio-button>
<el-radio-button :value="1">公告</el-radio-button>
<el-radio-button :value="2">活动</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="紧急程度">
<el-radio-group v-model="form.level">
<el-radio-button :value="0">普通</el-radio-button>
<el-radio-button :value="1">重要</el-radio-button>
<el-radio-button :value="2">紧急</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="置顶">
<el-switch
v-model="form.is_top"
active-text="置顶显示"
inactive-text="不置顶"
/>
</el-form-item>
<el-form-item label="公告内容" prop="content">
<el-input
v-model="form.content"
type="textarea"
:rows="8"
maxlength="5000"
show-word-limit
placeholder="请输入公告正文(可选)"
/>
</el-form-item>
<el-form-item v-if="!isPublished">
<el-checkbox v-model="form.publish">保存后立即发布</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave"
>保存</el-button
>
</template>
</el-drawer>
</template>
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { createNotice, updateNotice } from "@/api/oaNotice";
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
// 编辑时传入的公告对象;null 表示新建
notice: {
type: Object,
default: null
}
});
const emit = defineEmits(["update:modelValue", "saved"]);
const visible = computed({
get: () => props.modelValue,
set: value => emit("update:modelValue", value)
});
const formRef = ref(null);
const saving = ref(false);
const editingId = ref(0);
const form = reactive({
title: "",
content: "",
notice_type: 0,
level: 0,
is_top: false,
publish: true
});
const rules = {
title: [{ required: true, message: "请输入公告标题", trigger: "blur" }]
};
const isEdit = computed(() => !!props.notice);
// 已发布的公告保存时不再显示"立即发布"勾选框
const isPublished = computed(() => props.notice?.status === 1);
watch(
() => props.modelValue,
open => {
if (open) {
initForm();
}
}
);
function initForm() {
if (props.notice) {
editingId.value = props.notice.id;
form.title = props.notice.title || "";
form.content = props.notice.content || "";
form.notice_type = props.notice.notice_type || 0;
form.level = props.notice.level || 0;
form.is_top = props.notice.is_top === 1;
form.publish = props.notice.status === 1;
} else {
editingId.value = 0;
form.title = "";
form.content = "";
form.notice_type = 0;
form.level = 0;
form.is_top = false;
form.publish = true;
}
}
async function handleSave() {
try {
await formRef.value.validate();
} catch {
return;
}
saving.value = true;
try {
const payload = {
title: form.title.trim(),
content: form.content,
notice_type: form.notice_type,
level: form.level,
is_top: form.is_top ? 1 : 0,
publish: form.publish
};
const res = isEdit.value
? await updateNotice(editingId.value, payload)
: await createNotice(payload);
if (res?.code === 200) {
emit("saved");
visible.value = false;
}
} finally {
saving.value = false;
}
}
</script>
+389
View File
@@ -0,0 +1,389 @@
<template>
<div class="notice-page">
<div class="page-header">
<div>
<h2>通知公告</h2>
<p>发布与管理租户内的通知、公告与活动</p>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate"
>发布公告</el-button
>
</div>
<div class="table-card">
<div class="filter-bar">
<el-input
v-model="filters.keyword"
clearable
placeholder="搜索公告标题"
style="width: 220px"
@keyup.enter="loadList"
/>
<el-select
v-model="filters.status"
clearable
placeholder="全部状态"
style="width: 130px"
>
<el-option label="草稿" value="0" />
<el-option label="已发布" value="1" />
<el-option label="已下架" value="2" />
</el-select>
<el-select
v-model="filters.notice_type"
clearable
placeholder="全部类型"
style="width: 130px"
>
<el-option label="通知" value="0" />
<el-option label="公告" value="1" />
<el-option label="活动" value="2" />
</el-select>
<el-button type="primary" :icon="Search" @click="handleSearch"
>查询</el-button
>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</div>
<el-table v-loading="loading" :data="list" stripe>
<el-table-column label="标题" min-width="260" show-overflow-tooltip>
<template #default="{ row }">
<span class="title-cell">
<el-tag v-if="row.is_top === 1" type="danger" size="small" effect="dark"
>置顶</el-tag
>
<el-tag :type="typeTagType(row.notice_type)" size="small" effect="light">
{{ typeLabel(row.notice_type) }}
</el-tag>
<el-tag
v-if="row.level > 0"
:type="levelTagType(row.level)"
size="small"
effect="plain"
>
{{ levelLabel(row.level) }}
</el-tag>
<span class="title-text" @click="openDetail(row)">{{
row.title
}}</span>
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{
statusLabel(row.status)
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="publisher_name" label="发布人" width="120" />
<el-table-column label="发布时间" width="170">
<template #default="{ row }">{{
formatDateTime(row.publish_time)
}}</template>
</el-table-column>
<el-table-column prop="read_count" label="阅读" width="90" />
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="togglePublish(row)">{{
row.status === 1 ? "下架" : "发布"
}}</el-button>
<el-button link type="primary" @click="toggleTop(row)">{{
row.is_top === 1 ? "取消置顶" : "置顶"
}}</el-button>
<el-button link type="primary" @click="openEdit(row)"
>编辑</el-button
>
<el-button link type="danger" @click="handleDelete(row)"
>删除</el-button
>
</template>
</el-table-column>
<template #empty><el-empty description="暂无公告" /></template>
</el-table>
<div class="pagination">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@update:current-page="pagination.page = $event"
@update:page-size="pagination.pageSize = $event"
@current-change="loadList"
@size-change="loadList"
/>
</div>
</div>
<NoticeEdit v-model="editVisible" :notice="editingItem" @saved="loadList" />
<!-- 公告详情 -->
<el-dialog v-model="detailVisible" :title="detailItem?.title" width="620px">
<div v-if="detailItem" class="notice-detail">
<div class="detail-meta">
<el-tag :type="typeTagType(detailItem.notice_type)" size="small">{{
typeLabel(detailItem.notice_type)
}}</el-tag>
<el-tag
v-if="detailItem.level > 0"
:type="levelTagType(detailItem.level)"
size="small"
effect="plain"
>
{{ levelLabel(detailItem.level) }}
</el-tag>
<span class="meta-text"
>{{ detailItem.publisher_name }} ·
{{ formatDateTime(detailItem.publish_time) }}</span
>
<span class="meta-text">阅读 {{ detailItem.read_count || 0 }}</span>
</div>
<div class="detail-content">{{ detailItem.content || "暂无内容" }}</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Refresh, Search } from "@element-plus/icons-vue";
import NoticeEdit from "./components/edit.vue";
import {
deleteNotice,
getNoticeDetail,
getNoticeList,
publishNotice,
topNotice
} from "@/api/oaNotice";
const filters = reactive({ keyword: "", status: "", notice_type: "" });
const list = ref([]);
const loading = ref(false);
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
async function loadList() {
loading.value = true;
try {
const res = await getNoticeList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: filters.keyword,
status: filters.status,
notice_type: filters.notice_type
});
const data = res?.data || {};
list.value = data.list || [];
pagination.total = data.total || 0;
} finally {
loading.value = false;
}
}
function handleSearch() {
pagination.page = 1;
loadList();
}
function resetFilters() {
filters.keyword = "";
filters.status = "";
filters.notice_type = "";
pagination.page = 1;
loadList();
}
// ---------- 发布/编辑 ----------
const editVisible = ref(false);
const editingItem = ref(null);
function openCreate() {
editingItem.value = null;
editVisible.value = true;
}
function openEdit(item) {
editingItem.value = item;
editVisible.value = true;
}
// ---------- 详情 ----------
const detailVisible = ref(false);
const detailItem = ref(null);
async function openDetail(row) {
const res = await getNoticeDetail(row.id);
if (res?.code === 200) {
detailItem.value = res.data;
detailVisible.value = true;
loadList();
}
}
// ---------- 操作 ----------
async function togglePublish(row) {
const res = await publishNotice(row.id);
if (res?.code === 200) {
ElMessage.success(res.data?.status === 1 ? "已发布" : "已下架");
loadList();
} else {
ElMessage.error(res?.msg || "操作失败");
}
}
async function toggleTop(row) {
const res = await topNotice(row.id);
if (res?.code === 200) {
ElMessage.success(res.data?.is_top === 1 ? "已置顶" : "已取消置顶");
loadList();
} else {
ElMessage.error(res?.msg || "操作失败");
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(
`确定删除公告「${row.title}」吗?`,
"删除确认",
{ type: "warning", confirmButtonText: "删除", cancelButtonText: "取消" }
);
} catch {
return;
}
const res = await deleteNotice(row.id);
if (res?.code === 200) {
ElMessage.success("删除成功");
loadList();
} else {
ElMessage.error(res?.msg || "删除失败");
}
}
// ---------- 展示辅助 ----------
function typeLabel(type) {
return { 1: "公告", 2: "活动" }[type] || "通知";
}
function typeTagType(type) {
return { 1: "primary", 2: "success" }[type] || "info";
}
function levelLabel(level) {
return { 1: "重要", 2: "紧急" }[level] || "普通";
}
function levelTagType(level) {
return { 1: "warning", 2: "danger" }[level] || "info";
}
function statusLabel(status) {
return { 1: "已发布", 2: "已下架" }[status] || "草稿";
}
function statusTagType(status) {
return { 1: "success", 2: "info" }[status] || "warning";
}
function formatDateTime(value) {
if (!value) {
return "-";
}
const d = new Date(value);
if (Number.isNaN(d.getTime())) {
return String(value);
}
const pad = n => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
d.getHours()
)}:${pad(d.getMinutes())}`;
}
onMounted(loadList);
</script>
<style scoped>
.notice-page {
padding: 16px 20px 24px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-header h2 {
margin: 0 0 4px;
font-size: 20px;
}
.page-header p {
margin: 0;
color: #909399;
font-size: 13px;
}
.table-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 16px;
}
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.title-cell {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.title-text {
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title-text:hover {
color: #409eff;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
.notice-detail {
.detail-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding-bottom: 12px;
border-bottom: 1px solid #ebeef5;
}
.meta-text {
font-size: 12px;
color: #909399;
}
.detail-content {
margin-top: 12px;
font-size: 14px;
line-height: 1.8;
color: #303133;
white-space: pre-wrap;
word-break: break-word;
max-height: 420px;
overflow-y: auto;
}
}
</style>
@@ -39,10 +39,6 @@
v-if="activeTab === 'legalNotice'"
/>
</el-tab-pane>
<el-tab-pane label="其他设置" name="other">
<otherSettings ref="otherSettingsRef" v-if="activeTab === 'other'" />
</el-tab-pane>
</el-tabs>
</div>
</div>
@@ -53,7 +49,6 @@ import { ref, onMounted } from "vue";
import normalSettings from "./components/normalSettings.vue";
import seoSettings from "./components/seoSettings.vue";
import contactSettings from "./components/contactSettings.vue";
import otherSettings from "./components/otherSettings.vue";
import legalNoticeSettings from "./components/legalNotice.vue";
import loginVerificationSettings from "./components/loginVerification.vue";
import { useAuthStore } from '@/stores/auth';