From dacc95761cf7090c628fc37a5d4f8bb825ccbbb0 Mon Sep 17 00:00:00 2001
From: yyb <995253665@qq.com>
Date: 星期六, 16 五月 2026 15:41:45 +0800
Subject: [PATCH] 企业新闻和通知公告
---
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/components/NoticeDetailPanel.vue | 77 +
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/useNoticeAnnouncement.js | 332 ++++++++
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js | 375 +++++++++
src/views/officeProcessAutomation/EnterpriseNews/news-manage/components/NewsDetailPanel.vue | 169 ++++
src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue | 461 +++++++++++
src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNews.js | 440 +++++++++++
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue | 253 ++++++
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/noticeAnnouncementUtils.js | 194 ++++
8 files changed, 2,301 insertions(+), 0 deletions(-)
diff --git a/src/views/officeProcessAutomation/EnterpriseNews/news-manage/components/NewsDetailPanel.vue b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/components/NewsDetailPanel.vue
new file mode 100644
index 0000000..bb25ba0
--- /dev/null
+++ b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/components/NewsDetailPanel.vue
@@ -0,0 +1,169 @@
+<!-- EnterpriseNews锛氳鎯呭彧璇婚潰鏉匡紙鍚簰鍔級 -->
+<template>
+ <el-descriptions :column="2" border>
+ <el-descriptions-item label="鏂伴椈缂栧彿">{{ row.newsNo || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鍙戝竷鐘舵��">
+ <el-tag :type="publishStatusTag(row.publishStatus)" size="small">
+ {{ publishStatusLabel(row.publishStatus) }}
+ </el-tag>
+ </el-descriptions-item>
+ <el-descriptions-item label="鏂伴椈鍒嗙被">
+ <span class="type-badge" :style="{ color: newsTypeColor(row.newsType) }">
+ {{ newsTypeLabel(row.newsType) }}
+ </span>
+ </el-descriptions-item>
+ <el-descriptions-item label="鎺掔増妯℃澘">{{ layoutTemplateLabel(row.layoutTemplate) }}</el-descriptions-item>
+ <el-descriptions-item label="鏍囬" :span="2">{{ row.title || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鎽樿" :span="2">{{ row.summary || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="闃呰鑼冨洿">{{ readScopeLabel(row.readScope) }}</el-descriptions-item>
+ <el-descriptions-item label="闃呰鐜�">
+ {{ readRate(row) }}%锛堟湭璇� {{ unreadCount }} 浜猴級
+ </el-descriptions-item>
+ <el-descriptions-item label="缂栬緫鏉冮檺">{{ publishRoleLabel(row.editorRole) }}</el-descriptions-item>
+ <el-descriptions-item label="瀹℃牳瑙掕壊">{{ publishRoleLabel(row.reviewerRole) }}</el-descriptions-item>
+ <el-descriptions-item label="鍙戝竷浜�">{{ row.publisherName || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鍙戝竷鏃堕棿">{{ row.publishTime || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="褰撳墠鐗堟湰">v{{ row.versionNo || 1 }}</el-descriptions-item>
+ <el-descriptions-item label="闇�闃呰纭">
+ {{ row.requireReadConfirm ? "鏄�" : "鍚�" }}
+ </el-descriptions-item>
+ </el-descriptions>
+
+ <el-divider content-position="left">姝f枃鍐呭</el-divider>
+ <div v-if="row.contentHtml" class="news-html-body" v-html="row.contentHtml" />
+ <el-empty v-else description="鏆傛棤姝f枃" :image-size="48" />
+
+ <template v-if="row.mediaList?.length">
+ <el-divider content-position="left">鍥鹃泦 / 瑙嗛</el-divider>
+ <div class="media-grid">
+ <div v-for="(m, i) in row.mediaList" :key="i" class="media-item">
+ <el-tag size="small" type="info">{{ m.type === "video" ? "瑙嗛" : "鍥剧墖" }}</el-tag>
+ <span class="media-name">{{ m.name }}</span>
+ </div>
+ </div>
+ </template>
+
+ <el-divider content-position="left">闄勪欢</el-divider>
+ <template v-if="row.attachmentList?.length">
+ <el-tag
+ v-for="(f, i) in row.attachmentList"
+ :key="i"
+ class="file-tag"
+ type="info"
+ @click="openFile(f)"
+ >
+ {{ f.name }}
+ </el-tag>
+ </template>
+ <el-empty v-else description="鏆傛棤闄勪欢" :image-size="48" />
+
+ <template v-if="row.newsType === 'culture' && row.publishStatus === 'published'">
+ <el-divider content-position="left">浜掑姩锛堢偣璧� {{ likeCount }} 路 璇勮 {{ commentCount }}锛�</el-divider>
+ <div class="interaction-bar">
+ <el-button type="primary" plain size="small" @click="$emit('like')">
+ {{ likedByMe ? "鍙栨秷鐐硅禐" : "鐐硅禐" }}
+ </el-button>
+ </div>
+ <el-input
+ v-model="commentDraft"
+ type="textarea"
+ :rows="2"
+ maxlength="300"
+ show-word-limit
+ placeholder="鍐欎笅浣犵殑璇勮鈥�"
+ class="mb8"
+ />
+ <el-button type="primary" size="small" @click="submitComment">鍙戣〃璇勮</el-button>
+ <el-timeline v-if="row.comments?.length" class="comment-timeline mt12">
+ <el-timeline-item v-for="c in row.comments" :key="c.id" :timestamp="c.time">
+ <strong>{{ c.name }}</strong>锛歿{ c.content }}
+ </el-timeline-item>
+ </el-timeline>
+ <el-empty v-else description="鏆傛棤璇勮" :image-size="40" />
+ </template>
+</template>
+
+<script setup>
+import { computed, ref } from "vue";
+import {
+ newsTypeLabel,
+ newsTypeColor,
+ publishStatusLabel,
+ publishStatusTag,
+ layoutTemplateLabel,
+ readScopeLabel,
+ publishRoleLabel,
+ readRate,
+ getUnreadEmployees,
+} from "../enterpriseNewsUtils.js";
+
+const props = defineProps({
+ row: { type: Object, default: () => ({}) },
+});
+
+const emit = defineEmits(["like", "comment"]);
+
+const commentDraft = ref("");
+
+const unreadCount = computed(() => getUnreadEmployees(props.row).length);
+const likeCount = computed(() => props.row?.likes?.length || 0);
+const commentCount = computed(() => props.row?.comments?.length || 0);
+const likedByMe = computed(() => (props.row?.likes || []).some((l) => l.userId === "u1"));
+
+function openFile(f) {
+ const url = f?.url || f?.downloadURL;
+ if (url) window.open(url, "_blank");
+}
+
+function submitComment() {
+ emit("comment", commentDraft.value);
+ commentDraft.value = "";
+}
+</script>
+
+<style scoped>
+.type-badge {
+ font-weight: 600;
+}
+.news-html-body {
+ padding: 12px 16px;
+ background: var(--el-fill-color-light);
+ border-radius: 6px;
+ line-height: 1.7;
+ max-height: 320px;
+ overflow-y: auto;
+}
+.media-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+.media-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ background: var(--el-fill-color-lighter);
+ border-radius: 4px;
+}
+.media-name {
+ font-size: 13px;
+}
+.file-tag {
+ margin: 0 8px 8px 0;
+ cursor: pointer;
+}
+.interaction-bar {
+ margin-bottom: 8px;
+}
+.comment-timeline {
+ max-height: 200px;
+ overflow-y: auto;
+}
+.mb8 {
+ margin-bottom: 8px;
+}
+.mt12 {
+ margin-top: 12px;
+}
+</style>
diff --git a/src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js
new file mode 100644
index 0000000..edc7f7e
--- /dev/null
+++ b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js
@@ -0,0 +1,375 @@
+import dayjs from "dayjs";
+
+/** 鏂伴椈鍒嗙被锛氱粺涓�淇℃伅鍑哄彛 */
+export const NEWS_TYPE_OPTIONS = [
+ { value: "announcement", label: "浼佷笟鍏憡", color: "#409eff" },
+ { value: "policy", label: "鏀跨瓥瑙h", color: "#e6a23c" },
+ { value: "industry", label: "琛屼笟鍔ㄦ��", color: "#909399" },
+ { value: "culture", label: "鏂囧寲娲诲姩", color: "#67c23a" },
+];
+
+/** 鍙戝竷鐘舵�� */
+export const PUBLISH_STATUS_OPTIONS = [
+ { value: "draft", label: "鑽夌", tag: "info" },
+ { value: "pending_review", label: "寰呭鏍�", tag: "warning" },
+ { value: "published", label: "宸插彂甯�", tag: "success" },
+ { value: "archived", label: "宸插綊妗�", tag: "" },
+];
+
+/** 鎺掔増妯℃澘 */
+export const LAYOUT_TEMPLATE_OPTIONS = [
+ { value: "standard", label: "鏍囧噯鍥炬枃" },
+ { value: "policy", label: "鏀跨瓥鏉℃枃" },
+ { value: "gallery", label: "鍥鹃泦鐩稿唽" },
+ { value: "briefing", label: "绠�鎶ユ憳瑕�" },
+];
+
+/** 闃呰鍙鑼冨洿 */
+export const READ_SCOPE_OPTIONS = [
+ { value: "all", label: "鍏ㄥ憳鍙" },
+ { value: "management", label: "绠$悊灞�" },
+ { value: "department", label: "鎸囧畾閮ㄩ棬" },
+ { value: "custom", label: "鑷畾涔夊悕鍗�" },
+];
+
+/** 缂栬緫/瀹℃牳瑙掕壊锛堝彂甯冩潈闄愶級 */
+export const PUBLISH_ROLE_OPTIONS = [
+ { value: "hr", label: "HR锛堜汉浜嬫斂绛栵級" },
+ { value: "admin", label: "绠$悊鍛橈紙澶栭儴鏂伴椈瀹℃牳锛�" },
+ { value: "dept_manager", label: "閮ㄩ棬璐熻矗浜�" },
+ { value: "editor", label: "鍐呭缂栬緫" },
+];
+
+export const STORAGE_KEY = "oa_enterprise_news_v1";
+
+/** 婕旂ず鐢ㄧ洰鏍囧彈浼楋紙鍚庢湡瀵规帴缁勭粐鏋舵瀯锛� */
+export const MOCK_AUDIENCE = [
+ { userId: "u1", employeeNo: "zhangsan", name: "寮犱笁", deptName: "鐮斿彂閮�", isManagement: false },
+ { userId: "u2", employeeNo: "lisi", name: "鏉庡洓", deptName: "鐮斿彂閮�", isManagement: false },
+ { userId: "u3", employeeNo: "wangwu", name: "鐜嬩簲", deptName: "琛屾斂閮�", isManagement: false },
+ { userId: "u4", employeeNo: "zhaoliu", name: "璧靛叚", deptName: "閿�鍞儴", isManagement: false },
+ { userId: "u5", employeeNo: "sunqi", name: "瀛欎竷", deptName: "璐㈠姟閮�", isManagement: false },
+ { userId: "u6", employeeNo: "zhouba", name: "鍛ㄥ叓", deptName: "鎬荤粡鍔�", isManagement: true },
+ { userId: "u7", employeeNo: "wujiu", name: "鍚翠節", deptName: "鎬荤粡鍔�", isManagement: true },
+ { userId: "u8", employeeNo: "zhengshi", name: "閮戝崄", deptName: "浜哄姏璧勬簮閮�", isManagement: false },
+];
+
+const DEPT_OPTIONS = [
+ { value: "101", label: "鐮斿彂閮�" },
+ { value: "102", label: "閿�鍞儴" },
+ { value: "103", label: "琛屾斂閮�" },
+ { value: "104", label: "璐㈠姟閮�" },
+ { value: "105", label: "鎬荤粡鍔�" },
+ { value: "106", label: "浜哄姏璧勬簮閮�" },
+];
+
+export { DEPT_OPTIONS };
+
+export function newsTypeLabel(v) {
+ return NEWS_TYPE_OPTIONS.find((x) => x.value === v)?.label || v || "鈥�";
+}
+
+export function newsTypeColor(v) {
+ return NEWS_TYPE_OPTIONS.find((x) => x.value === v)?.color || "#909399";
+}
+
+export function publishStatusLabel(v) {
+ return PUBLISH_STATUS_OPTIONS.find((x) => x.value === v)?.label || v || "鈥�";
+}
+
+export function publishStatusTag(v) {
+ return PUBLISH_STATUS_OPTIONS.find((x) => x.value === v)?.tag || "info";
+}
+
+export function layoutTemplateLabel(v) {
+ return LAYOUT_TEMPLATE_OPTIONS.find((x) => x.value === v)?.label || v || "鈥�";
+}
+
+export function readScopeLabel(v) {
+ return READ_SCOPE_OPTIONS.find((x) => x.value === v)?.label || v || "鈥�";
+}
+
+export function publishRoleLabel(v) {
+ return PUBLISH_ROLE_OPTIONS.find((x) => x.value === v)?.label || v || "鈥�";
+}
+
+export function createEmptyForm() {
+ return {
+ id: "",
+ newsNo: "",
+ title: "",
+ summary: "",
+ newsType: "announcement",
+ layoutTemplate: "standard",
+ contentHtml: "",
+ coverImage: "",
+ mediaList: [],
+ attachmentList: [],
+ editorRole: "hr",
+ reviewerRole: "admin",
+ readScope: "all",
+ targetDeptIds: [],
+ targetUserIds: [],
+ publishStatus: "draft",
+ publisherName: "",
+ publishTime: "",
+ readRecords: [],
+ remindLogs: [],
+ likes: [],
+ comments: [],
+ versions: [],
+ versionNo: 1,
+ requireReadConfirm: false,
+ };
+}
+
+function buildReadRecords(readUserIds = []) {
+ const set = new Set(readUserIds);
+ return MOCK_AUDIENCE.map((u) => ({
+ userId: u.userId,
+ employeeNo: u.employeeNo,
+ name: u.name,
+ deptName: u.deptName,
+ readAt: set.has(u.userId) ? dayjs().subtract(2, "day").format("YYYY-MM-DD HH:mm:ss") : "",
+ lastRemindAt: "",
+ }));
+}
+
+function createVersionSnapshot(row, changeNote = "鍙戝竷") {
+ return {
+ versionNo: row.versionNo || 1,
+ title: row.title,
+ summary: row.summary,
+ contentHtml: row.contentHtml,
+ newsType: row.newsType,
+ publishTime: row.publishTime || dayjs().format("YYYY-MM-DD HH:mm:ss"),
+ archivedAt: dayjs().format("YYYY-MM-DD HH:mm:ss"),
+ changeNote,
+ publisherName: row.publisherName || "绯荤粺",
+ };
+}
+
+export function createInitialMockNews() {
+ const policyContent =
+ "<p><strong>2026 骞磋�冨嫟绠$悊鍒跺害锛堣瘯琛岋級</strong></p><p>涓�銆佷笂鐝椂闂� 9:00锛屽脊鎬ф墦鍗$獥鍙� 8:30鈥�9:30銆�</p><p>浜屻�佽鍋囬』鎻愬墠鍦� OA 鎻愪氦瀹℃壒銆�</p><p>涓夈�佹湰鍒跺害鑷� 2026-06-01 璧锋墽琛屻��</p>";
+ const cultureContent =
+ "<p>2026 浼佷笟骞翠細鍦嗘弧钀藉箷锛佹劅璋㈡瘡涓�浣嶅悓浜嬬殑鍙備笌锛屼互涓嬩负绮惧僵鐬棿鍥鹃泦銆�</p>";
+ const strategyContent =
+ "<p><strong>2026 涓嬪崐骞存垬鐣ユ柟鍚戯紙鍐呴儴锛�</strong></p><p>鑱氱劍鏍稿績浜у搧绾垮崌绾т笌娴峰甯傚満鎷撳睍锛屽叿浣撴寚鏍囪闄勪欢銆�</p>";
+
+ const policyRow = {
+ id: "news_1",
+ newsNo: "EN202605150001",
+ title: "鍏充簬鍙戝竷鏂拌�冨嫟鍒跺害鐨勯�氱煡",
+ summary: "璇峰叏浣撳憳宸ヨ鐪熼槄璇诲苟纭鐭ユ倝锛岃嚜 2026-06-01 璧锋墽琛屻��",
+ newsType: "policy",
+ layoutTemplate: "policy",
+ contentHtml: policyContent,
+ coverImage: "",
+ mediaList: [],
+ attachmentList: [{ name: "鑰冨嫟鍒跺害2026.pdf", url: "/mock/attendance-policy.pdf" }],
+ editorRole: "hr",
+ reviewerRole: "admin",
+ readScope: "all",
+ targetDeptIds: [],
+ targetUserIds: [],
+ publishStatus: "published",
+ publisherName: "浜哄姏璧勬簮閮�",
+ publishTime: "2026-05-15 10:00:00",
+ readRecords: buildReadRecords(["u6", "u7", "u8"]),
+ remindLogs: [],
+ likes: [],
+ comments: [],
+ versions: [
+ {
+ versionNo: 1,
+ title: "鍏充簬鍙戝竷鏂拌�冨嫟鍒跺害鐨勯�氱煡锛堝緛姹傛剰瑙佺锛�",
+ summary: "寰佹眰鎰忚绋�",
+ contentHtml: "<p>寰佹眰鎰忚绋匡細涓婄彮鏃堕棿 9:00鈥︹��</p>",
+ newsType: "policy",
+ publishTime: "2026-05-10 09:00:00",
+ archivedAt: "2026-05-15 09:55:00",
+ changeNote: "瀹氱鍙戝竷",
+ publisherName: "浜哄姏璧勬簮閮�",
+ },
+ ],
+ versionNo: 2,
+ requireReadConfirm: true,
+ createTime: "2026-05-10 09:00:00",
+ updateTime: "2026-05-15 10:00:00",
+ };
+
+ const cultureRow = {
+ id: "news_2",
+ newsNo: "EN202605200002",
+ title: "2026 浼佷笟骞翠細绮惧僵鐬棿",
+ summary: "骞翠細鍥鹃泦涓婄嚎锛屾杩庣偣璧炵暀瑷�锛屽叡寤轰紒涓氭枃鍖栥��",
+ newsType: "culture",
+ layoutTemplate: "gallery",
+ contentHtml: cultureContent,
+ coverImage: "/mock/annual-cover.jpg",
+ mediaList: [
+ { type: "image", name: "寮�鍦�.jpg", url: "/mock/annual-1.jpg" },
+ { type: "image", name: "棰佸.jpg", url: "/mock/annual-2.jpg" },
+ { type: "video", name: "骞翠細鑺辩诞.mp4", url: "/mock/annual.mp4" },
+ ],
+ attachmentList: [],
+ editorRole: "dept_manager",
+ reviewerRole: "admin",
+ readScope: "all",
+ targetDeptIds: [],
+ targetUserIds: [],
+ publishStatus: "published",
+ publisherName: "琛屾斂閮�",
+ publishTime: "2026-05-20 14:30:00",
+ readRecords: buildReadRecords(["u1", "u2", "u3", "u4", "u5", "u6", "u7"]),
+ remindLogs: [],
+ likes: [
+ { userId: "u1", name: "寮犱笁", time: "2026-05-20 15:01:00" },
+ { userId: "u2", name: "鏉庡洓", time: "2026-05-20 15:05:00" },
+ { userId: "u4", name: "璧靛叚", time: "2026-05-20 16:20:00" },
+ ],
+ comments: [
+ { id: "c1", userId: "u1", name: "寮犱笁", content: "鑺傜洰澶簿褰╀簡锛�", time: "2026-05-20 15:10:00" },
+ { id: "c2", userId: "u3", name: "鐜嬩簲", content: "鏈熷緟鏄庡勾鍐嶈仛锛�", time: "2026-05-20 17:00:00" },
+ ],
+ versions: [],
+ versionNo: 1,
+ requireReadConfirm: false,
+ createTime: "2026-05-20 14:00:00",
+ updateTime: "2026-05-20 14:30:00",
+ };
+
+ const strategyRow = {
+ id: "news_3",
+ newsNo: "EN202605220003",
+ title: "2026 涓嬪崐骞存垬鐣ヨ鍒掕鐐�",
+ summary: "浠呴檺绠$悊灞傞槄璇伙紝璇峰嬁瀵瑰浼犳挱銆�",
+ newsType: "announcement",
+ layoutTemplate: "briefing",
+ contentHtml: strategyContent,
+ coverImage: "",
+ mediaList: [],
+ attachmentList: [{ name: "鎴樼暐鎸囨爣.pdf", url: "/mock/strategy.pdf" }],
+ editorRole: "admin",
+ reviewerRole: "admin",
+ readScope: "management",
+ targetDeptIds: [],
+ targetUserIds: [],
+ publishStatus: "published",
+ publisherName: "鎬荤粡鍔�",
+ publishTime: "2026-05-22 09:00:00",
+ readRecords: buildReadRecords(["u6", "u7"]),
+ remindLogs: [],
+ likes: [],
+ comments: [],
+ versions: [],
+ versionNo: 1,
+ requireReadConfirm: false,
+ createTime: "2026-05-22 08:30:00",
+ updateTime: "2026-05-22 09:00:00",
+ };
+
+ const industryDraft = {
+ id: "news_4",
+ newsNo: "EN202605250004",
+ title: "鍒堕�犱笟鏁板瓧鍖栬浆鍨嬭秼鍔跨畝鎶�",
+ summary: "琛屼笟鍔ㄦ�佽崏绋匡紝寰呯鐞嗗憳瀹℃牳鍚庡彂甯冦��",
+ newsType: "industry",
+ layoutTemplate: "standard",
+ contentHtml: "<p>鏈湡绠�鎶ユ⒊鐞嗗伐涓氫簰鑱旂綉涓� AI 璐ㄦ搴旂敤妗堜緥鈥︹��</p>",
+ coverImage: "",
+ mediaList: [],
+ attachmentList: [],
+ editorRole: "editor",
+ reviewerRole: "admin",
+ readScope: "all",
+ targetDeptIds: [],
+ targetUserIds: [],
+ publishStatus: "pending_review",
+ publisherName: "甯傚満閮�",
+ publishTime: "",
+ readRecords: [],
+ remindLogs: [],
+ likes: [],
+ comments: [],
+ versions: [],
+ versionNo: 1,
+ requireReadConfirm: false,
+ createTime: "2026-05-25 11:00:00",
+ updateTime: "2026-05-25 11:00:00",
+ };
+
+ return [policyRow, cultureRow, strategyRow, industryDraft];
+}
+
+export function loadStoredNews() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return null;
+ const data = JSON.parse(raw);
+ return Array.isArray(data) ? data : null;
+ } catch {
+ return null;
+ }
+}
+
+export function saveStoredNews(rows) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(rows));
+ } catch {
+ /* ignore */
+ }
+}
+
+/** 鎸夐槄璇昏寖鍥磋В鏋愮洰鏍囧彈浼� */
+export function resolveTargetAudience(row) {
+ const scope = row.readScope || "all";
+ if (scope === "management") {
+ return MOCK_AUDIENCE.filter((u) => u.isManagement);
+ }
+ if (scope === "department" && row.targetDeptIds?.length) {
+ const names = DEPT_OPTIONS.filter((d) => row.targetDeptIds.includes(d.value)).map((d) => d.label);
+ return MOCK_AUDIENCE.filter((u) => names.includes(u.deptName));
+ }
+ if (scope === "custom" && row.targetUserIds?.length) {
+ return MOCK_AUDIENCE.filter((u) => row.targetUserIds.includes(u.userId));
+ }
+ return [...MOCK_AUDIENCE];
+}
+
+export function getUnreadEmployees(row) {
+ const audience = resolveTargetAudience(row);
+ const readSet = new Set(
+ (row.readRecords || []).filter((r) => r.readAt).map((r) => r.userId)
+ );
+ return audience.filter((u) => !readSet.has(u.userId));
+}
+
+export function readRate(row) {
+ const audience = resolveTargetAudience(row);
+ if (!audience.length) return 0;
+ const readCount = (row.readRecords || []).filter((r) => r.readAt).length;
+ return Math.round((readCount / audience.length) * 100);
+}
+
+export function nextNewsNo() {
+ return `EN${dayjs().format("YYYYMMDD")}${String(Math.floor(Math.random() * 9000) + 1000)}`;
+}
+
+export function pushVersionBeforeUpdate(row, changeNote) {
+ const versions = row.versions || [];
+ versions.unshift(createVersionSnapshot(row, changeNote));
+ row.versions = versions;
+ row.versionNo = (row.versionNo || 1) + 1;
+}
+
+export function validateNewsForm(form) {
+ const title = (form.title || "").trim();
+ if (!title) return { ok: false, message: "璇峰~鍐欐柊闂绘爣棰�" };
+ if (!form.newsType) return { ok: false, message: "璇烽�夋嫨鏂伴椈鍒嗙被" };
+ if (form.readScope === "department" && !(form.targetDeptIds || []).length) {
+ return { ok: false, message: "璇烽�夋嫨鍙閮ㄩ棬" };
+ }
+ return { ok: true, title };
+}
diff --git a/src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue
new file mode 100644
index 0000000..a8b743a
--- /dev/null
+++ b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue
@@ -0,0 +1,461 @@
+<!--OA妯″潡锛欵nterpriseNews 浼佷笟鏂伴椈-->
+<template>
+ <div class="app-container enterprise-news-page">
+
+ <div class="search_form mb20">
+ <div class="search_fields">
+ <span class="search_title">鍏抽敭璇嶏細</span>
+ <el-input
+ v-model="searchForm.keyword"
+ style="width: 200px"
+ placeholder="鏍囬 / 缂栧彿 / 鎽樿"
+ clearable
+ :prefix-icon="Search"
+ @keyup.enter="handleQuery"
+ />
+ <span class="search_title" style="margin-left: 12px">鍒嗙被锛�</span>
+ <el-select v-model="searchForm.newsType" placeholder="鍏ㄩ儴" clearable style="width: 140px">
+ <el-option v-for="opt in NEWS_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
+ </el-select>
+ <span class="search_title" style="margin-left: 12px">鐘舵�侊細</span>
+ <el-select v-model="searchForm.publishStatus" placeholder="鍏ㄩ儴" clearable style="width: 120px">
+ <el-option v-for="opt in PUBLISH_STATUS_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
+ </el-select>
+ <span class="search_title" style="margin-left: 12px">鍙戝竷鏃堕棿锛�</span>
+ <el-date-picker
+ v-model="searchForm.publishTimeRange"
+ type="daterange"
+ range-separator="-"
+ start-placeholder="寮�濮�"
+ end-placeholder="缁撴潫"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 260px"
+ clearable
+ />
+ <el-button type="primary" :icon="Search" class="ml10" @click="handleQuery">鎼滅储</el-button>
+ <el-button :icon="RefreshRight" @click="resetSearch">閲嶇疆</el-button>
+ </div>
+ <div class="search_actions">
+ <el-button type="primary" :icon="Plus" @click="openFormDialog('add')">鏂板缓鏂伴椈</el-button>
+ </div>
+ </div>
+
+ <div class="table_list">
+ <PIMTable
+ rowKey="id"
+ :column="tableColumn"
+ :tableData="tableData"
+ :page="page"
+ :isSelection="false"
+ :tableLoading="tableLoading"
+ :total="page.total"
+ @pagination="pagination"
+ >
+ <template #newsType="{ row }">
+ <span class="news-type-tag" :style="{ color: newsTypeColor(row.newsType) }">
+ {{ newsTypeLabel(row.newsType) }}
+ </span>
+ </template>
+ </PIMTable>
+ </div>
+
+ <!-- 鏂板缓 / 缂栬緫 -->
+ <el-dialog
+ v-model="formDialog.visible"
+ :title="formDialog.title"
+ width="960px"
+ append-to-body
+ destroy-on-close
+ class="news-form-dialog"
+ @closed="formRef?.resetFields?.()"
+ >
+ <el-form
+ ref="formRef"
+ :model="form"
+ :rules="formRules"
+ label-width="110px"
+ :disabled="formDialog.readonly"
+ >
+ <el-row :gutter="20">
+ <el-col :span="12">
+ <el-form-item label="鏂伴椈鍒嗙被" prop="newsType">
+ <el-select v-model="form.newsType" placeholder="璇烽�夋嫨" style="width: 100%">
+ <el-option v-for="opt in NEWS_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
+ </el-select>
+ </el-form-item>
+ </el-col>
+ <el-col :span="12">
+ <el-form-item label="鎺掔増妯℃澘">
+ <el-select v-model="form.layoutTemplate" style="width: 100%">
+ <el-option
+ v-for="opt in LAYOUT_TEMPLATE_OPTIONS"
+ :key="opt.value"
+ :label="opt.label"
+ :value="opt.value"
+ />
+ </el-select>
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-form-item label="鏍囬" prop="title">
+ <el-input v-model="form.title" placeholder="鏂伴椈鏍囬" maxlength="100" show-word-limit />
+ </el-form-item>
+ <el-form-item label="鎽樿">
+ <el-input v-model="form.summary" type="textarea" :rows="2" maxlength="300" show-word-limit />
+ </el-form-item>
+ <el-form-item label="姝f枃" prop="contentHtml">
+ <Editor v-model="form.contentHtml" :min-height="280" />
+ </el-form-item>
+ <el-form-item label="闄勪欢">
+ <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="涓婁紶 PDF / 鏂囨。" />
+ </el-form-item>
+ <el-form-item v-if="form.layoutTemplate === 'gallery'" label="鍥鹃泦/瑙嗛">
+ <el-input
+ v-model="galleryInput"
+ placeholder="杈撳叆璧勬簮鍚嶇О鍚庡洖杞︽坊鍔狅紙婕旂ず锛�"
+ @keyup.enter="addGalleryItem"
+ />
+ <el-tag
+ v-for="(m, i) in form.mediaList"
+ :key="i"
+ closable
+ class="media-tag"
+ @close="form.mediaList.splice(i, 1)"
+ >
+ {{ m.name }}
+ </el-tag>
+ </el-form-item>
+
+ <el-divider content-position="left">鏉冮檺绠℃帶</el-divider>
+ <el-row :gutter="20">
+ <el-col :span="12">
+ <el-form-item label="缂栬緫瑙掕壊">
+ <el-select v-model="form.editorRole" style="width: 100%">
+ <el-option v-for="opt in PUBLISH_ROLE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
+ </el-select>
+ </el-form-item>
+ </el-col>
+ <el-col :span="12">
+ <el-form-item label="瀹℃牳瑙掕壊">
+ <el-select v-model="form.reviewerRole" style="width: 100%">
+ <el-option v-for="opt in PUBLISH_ROLE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
+ </el-select>
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-form-item label="闃呰鑼冨洿" prop="readScope">
+ <el-radio-group v-model="form.readScope">
+ <el-radio v-for="opt in READ_SCOPE_OPTIONS" :key="opt.value" :value="opt.value">
+ {{ opt.label }}
+ </el-radio>
+ </el-radio-group>
+ </el-form-item>
+ <el-form-item v-if="form.readScope === 'department'" label="鍙閮ㄩ棬">
+ <el-select v-model="form.targetDeptIds" multiple placeholder="閫夋嫨閮ㄩ棬" style="width: 100%">
+ <el-option v-for="d in DEPT_OPTIONS" :key="d.value" :label="d.label" :value="d.value" />
+ </el-select>
+ </el-form-item>
+ <el-form-item label="鏀跨瓥绫诲繀璇�">
+ <el-switch v-model="form.requireReadConfirm" active-text="闇�闃呰纭锛堜究浜庣粺璁℃湭璇伙級" />
+ </el-form-item>
+ <el-form-item label="鍙戝竷浜�">
+ <el-input v-model="form.publisherName" placeholder="濡傦細浜哄姏璧勬簮閮�" maxlength="50" />
+ </el-form-item>
+ </el-form>
+ <template v-if="!formDialog.readonly" #footer>
+ <el-button @click="formDialog.visible = false">鍙� 娑�</el-button>
+ <el-button @click="onSave('save')">瀛樿崏绋�</el-button>
+ <el-button type="warning" @click="onSave('submit_review')">鎻愪氦瀹℃牳</el-button>
+ <el-button type="primary" @click="onSave('publish')">鐩存帴鍙戝竷</el-button>
+ </template>
+ </el-dialog>
+
+ <!-- 璇︽儏 -->
+ <el-dialog v-model="detailDialog.visible" title="鏂伴椈璇︽儏" width="880px" append-to-body destroy-on-close>
+ <NewsDetailPanel
+ :row="detailRow"
+ @like="onDetailLike"
+ @comment="onDetailComment"
+ />
+ <template #footer>
+ <el-button
+ v-if="detailRow.publishStatus === 'published' && getUnreadEmployees(detailRow).length"
+ type="warning"
+ @click="openUnreadFromDetail"
+ >
+ 鏈鎻愰啋
+ </el-button>
+ <el-button @click="openVersionFromDetail">鐗堟湰鐣欒瘉</el-button>
+ <el-button @click="detailDialog.visible = false">鍏� 闂�</el-button>
+ </template>
+ </el-dialog>
+
+ <!-- 鏈鎻愰啋 -->
+ <el-dialog
+ v-model="unreadDialog.visible"
+ :title="`鏈槄璇诲憳宸� 路 ${unreadDialog.row?.title || ''}`"
+ width="720px"
+ append-to-body
+ destroy-on-close
+ >
+ <el-alert type="warning" show-icon :closable="false" class="mb12">
+ 鏀跨瓥浼犺揪鍦烘櫙锛氬彂甯冩柊鑰冨嫟鍒跺害绛夊繀璇讳俊鎭悗锛屽彲鍕鹃�夋湭璇诲憳宸ョ敱 HR 瀹氬悜鎻愰啋锛堟紨绀烘暟鎹紝鍚庢湡瀵规帴娑堟伅涓績锛夈��
+ </el-alert>
+ <div class="unread-toolbar mb12">
+ <el-button size="small" @click="selectAllUnread">鍏ㄩ�夋湭璇�</el-button>
+ <span class="unread-stat">鍏� {{ unreadList.length }} 浜烘湭璇�</span>
+ </div>
+ <el-table
+ :data="unreadList"
+ border
+ size="small"
+ max-height="360"
+ @selection-change="onUnreadSelectionChange"
+ >
+ <el-table-column type="selection" width="48" />
+ <el-table-column prop="employeeNo" label="宸ュ彿" width="100" />
+ <el-table-column prop="name" label="濮撳悕" width="90" />
+ <el-table-column prop="deptName" label="閮ㄩ棬" min-width="120" />
+ </el-table>
+ <el-divider v-if="unreadDialog.row?.remindLogs?.length" content-position="left">鎻愰啋璁板綍</el-divider>
+ <el-timeline v-if="unreadDialog.row?.remindLogs?.length">
+ <el-timeline-item
+ v-for="(log, i) in unreadDialog.row.remindLogs"
+ :key="i"
+ :timestamp="log.time"
+ >
+ {{ log.operator }} 宸插悜 {{ log.count }} 浜哄彂閫侀槄璇绘彁閱�
+ </el-timeline-item>
+ </el-timeline>
+ <template #footer>
+ <el-button type="primary" @click="onSendRemind">鍙戦�佸畾鍚戞彁閱�</el-button>
+ <el-button @click="unreadDialog.visible = false">鍏� 闂�</el-button>
+ </template>
+ </el-dialog>
+
+ <!-- 鐗堟湰鐣欒瘉 -->
+ <el-dialog
+ v-model="versionDialog.visible"
+ :title="`鍘嗗彶鐗堟湰鐣欒瘉 路 ${versionDialog.row?.title || ''}`"
+ width="800px"
+ append-to-body
+ destroy-on-close
+ >
+ <el-alert type="info" show-icon :closable="false" class="mb12">
+ 浜夎鍙戠敓鏃跺彲鏌ラ槄鍘嗗彶鐗堟湰锛岃瘉鏄庡綋鏃跺彂甯冨唴瀹逛笌鍙戝竷鏃堕棿锛堝悎瑙勭暀璇侊級銆�
+ </el-alert>
+ <el-descriptions :column="2" border class="mb16">
+ <el-descriptions-item label="褰撳墠鐗堟湰">v{{ versionDialog.row?.versionNo || 1 }}</el-descriptions-item>
+ <el-descriptions-item label="鏈�杩戝彂甯�">{{ versionDialog.row?.publishTime || "鈥�" }}</el-descriptions-item>
+ </el-descriptions>
+ <el-table :data="versionList" border size="small" empty-text="鏆傛棤鍘嗗彶鐗堟湰">
+ <el-table-column prop="versionNo" label="鐗堟湰" width="70" align="center" />
+ <el-table-column prop="title" label="鏍囬" min-width="160" show-overflow-tooltip />
+ <el-table-column prop="changeNote" label="鍙樻洿璇存槑" width="120" />
+ <el-table-column prop="publishTime" label="鍙戝竷鏃堕棿" width="170" />
+ <el-table-column prop="archivedAt" label="褰掓。鏃堕棿" width="170" />
+ <el-table-column label="鎿嶄綔" width="90" align="center">
+ <template #default="{ row: ver }">
+ <el-button type="primary" link @click="previewVersion(ver)">鏌ョ湅</el-button>
+ </template>
+ </el-table-column>
+ </el-table>
+ <template #footer>
+ <el-button @click="versionDialog.visible = false">鍏� 闂�</el-button>
+ </template>
+ </el-dialog>
+
+ <!-- 鐗堟湰棰勮 -->
+ <el-dialog v-model="versionPreview.visible" title="鍘嗗彶鐗堟湰鍐呭" width="640px" append-to-body>
+ <p class="version-meta">
+ v{{ versionPreview.data?.versionNo }} 路 {{ versionPreview.data?.changeNote }} 路
+ {{ versionPreview.data?.publishTime }}
+ </p>
+ <div class="version-html" v-html="versionPreview.data?.contentHtml || ''" />
+ </el-dialog>
+ </div>
+</template>
+
+<script setup>
+import { Plus, RefreshRight } from "@element-plus/icons-vue";
+import { ElMessage } from "element-plus";
+import { computed, onMounted, reactive, ref } from "vue";
+import Editor from "@/components/Editor/index.vue";
+import FileUpload from "@/components/AttachmentUpload/file/index.vue";
+import { newsTypeColor } from "./enterpriseNewsUtils.js";
+import NewsDetailPanel from "./components/NewsDetailPanel.vue";
+import { useEnterpriseNews } from "./useEnterpriseNews.js";
+
+const {
+ Search,
+ NEWS_TYPE_OPTIONS,
+ PUBLISH_STATUS_OPTIONS,
+ LAYOUT_TEMPLATE_OPTIONS,
+ READ_SCOPE_OPTIONS,
+ PUBLISH_ROLE_OPTIONS,
+ DEPT_OPTIONS,
+ newsTypeLabel,
+ searchForm,
+ tableLoading,
+ page,
+ tableData,
+ tableColumn,
+ formDialog,
+ form,
+ formRef,
+ formRules,
+ detailDialog,
+ detailRow,
+ unreadDialog,
+ unreadList,
+ versionDialog,
+ getUnreadEmployees,
+ handleQuery,
+ resetSearch,
+ pagination,
+ openFormDialog,
+ openDetail,
+ openUnreadRemind,
+ openVersionHistory,
+ saveForm,
+ sendUnreadRemind,
+ toggleLike,
+ addComment,
+} = useEnterpriseNews();
+
+const galleryInput = ref("");
+const unreadSelected = ref([]);
+const versionPreview = reactive({ visible: false, data: null });
+
+const versionList = computed(() => {
+ const row = versionDialog.row;
+ if (!row) return [];
+ const history = [...(row.versions || [])];
+ return history.sort((a, b) => (b.versionNo || 0) - (a.versionNo || 0));
+});
+
+function addGalleryItem() {
+ const name = (galleryInput.value || "").trim();
+ if (!name) return;
+ form.mediaList = form.mediaList || [];
+ form.mediaList.push({ type: "image", name, url: `/mock/${name}` });
+ galleryInput.value = "";
+}
+
+function onSave(action) {
+ const ret = saveForm(action);
+ if (ret?.message) {
+ ElMessage.warning(ret.message);
+ return;
+ }
+ if (ret?.ok) {
+ ElMessage.success(action === "publish" ? "宸插彂甯�" : action === "submit_review" ? "宸叉彁浜ゅ鏍�" : "宸蹭繚瀛�");
+ }
+}
+
+function onDetailLike() {
+ toggleLike(detailRow.value);
+}
+
+function onDetailComment(text) {
+ const ret = addComment(detailRow.value, text);
+ if (ret?.message) ElMessage.warning(ret.message);
+ else if (ret?.ok) ElMessage.success("璇勮宸插彂甯�");
+}
+
+function openUnreadFromDetail() {
+ const row = detailRow.value;
+ detailDialog.visible = false;
+ openUnreadRemind(row);
+}
+
+function openVersionFromDetail() {
+ const row = detailRow.value;
+ detailDialog.visible = false;
+ openVersionHistory(row);
+}
+
+function onUnreadSelectionChange(rows) {
+ unreadSelected.value = rows.map((r) => r.userId);
+}
+
+function selectAllUnread() {
+ unreadSelected.value = unreadList.value.map((u) => u.userId);
+}
+
+function onSendRemind() {
+ const ids = unreadSelected.value;
+ const ret = sendUnreadRemind(ids);
+ if (ret?.message) {
+ ElMessage.warning(ret.message);
+ return;
+ }
+ if (ret?.ok) ElMessage.success(`宸插悜 ${ret.count} 鍚嶅憳宸ュ彂閫侀槄璇绘彁閱抈);
+}
+
+function previewVersion(ver) {
+ versionPreview.data = ver;
+ versionPreview.visible = true;
+}
+
+onMounted(() => {
+ handleQuery();
+});
+</script>
+
+<style scoped>
+.enterprise-news-page .search_form {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+}
+.search_fields {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px;
+}
+.search_actions {
+ flex-shrink: 0;
+}
+.news-type-tag {
+ font-weight: 600;
+ font-size: 13px;
+}
+.media-tag {
+ margin: 6px 8px 0 0;
+}
+.unread-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.unread-stat {
+ color: var(--el-text-color-secondary);
+ font-size: 13px;
+}
+.version-meta {
+ color: var(--el-text-color-secondary);
+ font-size: 13px;
+ margin-bottom: 12px;
+}
+.version-html {
+ padding: 12px;
+ background: var(--el-fill-color-light);
+ border-radius: 6px;
+ max-height: 400px;
+ overflow-y: auto;
+}
+.mb16 {
+ margin-bottom: 16px;
+}
+.mb12 {
+ margin-bottom: 12px;
+}
+.ml10 {
+ margin-left: 10px;
+}
+</style>
diff --git a/src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNews.js b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNews.js
new file mode 100644
index 0000000..d272b83
--- /dev/null
+++ b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNews.js
@@ -0,0 +1,440 @@
+import { Search } from "@element-plus/icons-vue";
+import dayjs from "dayjs";
+import { ElMessageBox } from "element-plus";
+import { computed, reactive, ref, watch } from "vue";
+import {
+ NEWS_TYPE_OPTIONS,
+ PUBLISH_STATUS_OPTIONS,
+ LAYOUT_TEMPLATE_OPTIONS,
+ READ_SCOPE_OPTIONS,
+ PUBLISH_ROLE_OPTIONS,
+ DEPT_OPTIONS,
+ createEmptyForm,
+ createInitialMockNews,
+ loadStoredNews,
+ saveStoredNews,
+ getUnreadEmployees,
+ readRate,
+ nextNewsNo,
+ pushVersionBeforeUpdate,
+ validateNewsForm,
+ newsTypeLabel,
+ publishStatusLabel,
+} from "./enterpriseNewsUtils.js";
+
+export function useEnterpriseNews() {
+ const stored = loadStoredNews();
+ const allRows = ref(stored?.length ? stored : createInitialMockNews());
+
+ const searchForm = reactive({
+ keyword: "",
+ newsType: "",
+ publishStatus: "",
+ publishTimeRange: [],
+ });
+
+ const tableLoading = ref(false);
+ const page = reactive({ current: 1, size: 10, total: 0 });
+
+ const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false });
+ const form = reactive(createEmptyForm());
+ const formRef = ref();
+
+ const detailDialog = reactive({ visible: false });
+ const detailRow = ref({});
+
+ const unreadDialog = reactive({ visible: false, row: null });
+ const unreadSelection = ref([]);
+
+ const versionDialog = reactive({ visible: false, row: null });
+
+ const filteredList = computed(() => {
+ let list = [...allRows.value];
+ const kw = (searchForm.keyword || "").trim().toLowerCase();
+ if (kw) {
+ list = list.filter((r) => {
+ const title = (r.title || "").toLowerCase();
+ const summary = (r.summary || "").toLowerCase();
+ const no = (r.newsNo || "").toLowerCase();
+ return title.includes(kw) || summary.includes(kw) || no.includes(kw);
+ });
+ }
+ if (searchForm.newsType) {
+ list = list.filter((r) => r.newsType === searchForm.newsType);
+ }
+ if (searchForm.publishStatus) {
+ list = list.filter((r) => r.publishStatus === searchForm.publishStatus);
+ }
+ const range = searchForm.publishTimeRange;
+ if (range?.length === 2 && range[0] && range[1]) {
+ const start = dayjs(range[0]).startOf("day");
+ const end = dayjs(range[1]).endOf("day");
+ list = list.filter((r) => {
+ if (!r.publishTime) return false;
+ const t = dayjs(r.publishTime);
+ return t.isAfter(start) && t.isBefore(end);
+ });
+ }
+ return list.sort((a, b) => (String(a.updateTime) < String(b.updateTime) ? 1 : -1));
+ });
+
+ watch(
+ filteredList,
+ (list) => {
+ page.total = list.length;
+ const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
+ if (page.current > maxPage) page.current = maxPage;
+ },
+ { immediate: true }
+ );
+
+ const tableData = computed(() => {
+ const start = (page.current - 1) * page.size;
+ return filteredList.value.slice(start, start + page.size);
+ });
+
+ const unreadList = computed(() => {
+ if (!unreadDialog.row) return [];
+ return getUnreadEmployees(unreadDialog.row);
+ });
+
+ const formRules = {
+ title: [{ required: true, message: "璇疯緭鍏ユ柊闂绘爣棰�", trigger: "blur" }],
+ newsType: [{ required: true, message: "璇烽�夋嫨鏂伴椈鍒嗙被", trigger: "change" }],
+ readScope: [{ required: true, message: "璇烽�夋嫨闃呰鑼冨洿", trigger: "change" }],
+ };
+
+ const tableColumn = ref([
+ { label: "缂栧彿", prop: "newsNo", width: 150 },
+ { label: "鏍囬", prop: "title", minWidth: 180, showOverflowTooltip: true },
+ {
+ label: "鍒嗙被",
+ prop: "newsType",
+ width: 100,
+ dataType: "slot",
+ slot: "newsType",
+ },
+ {
+ label: "鐘舵��",
+ prop: "publishStatus",
+ width: 90,
+ dataType: "tag",
+ formatData: (v) => publishStatusLabel(v),
+ formatType: (v) => {
+ const hit = PUBLISH_STATUS_OPTIONS.find((x) => x.value === v);
+ return hit?.tag || "info";
+ },
+ },
+ {
+ label: "闃呰鐜�",
+ prop: "readRecords",
+ width: 90,
+ align: "center",
+ formatData: (_, row) => `${readRate(row)}%`,
+ },
+ {
+ label: "鏈",
+ prop: "id",
+ width: 70,
+ align: "center",
+ formatData: (_, row) => {
+ if (row.publishStatus !== "published") return "鈥�";
+ return getUnreadEmployees(row).length;
+ },
+ },
+ { label: "鍙戝竷浜�", prop: "publisherName", width: 110 },
+ { label: "鍙戝竷鏃堕棿", prop: "publishTime", width: 170 },
+ { label: "鏇存柊鏃堕棿", prop: "updateTime", width: 170 },
+ {
+ dataType: "action",
+ label: "鎿嶄綔",
+ align: "center",
+ fixed: "right",
+ width: 280,
+ operation: [
+ { name: "璇︽儏", type: "text", clickFun: (row) => openDetail(row) },
+ {
+ name: "缂栬緫",
+ type: "text",
+ disabled: (row) => row.publishStatus === "archived",
+ clickFun: (row) => openFormDialog("edit", row),
+ },
+ {
+ name: "瀹℃牳",
+ type: "text",
+ disabled: (row) => row.publishStatus !== "pending_review",
+ clickFun: (row) => openReview(row),
+ },
+ {
+ name: "鏈鎻愰啋",
+ type: "text",
+ disabled: (row) =>
+ row.publishStatus !== "published" || getUnreadEmployees(row).length === 0,
+ clickFun: (row) => openUnreadRemind(row),
+ },
+ { name: "鐗堟湰鐣欒瘉", type: "text", clickFun: (row) => openVersionHistory(row) },
+ ],
+ },
+ ]);
+
+ function persist() {
+ saveStoredNews(allRows.value);
+ }
+
+ function handleQuery() {
+ tableLoading.value = true;
+ page.current = 1;
+ setTimeout(() => {
+ tableLoading.value = false;
+ }, 200);
+ }
+
+ function resetSearch() {
+ searchForm.keyword = "";
+ searchForm.newsType = "";
+ searchForm.publishStatus = "";
+ searchForm.publishTimeRange = [];
+ handleQuery();
+ }
+
+ function pagination({ page: p, limit }) {
+ page.current = p;
+ page.size = limit;
+ }
+
+ function resetForm(target = createEmptyForm()) {
+ Object.assign(form, createEmptyForm(), target);
+ }
+
+ function openFormDialog(mode, row) {
+ formDialog.mode = mode;
+ formDialog.readonly = mode === "view";
+ formDialog.title =
+ mode === "add" ? "鏂板缓浼佷笟鏂伴椈" : mode === "edit" ? "缂栬緫浼佷笟鏂伴椈" : "鏌ョ湅浼佷笟鏂伴椈";
+ if (mode === "add") {
+ resetForm({ publisherName: "褰撳墠鐢ㄦ埛" });
+ } else {
+ resetForm({
+ ...JSON.parse(JSON.stringify(row)),
+ targetDeptIds: [...(row.targetDeptIds || [])],
+ targetUserIds: [...(row.targetUserIds || [])],
+ mediaList: [...(row.mediaList || [])],
+ attachmentList: [...(row.attachmentList || [])],
+ });
+ }
+ formDialog.visible = true;
+ }
+
+ function openDetail(row) {
+ detailRow.value = { ...row };
+ detailDialog.visible = true;
+ }
+
+ function openUnreadRemind(row) {
+ unreadDialog.row = row;
+ unreadSelection.value = [];
+ unreadDialog.visible = true;
+ }
+
+ function openVersionHistory(row) {
+ versionDialog.row = row;
+ versionDialog.visible = true;
+ }
+
+ async function openReview(row) {
+ try {
+ await ElMessageBox.confirm(
+ `纭瀹℃牳閫氳繃骞跺彂甯冦��${row.title}銆嶏紵澶栭儴/琛屼笟绫绘柊闂婚渶绠$悊鍛樺鏍搞�俙,
+ "瀹℃牳鍙戝竷",
+ { type: "warning", confirmButtonText: "閫氳繃骞跺彂甯�", cancelButtonText: "鍙栨秷" }
+ );
+ const hit = allRows.value.find((r) => r.id === row.id);
+ if (!hit) return;
+ hit.publishStatus = "published";
+ hit.publishTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ hit.updateTime = hit.publishTime;
+ if (!hit.readRecords?.length) {
+ hit.readRecords = [];
+ }
+ persist();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ function saveForm(submitAction = "save") {
+ const v = validateNewsForm(form);
+ if (!v.ok) return { ok: false, message: v.message };
+
+ const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ const payload = {
+ ...JSON.parse(JSON.stringify(form)),
+ title: v.title,
+ updateTime: now,
+ };
+
+ if (formDialog.mode === "add") {
+ payload.id = `news_${Date.now()}`;
+ payload.newsNo = nextNewsNo();
+ payload.createTime = now;
+ if (submitAction === "submit_review") {
+ payload.publishStatus = "pending_review";
+ } else if (submitAction === "publish") {
+ payload.publishStatus = "published";
+ payload.publishTime = now;
+ } else {
+ payload.publishStatus = "draft";
+ }
+ allRows.value.unshift(payload);
+ } else {
+ const idx = allRows.value.findIndex((r) => r.id === form.id);
+ if (idx < 0) return { ok: false, message: "璁板綍涓嶅瓨鍦�" };
+ const prev = allRows.value[idx];
+ if (prev.publishStatus === "published" && submitAction !== "draft") {
+ pushVersionBeforeUpdate(prev, submitAction === "publish" ? "淇鍙戝竷" : "鍐呭鏇存柊");
+ }
+ if (submitAction === "submit_review") {
+ payload.publishStatus = "pending_review";
+ } else if (submitAction === "publish") {
+ payload.publishStatus = "published";
+ payload.publishTime = payload.publishTime || now;
+ }
+ payload.versions = prev.versions || [];
+ payload.versionNo = prev.versionNo || 1;
+ if (prev.publishStatus === "published" && submitAction === "publish") {
+ payload.versionNo = (prev.versionNo || 1) + 1;
+ }
+ allRows.value[idx] = { ...prev, ...payload };
+ }
+ persist();
+ formDialog.visible = false;
+ return { ok: true };
+ }
+
+ function archiveNews(row) {
+ const hit = allRows.value.find((r) => r.id === row.id);
+ if (hit) {
+ hit.publishStatus = "archived";
+ hit.updateTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ persist();
+ }
+ }
+
+ function sendUnreadRemind(selectedIds) {
+ const row = unreadDialog.row;
+ if (!row || !selectedIds?.length) return { ok: false, message: "璇烽�夋嫨瑕佹彁閱掔殑鍛樺伐" };
+ const hit = allRows.value.find((r) => r.id === row.id);
+ if (!hit) return { ok: false };
+
+ const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ hit.remindLogs = hit.remindLogs || [];
+ hit.remindLogs.push({
+ time: now,
+ count: selectedIds.length,
+ operator: "HR",
+ userIds: [...selectedIds],
+ });
+
+ const records = hit.readRecords || [];
+ selectedIds.forEach((uid) => {
+ let rec = records.find((r) => r.userId === uid);
+ if (!rec) {
+ const emp = getUnreadEmployees(hit).find((e) => e.userId === uid);
+ rec = {
+ userId: uid,
+ employeeNo: emp?.employeeNo || "",
+ name: emp?.name || "",
+ deptName: emp?.deptName || "",
+ readAt: "",
+ lastRemindAt: now,
+ };
+ records.push(rec);
+ } else {
+ rec.lastRemindAt = now;
+ }
+ });
+ hit.readRecords = records;
+ hit.updateTime = now;
+ persist();
+ unreadDialog.visible = false;
+ return { ok: true, count: selectedIds.length };
+ }
+
+ function toggleLike(row, userId = "u1", userName = "寮犱笁") {
+ const hit = allRows.value.find((r) => r.id === row.id);
+ if (!hit) return;
+ hit.likes = hit.likes || [];
+ const idx = hit.likes.findIndex((l) => l.userId === userId);
+ if (idx >= 0) {
+ hit.likes.splice(idx, 1);
+ } else {
+ hit.likes.push({ userId, name: userName, time: dayjs().format("YYYY-MM-DD HH:mm:ss") });
+ }
+ persist();
+ if (detailRow.value?.id === row.id) {
+ detailRow.value = { ...hit };
+ }
+ }
+
+ function addComment(row, content, userId = "u1", userName = "寮犱笁") {
+ const text = (content || "").trim();
+ if (!text) return { ok: false, message: "璇疯緭鍏ヨ瘎璁哄唴瀹�" };
+ const hit = allRows.value.find((r) => r.id === row.id);
+ if (!hit) return { ok: false };
+ hit.comments = hit.comments || [];
+ hit.comments.push({
+ id: `c_${Date.now()}`,
+ userId,
+ name: userName,
+ content: text,
+ time: dayjs().format("YYYY-MM-DD HH:mm:ss"),
+ });
+ persist();
+ if (detailRow.value?.id === row.id) {
+ detailRow.value = { ...hit };
+ }
+ return { ok: true };
+ }
+
+ return {
+ Search,
+ NEWS_TYPE_OPTIONS,
+ PUBLISH_STATUS_OPTIONS,
+ LAYOUT_TEMPLATE_OPTIONS,
+ READ_SCOPE_OPTIONS,
+ PUBLISH_ROLE_OPTIONS,
+ DEPT_OPTIONS,
+ newsTypeLabel,
+ searchForm,
+ tableLoading,
+ page,
+ tableData,
+ tableColumn,
+ formDialog,
+ form,
+ formRef,
+ formRules,
+ detailDialog,
+ detailRow,
+ unreadDialog,
+ unreadList,
+ unreadSelection,
+ versionDialog,
+ getUnreadEmployees,
+ readRate,
+ handleQuery,
+ resetSearch,
+ pagination,
+ openFormDialog,
+ openDetail,
+ openUnreadRemind,
+ openVersionHistory,
+ openReview,
+ saveForm,
+ archiveNews,
+ sendUnreadRemind,
+ toggleLike,
+ addComment,
+ };
+}
diff --git a/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/components/NoticeDetailPanel.vue b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/components/NoticeDetailPanel.vue
new file mode 100644
index 0000000..9a490fc
--- /dev/null
+++ b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/components/NoticeDetailPanel.vue
@@ -0,0 +1,77 @@
+<!-- NoticeAnnouncement锛氬叕鍛婅鎯呭彧璇婚潰鏉� -->
+<template>
+ <el-descriptions :column="2" border>
+ <el-descriptions-item label="鍏憡缂栧彿">{{ row.noticeNo || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鍙戝竷鐘舵��">
+ <el-tag :type="statusTag" size="small">{{ statusText }}</el-tag>
+ </el-descriptions-item>
+ <el-descriptions-item label="鍏憡绫诲瀷">
+ <span class="type-badge" :style="{ color: noticeTypeColor(row.noticeType) }">
+ {{ noticeTypeLabel(row.noticeType) }}
+ </span>
+ </el-descriptions-item>
+ <el-descriptions-item label="浼樺厛绾�">
+ <el-tag :type="priorityTag(row.priority)" size="small">{{ priorityLabel(row.priority) }}</el-tag>
+ </el-descriptions-item>
+ <el-descriptions-item label="鏍囬" :span="2">{{ row.title || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鍙戝竷鏃ユ湡">{{ row.publishDate || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="杩囨湡鏃ユ湡">{{ row.expireDate || "闀挎湡鏈夋晥" }}</el-descriptions-item>
+ <el-descriptions-item label="闃呰鑼冨洿">{{ readScopeLabel(row.readScope) }}</el-descriptions-item>
+ <el-descriptions-item label="闇�闃呰纭">{{ row.requireReadConfirm ? "鏄�" : "鍚�" }}</el-descriptions-item>
+ <el-descriptions-item label="鍙戝竷浜�">{{ row.publisherName || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鍙戝竷鏃堕棿">{{ row.publishTime || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="闃呰閲�">{{ row.readCount ?? 0 }}</el-descriptions-item>
+ </el-descriptions>
+
+ <el-divider content-position="left">鍏憡鍐呭</el-divider>
+ <div v-if="row.priority === 'urgent'" class="urgent-banner">
+ <el-alert title="绱ф�ラ�氱煡" type="error" :closable="false" show-icon />
+ </div>
+ <div v-if="row.contentHtml" class="notice-html-body" v-html="row.contentHtml" />
+ <el-empty v-else description="鏆傛棤鍐呭" :image-size="48" />
+</template>
+
+<script setup>
+import { computed } from "vue";
+import {
+ noticeTypeLabel,
+ noticeTypeColor,
+ priorityLabel,
+ priorityTag,
+ publishStatusLabel,
+ publishStatusTag,
+ readScopeLabel,
+ isExpired,
+} from "../noticeAnnouncementUtils.js";
+
+const props = defineProps({
+ row: { type: Object, default: () => ({}) },
+});
+
+const statusText = computed(() => {
+ if (isExpired(props.row) && props.row.publishStatus === "published") return "宸茶繃鏈�";
+ return publishStatusLabel(props.row.publishStatus);
+});
+
+const statusTag = computed(() => {
+ if (isExpired(props.row) && props.row.publishStatus === "published") return "";
+ return publishStatusTag(props.row.publishStatus);
+});
+</script>
+
+<style scoped>
+.type-badge {
+ font-weight: 600;
+}
+.urgent-banner {
+ margin-bottom: 12px;
+}
+.notice-html-body {
+ padding: 12px;
+ background: var(--el-fill-color-light);
+ border-radius: 6px;
+ max-height: 400px;
+ overflow-y: auto;
+ line-height: 1.7;
+}
+</style>
diff --git a/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue
new file mode 100644
index 0000000..4599ced
--- /dev/null
+++ b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue
@@ -0,0 +1,253 @@
+<!--OA妯″潡锛歂oticeAnnouncement 閫氱煡鍏憡-->
+<template>
+ <div class="app-container notice-announcement-page">
+ <div class="search_form mb20">
+ <div class="search_fields">
+ <span class="search_title">鍏抽敭璇嶏細</span>
+ <el-input
+ v-model="searchForm.keyword"
+ style="width: 200px"
+ placeholder="鏍囬 / 缂栧彿"
+ clearable
+ :prefix-icon="Search"
+ @keyup.enter="handleQuery"
+ />
+ <span class="search_title" style="margin-left: 12px">绫诲瀷锛�</span>
+ <el-select v-model="searchForm.noticeType" placeholder="鍏ㄩ儴" clearable style="width: 130px">
+ <el-option v-for="opt in NOTICE_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
+ </el-select>
+ <span class="search_title" style="margin-left: 12px">浼樺厛绾э細</span>
+ <el-select v-model="searchForm.priority" placeholder="鍏ㄩ儴" clearable style="width: 110px">
+ <el-option v-for="opt in PRIORITY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
+ </el-select>
+ <span class="search_title" style="margin-left: 12px">鐘舵�侊細</span>
+ <el-select v-model="searchForm.publishStatus" placeholder="鍏ㄩ儴" clearable style="width: 110px">
+ <el-option v-for="opt in PUBLISH_STATUS_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
+ </el-select>
+ <span class="search_title" style="margin-left: 12px">鍙戝竷鏃ユ湡锛�</span>
+ <el-date-picker
+ v-model="searchForm.publishDateRange"
+ type="daterange"
+ range-separator="-"
+ start-placeholder="寮�濮�"
+ end-placeholder="缁撴潫"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 260px"
+ clearable
+ />
+ <el-button type="primary" :icon="Search" class="ml10" @click="handleQuery">鎼滅储</el-button>
+ <el-button :icon="RefreshRight" @click="resetSearch">閲嶇疆</el-button>
+ </div>
+ <div class="search_actions">
+ <el-button type="primary" :icon="Plus" @click="openFormDialog('add')">娣诲姞鍏憡</el-button>
+ </div>
+ </div>
+
+ <div class="table_list">
+ <PIMTable
+ rowKey="id"
+ :column="tableColumn"
+ :tableData="tableData"
+ :page="page"
+ :isSelection="false"
+ :tableLoading="tableLoading"
+ :total="page.total"
+ @pagination="pagination"
+ >
+ <template #noticeType="{ row }">
+ <span class="notice-type-tag" :style="{ color: noticeTypeColor(row.noticeType) }">
+ {{ noticeTypeLabel(row.noticeType) }}
+ </span>
+ </template>
+ </PIMTable>
+ </div>
+
+ <!-- 娣诲姞 / 淇敼 -->
+ <el-dialog
+ v-model="formDialog.visible"
+ :title="formDialog.title"
+ width="800px"
+ append-to-body
+ destroy-on-close
+ class="notice-form-dialog"
+ @closed="formRef?.resetFields?.()"
+ >
+ <el-form
+ ref="formRef"
+ :model="form"
+ :rules="formRules"
+ label-width="100px"
+ :disabled="formDialog.readonly"
+ >
+ <el-form-item label="鏍囬" prop="title">
+ <el-input v-model="form.title" placeholder="璇疯緭鍏ュ叕鍛婃爣棰�" maxlength="100" show-word-limit />
+ </el-form-item>
+ <el-row :gutter="20">
+ <el-col :span="12">
+ <el-form-item label="鍏憡绫诲瀷" prop="noticeType">
+ <el-select v-model="form.noticeType" placeholder="璇烽�夋嫨" style="width: 100%" @change="onNoticeTypeChange">
+ <el-option v-for="opt in NOTICE_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
+ </el-select>
+ </el-form-item>
+ </el-col>
+ <el-col :span="12">
+ <el-form-item label="浼樺厛绾�">
+ <el-select v-model="form.priority" placeholder="璇烽�夋嫨" style="width: 100%">
+ <el-option v-for="opt in PRIORITY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
+ </el-select>
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="20">
+ <el-col :span="12">
+ <el-form-item label="鍙戝竷鏃ユ湡" prop="publishDate">
+ <el-date-picker
+ v-model="form.publishDate"
+ type="date"
+ placeholder="鍙戝竷鏃ユ湡"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 100%"
+ />
+ </el-form-item>
+ </el-col>
+ <el-col :span="12">
+ <el-form-item label="杩囨湡鏃ユ湡">
+ <el-date-picker
+ v-model="form.expireDate"
+ type="date"
+ placeholder="鍙�夛紝鐣欑┖涓洪暱鏈熸湁鏁�"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 100%"
+ clearable
+ />
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-form-item label="闃呰鑼冨洿">
+ <el-radio-group v-model="form.readScope">
+ <el-radio v-for="opt in READ_SCOPE_OPTIONS" :key="opt.value" :value="opt.value">
+ {{ opt.label }}
+ </el-radio>
+ </el-radio-group>
+ </el-form-item>
+ <el-form-item v-if="form.readScope === 'department'" label="鍙閮ㄩ棬">
+ <el-select v-model="form.targetDeptIds" multiple placeholder="閫夋嫨閮ㄩ棬" style="width: 100%">
+ <el-option v-for="d in DEPT_OPTIONS" :key="d.value" :label="d.label" :value="d.value" />
+ </el-select>
+ </el-form-item>
+ <el-form-item v-if="form.noticeType === 'emergency'" label="蹇呰纭">
+ <el-switch v-model="form.requireReadConfirm" active-text="绱ф�ラ�氱煡闇�鍛樺伐纭宸茶" />
+ </el-form-item>
+ <el-form-item label="鍐呭" prop="contentHtml">
+ <Editor v-model="form.contentHtml" :min-height="280" placeholder="璇疯緭鍏ュ唴瀹�" />
+ </el-form-item>
+ <el-form-item label="鍙戝竷浜�">
+ <el-input v-model="form.publisherName" placeholder="濡傦細琛屾斂閮�" maxlength="50" />
+ </el-form-item>
+ </el-form>
+ <template v-if="!formDialog.readonly" #footer>
+ <el-button @click="formDialog.visible = false">鍙� 娑�</el-button>
+ <el-button @click="onSave(false)">瀛樿崏绋�</el-button>
+ <el-button type="primary" @click="onSave(true)">纭� 瀹�</el-button>
+ </template>
+ </el-dialog>
+
+ <!-- 璇︽儏 -->
+ <el-dialog v-model="detailDialog.visible" title="鍏憡璇︽儏" width="800px" append-to-body destroy-on-close>
+ <NoticeDetailPanel :row="detailRow" />
+ <template #footer>
+ <el-button @click="detailDialog.visible = false">鍏� 闂�</el-button>
+ </template>
+ </el-dialog>
+ </div>
+</template>
+
+<script setup>
+import { Plus, RefreshRight } from "@element-plus/icons-vue";
+import { ElMessage } from "element-plus";
+import { onMounted } from "vue";
+import Editor from "@/components/Editor/index.vue";
+import { noticeTypeColor } from "./noticeAnnouncementUtils.js";
+import NoticeDetailPanel from "./components/NoticeDetailPanel.vue";
+import { useNoticeAnnouncement } from "./useNoticeAnnouncement.js";
+
+const {
+ Search,
+ NOTICE_TYPE_OPTIONS,
+ PRIORITY_OPTIONS,
+ PUBLISH_STATUS_OPTIONS,
+ READ_SCOPE_OPTIONS,
+ DEPT_OPTIONS,
+ noticeTypeLabel,
+ searchForm,
+ tableLoading,
+ page,
+ tableData,
+ tableColumn,
+ formDialog,
+ form,
+ formRef,
+ formRules,
+ detailDialog,
+ detailRow,
+ handleQuery,
+ resetSearch,
+ pagination,
+ openFormDialog,
+ saveForm,
+} = useNoticeAnnouncement();
+
+function onNoticeTypeChange(type) {
+ if (type === "emergency") {
+ form.priority = "urgent";
+ form.requireReadConfirm = true;
+ }
+}
+
+function onSave(publish) {
+ const ret = saveForm(publish);
+ if (ret?.message) {
+ ElMessage.warning(ret.message);
+ return;
+ }
+ if (ret?.ok) {
+ ElMessage.success(publish ? "鍏憡宸插彂甯�" : "宸蹭繚瀛樿崏绋�");
+ }
+}
+
+onMounted(() => {
+ handleQuery();
+});
+</script>
+
+<style scoped>
+.notice-announcement-page .search_form {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+}
+.search_fields {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px;
+}
+.search_actions {
+ flex-shrink: 0;
+}
+.notice-type-tag {
+ font-weight: 600;
+ font-size: 13px;
+}
+.ml10 {
+ margin-left: 10px;
+}
+.mb20 {
+ margin-bottom: 20px;
+}
+</style>
diff --git a/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/noticeAnnouncementUtils.js b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/noticeAnnouncementUtils.js
new file mode 100644
index 0000000..f6b789d
--- /dev/null
+++ b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/noticeAnnouncementUtils.js
@@ -0,0 +1,194 @@
+import dayjs from "dayjs";
+
+/** 鍏憡绫诲瀷 */
+export const NOTICE_TYPE_OPTIONS = [
+ { value: "emergency", label: "绱ф�ラ�氱煡", color: "#f56c6c" },
+ { value: "employee", label: "鍛樺伐鍏憡", color: "#409eff" },
+ { value: "company", label: "浼佷笟鍏憡", color: "#e6a23c" },
+];
+
+/** 浼樺厛绾� */
+export const PRIORITY_OPTIONS = [
+ { value: "urgent", label: "绱ф��", tag: "danger" },
+ { value: "high", label: "閲嶈", tag: "warning" },
+ { value: "normal", label: "鏅��", tag: "info" },
+];
+
+/** 鍙戝竷鐘舵�� */
+export const PUBLISH_STATUS_OPTIONS = [
+ { value: "draft", label: "鑽夌", tag: "info" },
+ { value: "published", label: "宸插彂甯�", tag: "success" },
+ { value: "withdrawn", label: "宸叉挙鍥�", tag: "warning" },
+ { value: "expired", label: "宸茶繃鏈�", tag: "" },
+];
+
+/** 闃呰鑼冨洿 */
+export const READ_SCOPE_OPTIONS = [
+ { value: "all", label: "鍏ㄥ憳鍙" },
+ { value: "department", label: "鎸囧畾閮ㄩ棬" },
+ { value: "management", label: "绠$悊灞�" },
+];
+
+export const DEPT_OPTIONS = [
+ { value: "101", label: "鐮斿彂閮�" },
+ { value: "102", label: "閿�鍞儴" },
+ { value: "103", label: "琛屾斂閮�" },
+ { value: "104", label: "璐㈠姟閮�" },
+ { value: "105", label: "鎬荤粡鍔�" },
+ { value: "106", label: "浜哄姏璧勬簮閮�" },
+];
+
+export const STORAGE_KEY = "oa_notice_announcement_v1";
+
+export function noticeTypeLabel(v) {
+ return NOTICE_TYPE_OPTIONS.find((x) => x.value === v)?.label || v || "鈥�";
+}
+
+export function noticeTypeColor(v) {
+ return NOTICE_TYPE_OPTIONS.find((x) => x.value === v)?.color || "#909399";
+}
+
+export function priorityLabel(v) {
+ return PRIORITY_OPTIONS.find((x) => x.value === v)?.label || v || "鈥�";
+}
+
+export function priorityTag(v) {
+ return PRIORITY_OPTIONS.find((x) => x.value === v)?.tag || "info";
+}
+
+export function publishStatusLabel(v) {
+ return PUBLISH_STATUS_OPTIONS.find((x) => x.value === v)?.label || v || "鈥�";
+}
+
+export function publishStatusTag(v) {
+ return PUBLISH_STATUS_OPTIONS.find((x) => x.value === v)?.tag || "info";
+}
+
+export function readScopeLabel(v) {
+ return READ_SCOPE_OPTIONS.find((x) => x.value === v)?.label || v || "鈥�";
+}
+
+export function createEmptyForm() {
+ return {
+ id: "",
+ noticeNo: "",
+ title: "",
+ noticeType: "employee",
+ priority: "normal",
+ contentHtml: "",
+ publishDate: dayjs().format("YYYY-MM-DD"),
+ expireDate: "",
+ readScope: "all",
+ targetDeptIds: [],
+ requireReadConfirm: false,
+ publishStatus: "draft",
+ publisherName: "",
+ publishTime: "",
+ readCount: 0,
+ createTime: "",
+ updateTime: "",
+ };
+}
+
+export function createInitialMockNotices() {
+ return [
+ {
+ id: "notice_1",
+ noticeNo: "NA202605100001",
+ title: "鍏充簬鍙伴澶╂皵灞呭鍔炲叕鐨勭揣鎬ラ�氱煡",
+ noticeType: "emergency",
+ priority: "urgent",
+ contentHtml:
+ "<p><strong>绱ф�ラ�氱煡</strong></p><p>鍙楀彴椋庡奖鍝嶏紝鏄庢棩锛�5鏈�17鏃ワ級鍏ㄤ綋鍛樺伐灞呭鍔炲叕锛岃鍚勯儴闂ㄨ礋璐d汉鍋氬ソ宸ヤ綔瀹夋帓涓庡憳宸ヨ仈缁溿��</p>",
+ publishDate: "2026-05-16",
+ expireDate: "2026-05-20",
+ readScope: "all",
+ targetDeptIds: [],
+ requireReadConfirm: true,
+ publishStatus: "published",
+ publisherName: "琛屾斂閮�",
+ publishTime: "2026-05-16 08:30:00",
+ readCount: 128,
+ createTime: "2026-05-16 08:00:00",
+ updateTime: "2026-05-16 08:30:00",
+ },
+ {
+ id: "notice_2",
+ noticeNo: "NA202605120002",
+ title: "2026骞寸鍗堣妭鏀惧亣瀹夋帓鍏憡",
+ noticeType: "employee",
+ priority: "high",
+ contentHtml:
+ "<p>鏍规嵁鍥藉娉曞畾鑺傚亣鏃ュ畨鎺掞紝绔崍鑺傛斁鍋囨椂闂翠负 6鏈�8鏃ヨ嚦6鏈�10鏃ワ紝鍏�3澶┿��6鏈�7鏃ワ紙鍛ㄥ叚锛夋甯镐笂鐝��</p>",
+ publishDate: "2026-05-12",
+ expireDate: "2026-06-15",
+ readScope: "all",
+ targetDeptIds: [],
+ requireReadConfirm: false,
+ publishStatus: "published",
+ publisherName: "浜哄姏璧勬簮閮�",
+ publishTime: "2026-05-12 10:00:00",
+ readCount: 256,
+ createTime: "2026-05-12 09:30:00",
+ updateTime: "2026-05-12 10:00:00",
+ },
+ {
+ id: "notice_3",
+ noticeNo: "NA202605140003",
+ title: "鍔炲叕鍖哄煙娑堥槻婕旂粌閫氱煡",
+ noticeType: "company",
+ priority: "normal",
+ contentHtml: "<p>瀹氫簬 5鏈�25鏃� 14:00 鍦ㄦ�婚儴澶фゼ杩涜娑堥槻婕旂粌锛岃鍚勯儴闂ㄦ彁鍓嶅畨鎺掍汉鍛樺弬鍔犮��</p>",
+ publishDate: "2026-05-14",
+ expireDate: "2026-05-26",
+ readScope: "department",
+ targetDeptIds: ["101", "102", "103"],
+ requireReadConfirm: false,
+ publishStatus: "draft",
+ publisherName: "琛屾斂閮�",
+ publishTime: "",
+ readCount: 0,
+ createTime: "2026-05-14 15:00:00",
+ updateTime: "2026-05-14 15:00:00",
+ },
+ ];
+}
+
+export function loadStoredNotices() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return null;
+ const data = JSON.parse(raw);
+ return Array.isArray(data) ? data : null;
+ } catch {
+ return null;
+ }
+}
+
+export function saveStoredNotices(rows) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(rows));
+ } catch {
+ /* ignore */
+ }
+}
+
+export function nextNoticeNo() {
+ return `NA${dayjs().format("YYYYMMDD")}${String(Math.floor(Math.random() * 9000) + 1000)}`;
+}
+
+export function validateNoticeForm(form) {
+ const title = (form.title || "").trim();
+ if (!title) return { ok: false, message: "璇疯緭鍏ュ叕鍛婃爣棰�" };
+ if (!form.publishDate) return { ok: false, message: "璇烽�夋嫨鍙戝竷鏃ユ湡" };
+ if (!form.noticeType) return { ok: false, message: "璇烽�夋嫨鍏憡绫诲瀷" };
+ if (form.readScope === "department" && !(form.targetDeptIds || []).length) {
+ return { ok: false, message: "璇烽�夋嫨鍙閮ㄩ棬" };
+ }
+ return { ok: true, title };
+}
+
+export function isExpired(row) {
+ if (!row.expireDate) return false;
+ return dayjs(row.expireDate).endOf("day").isBefore(dayjs());
+}
diff --git a/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/useNoticeAnnouncement.js b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/useNoticeAnnouncement.js
new file mode 100644
index 0000000..c9a9d9f
--- /dev/null
+++ b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/useNoticeAnnouncement.js
@@ -0,0 +1,332 @@
+import { Search } from "@element-plus/icons-vue";
+import dayjs from "dayjs";
+import { ElMessageBox } from "element-plus";
+import { computed, reactive, ref, watch } from "vue";
+import {
+ NOTICE_TYPE_OPTIONS,
+ PRIORITY_OPTIONS,
+ PUBLISH_STATUS_OPTIONS,
+ READ_SCOPE_OPTIONS,
+ DEPT_OPTIONS,
+ createEmptyForm,
+ createInitialMockNotices,
+ loadStoredNotices,
+ saveStoredNotices,
+ nextNoticeNo,
+ validateNoticeForm,
+ noticeTypeLabel,
+ priorityLabel,
+ publishStatusLabel,
+ isExpired,
+} from "./noticeAnnouncementUtils.js";
+
+export function useNoticeAnnouncement() {
+ const stored = loadStoredNotices();
+ const allRows = ref(stored?.length ? stored : createInitialMockNotices());
+
+ const searchForm = reactive({
+ keyword: "",
+ noticeType: "",
+ priority: "",
+ publishStatus: "",
+ publishDateRange: [],
+ });
+
+ const tableLoading = ref(false);
+ const page = reactive({ current: 1, size: 10, total: 0 });
+
+ const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false });
+ const form = reactive(createEmptyForm());
+ const formRef = ref();
+
+ const detailDialog = reactive({ visible: false });
+ const detailRow = ref({});
+
+ const filteredList = computed(() => {
+ let list = [...allRows.value];
+ const kw = (searchForm.keyword || "").trim().toLowerCase();
+ if (kw) {
+ list = list.filter((r) => (r.title || "").toLowerCase().includes(kw) || (r.noticeNo || "").toLowerCase().includes(kw));
+ }
+ if (searchForm.noticeType) list = list.filter((r) => r.noticeType === searchForm.noticeType);
+ if (searchForm.priority) list = list.filter((r) => r.priority === searchForm.priority);
+ if (searchForm.publishStatus) list = list.filter((r) => r.publishStatus === searchForm.publishStatus);
+ const range = searchForm.publishDateRange;
+ if (range?.length === 2 && range[0] && range[1]) {
+ const start = dayjs(range[0]).startOf("day");
+ const end = dayjs(range[1]).endOf("day");
+ list = list.filter((r) => {
+ if (!r.publishDate) return false;
+ const t = dayjs(r.publishDate);
+ return !t.isBefore(start) && !t.isAfter(end);
+ });
+ }
+ return list.sort((a, b) => (String(a.updateTime) < String(b.updateTime) ? 1 : -1));
+ });
+
+ watch(
+ filteredList,
+ (list) => {
+ page.total = list.length;
+ const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
+ if (page.current > maxPage) page.current = maxPage;
+ },
+ { immediate: true }
+ );
+
+ const tableData = computed(() => {
+ const start = (page.current - 1) * page.size;
+ return filteredList.value.slice(start, start + page.size);
+ });
+
+ const formRules = {
+ title: [{ required: true, message: "璇疯緭鍏ュ叕鍛婃爣棰�", trigger: "blur" }],
+ publishDate: [{ required: true, message: "璇烽�夋嫨鍙戝竷鏃ユ湡", trigger: "change" }],
+ noticeType: [{ required: true, message: "璇烽�夋嫨鍏憡绫诲瀷", trigger: "change" }],
+ };
+
+ const tableColumn = ref([
+ { label: "缂栧彿", prop: "noticeNo", width: 150 },
+ { label: "鏍囬", prop: "title", minWidth: 200, showOverflowTooltip: true },
+ {
+ label: "绫诲瀷",
+ prop: "noticeType",
+ width: 100,
+ dataType: "slot",
+ slot: "noticeType",
+ },
+ {
+ label: "浼樺厛绾�",
+ prop: "priority",
+ width: 90,
+ dataType: "tag",
+ formatData: (v) => priorityLabel(v),
+ formatType: (v) => {
+ const hit = PRIORITY_OPTIONS.find((x) => x.value === v);
+ return hit?.tag || "info";
+ },
+ },
+ {
+ label: "鐘舵��",
+ prop: "publishStatus",
+ width: 90,
+ dataType: "tag",
+ formatData: (v, row) => (isExpired(row) && v === "published" ? "宸茶繃鏈�" : publishStatusLabel(v)),
+ formatType: (v, row) => {
+ if (isExpired(row) && v === "published") return "";
+ const hit = PUBLISH_STATUS_OPTIONS.find((x) => x.value === v);
+ return hit?.tag || "info";
+ },
+ },
+ { label: "鍙戝竷鏃ユ湡", prop: "publishDate", width: 120 },
+ { label: "鍙戝竷浜�", prop: "publisherName", width: 110 },
+ { label: "闃呰閲�", prop: "readCount", width: 80, align: "center" },
+ {
+ dataType: "action",
+ label: "鎿嶄綔",
+ align: "center",
+ fixed: "right",
+ width: 220,
+ operation: [
+ { name: "璇︽儏", type: "text", clickFun: (row) => openDetail(row) },
+ {
+ name: "淇敼",
+ type: "text",
+ disabled: (row) => row.publishStatus === "withdrawn",
+ clickFun: (row) => openFormDialog("edit", row),
+ },
+ {
+ name: "鍙戝竷",
+ type: "text",
+ disabled: (row) => row.publishStatus === "published",
+ clickFun: (row) => publishNotice(row),
+ },
+ {
+ name: "鎾ゅ洖",
+ type: "text",
+ disabled: (row) => row.publishStatus !== "published",
+ clickFun: (row) => withdrawNotice(row),
+ },
+ { name: "鍒犻櫎", type: "text", clickFun: (row) => deleteNotice(row) },
+ ],
+ },
+ ]);
+
+ function persist() {
+ saveStoredNotices(allRows.value);
+ }
+
+ function handleQuery() {
+ tableLoading.value = true;
+ page.current = 1;
+ setTimeout(() => {
+ tableLoading.value = false;
+ }, 200);
+ }
+
+ function resetSearch() {
+ searchForm.keyword = "";
+ searchForm.noticeType = "";
+ searchForm.priority = "";
+ searchForm.publishStatus = "";
+ searchForm.publishDateRange = [];
+ handleQuery();
+ }
+
+ function pagination({ page: p, limit }) {
+ page.current = p;
+ page.size = limit;
+ }
+
+ function resetForm(target = createEmptyForm()) {
+ Object.assign(form, createEmptyForm(), target);
+ }
+
+ function openFormDialog(mode, row) {
+ formDialog.mode = mode;
+ formDialog.readonly = mode === "view";
+ formDialog.title =
+ mode === "add" ? "娣诲姞鍏憡" : mode === "edit" ? "淇敼鍏憡" : "鏌ョ湅鍏憡";
+ if (mode === "add") {
+ resetForm({ publisherName: "褰撳墠鐢ㄦ埛", priority: "normal" });
+ } else {
+ resetForm({
+ ...JSON.parse(JSON.stringify(row)),
+ targetDeptIds: [...(row.targetDeptIds || [])],
+ });
+ }
+ formDialog.visible = true;
+ }
+
+ function openDetail(row) {
+ detailRow.value = { ...row };
+ detailDialog.visible = true;
+ }
+
+ function saveForm(publish = false) {
+ const v = validateNoticeForm(form);
+ if (!v.ok) return { ok: false, message: v.message };
+
+ const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ const payload = {
+ ...JSON.parse(JSON.stringify(form)),
+ title: v.title,
+ updateTime: now,
+ };
+
+ if (form.noticeType === "emergency" && payload.priority === "normal") {
+ payload.priority = "urgent";
+ }
+
+ if (formDialog.mode === "add") {
+ payload.id = `notice_${Date.now()}`;
+ payload.noticeNo = nextNoticeNo();
+ payload.createTime = now;
+ payload.readCount = 0;
+ if (publish) {
+ payload.publishStatus = "published";
+ payload.publishTime = now;
+ } else {
+ payload.publishStatus = "draft";
+ }
+ allRows.value.unshift(payload);
+ } else {
+ const idx = allRows.value.findIndex((r) => r.id === form.id);
+ if (idx < 0) return { ok: false, message: "璁板綍涓嶅瓨鍦�" };
+ const prev = allRows.value[idx];
+ if (publish) {
+ payload.publishStatus = "published";
+ payload.publishTime = payload.publishTime || now;
+ }
+ allRows.value[idx] = { ...prev, ...payload };
+ }
+ persist();
+ formDialog.visible = false;
+ return { ok: true };
+ }
+
+ async function publishNotice(row) {
+ try {
+ await ElMessageBox.confirm(`纭鍙戝竷銆�${row.title}銆嶏紵`, "鍙戝竷鍏憡", {
+ type: "warning",
+ confirmButtonText: "鍙戝竷",
+ cancelButtonText: "鍙栨秷",
+ });
+ const hit = allRows.value.find((r) => r.id === row.id);
+ if (!hit) return;
+ const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ hit.publishStatus = "published";
+ hit.publishTime = now;
+ hit.updateTime = now;
+ if (hit.noticeType === "emergency") hit.priority = "urgent";
+ persist();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async function withdrawNotice(row) {
+ try {
+ await ElMessageBox.confirm(`纭鎾ゅ洖銆�${row.title}銆嶏紵鎾ゅ洖鍚庡憳宸ョ灏嗕笉鍐嶅睍绀恒�俙, "鎾ゅ洖鍏憡", {
+ type: "warning",
+ confirmButtonText: "鎾ゅ洖",
+ cancelButtonText: "鍙栨秷",
+ });
+ const hit = allRows.value.find((r) => r.id === row.id);
+ if (!hit) return;
+ hit.publishStatus = "withdrawn";
+ hit.updateTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ persist();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async function deleteNotice(row) {
+ try {
+ await ElMessageBox.confirm(`纭鍒犻櫎銆�${row.title}銆嶏紵姝ゆ搷浣滀笉鍙仮澶嶃�俙, "鍒犻櫎鍏憡", {
+ type: "warning",
+ confirmButtonText: "鍒犻櫎",
+ cancelButtonText: "鍙栨秷",
+ });
+ allRows.value = allRows.value.filter((r) => r.id !== row.id);
+ persist();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ return {
+ Search,
+ NOTICE_TYPE_OPTIONS,
+ PRIORITY_OPTIONS,
+ PUBLISH_STATUS_OPTIONS,
+ READ_SCOPE_OPTIONS,
+ DEPT_OPTIONS,
+ noticeTypeLabel,
+ searchForm,
+ tableLoading,
+ page,
+ tableData,
+ tableColumn,
+ formDialog,
+ form,
+ formRef,
+ formRules,
+ detailDialog,
+ detailRow,
+ isExpired,
+ handleQuery,
+ resetSearch,
+ pagination,
+ openFormDialog,
+ openDetail,
+ saveForm,
+ publishNotice,
+ withdrawNotice,
+ deleteNotice,
+ };
+}
--
Gitblit v1.9.3