yyb
9 小时以前 dacc95761cf7090c628fc37a5d4f8bb825ccbbb0
企业新闻和通知公告
已添加8个文件
2301 ■■■■■ 文件已修改
src/views/officeProcessAutomation/EnterpriseNews/news-manage/components/NewsDetailPanel.vue 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js 375 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue 461 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNews.js 440 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/components/NoticeDetailPanel.vue 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue 253 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/noticeAnnouncementUtils.js 194 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/useNoticeAnnouncement.js 332 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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">正文内容</el-divider>
  <div v-if="row.contentHtml" class="news-html-body" v-html="row.contentHtml" />
  <el-empty v-else description="暂无正文" :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>
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: "政策解读", 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 };
}
src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,461 @@
<!--OA模块:EnterpriseNews ä¼ä¸šæ–°é—»-->
<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="正文" 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>
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,
  };
}
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>
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,253 @@
<!--OA模块:NoticeAnnouncement é€šçŸ¥å…¬å‘Š-->
<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>
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日)全体员工居家办公,请各部门负责人做好工作安排与员工联络。</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());
}
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,
  };
}