更新若干功能

This commit is contained in:
hero920103 committed 2026-10-21 17:45:50 +08:00
1 parent 892ba5886d
commit 79d5ae6b66
45 files changed
+6755 -38

No files matched your search

+8
View File
@@ -73,6 +73,14 @@ export function changeBusinessStage(id, stage) {
return request({ url: `/backend/crm/business/${id}/stage`, method: "post", data: { stage } });
}
/**
* 商机状态流转:1跟进中 / 3已废弃。
* 「已转化项目(2)」不走本接口,须用 convertBusinessToProject 完成(会锁定商机)。
*/
export function changeBusinessStatus(id, status) {
return request({ url: `/backend/crm/business/${id}/status`, method: "post", data: { status } });
}
/* --------------------------------- 项目 --------------------------------- */
export function getProjectList(params) {
+81
View File
@@ -0,0 +1,81 @@
import request from '@/utils/request'
// OA 工作日报
// 响应拦截器只返回 {code, data, msg} 包装,调用方需自行取 res.data
// 当前用户身份与可见范围:{ user_id, org_id, is_admin, can_view_team, window_start, window_end, backfill_days }
export function getDailyReportProfile() {
return request({
url: '/backend/oa/dailyreport/profile',
method: 'get'
})
}
// 我的日报列表:params = { page, pageSize, start_date, end_date }
// data = { list, total, summary: { month_total, month_submitted, today_status, today_report_id, window_start, window_end } }
export function getMyDailyReports(params) {
return request({
url: '/backend/oa/dailyreport/mine/list',
method: 'get',
params
})
}
// 保存日报:{ date, today_done, tomorrow_plan, issues, attachments, submit }
// submit=true 提交,false 存草稿;同一人同一天只会有一条记录
export function saveDailyReport(data) {
return request({
url: '/backend/oa/dailyreport/save',
method: 'post',
data
})
}
// 日报详情:{ report, comments, can_edit, can_delete, can_comment }
export function getDailyReportDetail(id) {
return request({
url: `/backend/oa/dailyreport/detail/${id}`,
method: 'get'
})
}
export function deleteDailyReport(id) {
return request({
url: `/backend/oa/dailyreport/delete/${id}`,
method: 'delete'
})
}
// 团队汇总:params = { date, org_id, keyword, page, pageSize }
// data = { date, list, total, stats: { total, submitted, missing, rate } }
export function getTeamDailyReports(params) {
return request({
url: '/backend/oa/dailyreport/team/status',
method: 'get',
params
})
}
// 团队筛选部门:data = { list: [{ id, parent_id, name, member_count }] }
export function getTeamDailyReportOrgs() {
return request({
url: '/backend/oa/dailyreport/team/orgs',
method: 'get'
})
}
// 评论 / 回复:{ report_id, content, reply_to_id }
export function addDailyReportComment(data) {
return request({
url: '/backend/oa/dailyreport/comment',
method: 'post',
data
})
}
export function deleteDailyReportComment(id) {
return request({
url: `/backend/oa/dailyreport/comment/${id}`,
method: 'delete'
})
}
+9
View File
@@ -0,0 +1,9 @@
import request from '@/utils/request';
// 平台「小程序配置」中已启用的小程序(含二维码地址,公开接口无需登录)
export function getSiteMiniApps() {
return request({
url: '/site/miniapp',
method: 'get',
});
}
+141 -2
View File
@@ -1,7 +1,10 @@
<template>
<el-aside
:width="width"
:class="['common-aside', { 'is-collapsed': isCollapse }]"
:class="[
'common-aside',
{ 'is-collapsed': isCollapse, 'has-footer': miniApps.length > 0 },
]"
>
<!-- 加载状态 -->
<div v-if="loading" class="loading-spinner">
@@ -164,6 +167,26 @@
</el-sub-menu>
</template>
</el-menu>
<!-- 底部:平台「小程序配置」里的小程序码,吸附侧栏底部 -->
<div v-if="miniApps.length" class="aside-footer">
<div class="mini-qrcode" :class="{ 'is-collapsed': isCollapse }">
<el-image
:src="miniApps[0].qrcodeSrc"
:preview-src-list="miniQrcodePreview"
:initial-index="0"
preview-teleported
hide-on-click-modal
fit="contain"
class="mini-qrcode-img"
:title="miniApps[0].name || '小程序码'"
/>
<div v-if="!isCollapse" class="mini-qrcode-name">
{{ miniApps[0].name || "小程序"
}}<span v-if="miniApps.length > 1"> 等 {{ miniApps.length }} 个</span>
</div>
</div>
</div>
</el-aside>
</template>
@@ -172,6 +195,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRouter, useRoute } from "vue-router";
import { Document, Warning } from "@element-plus/icons-vue";
import { useAllDataStore, useMenuStore } from "@/stores";
import { getSiteMiniApps } from "@/api/site";
const emit = defineEmits(["menu-click"]);
@@ -195,6 +219,37 @@ const activeBgColor = ref("#3973FF");
const currentModuleId = ref(null);
// ---------- 底部小程序码 ----------
// 来源:平台端「系统设置 → 小程序配置」中已启用且上传了二维码的小程序,
// 通过公开接口 /site/miniapp 读取;相对地址按接口基址补全。
const miniApps = ref([]);
const miniQrcodePreview = computed(() =>
miniApps.value.map((app) => app.qrcodeSrc),
);
const resolveAssetUrl = (url) => {
const value = String(url || "").trim().replace(/\\/g, "/");
if (!value) return "";
if (/^(https?:\/\/|data:|blob:)/i.test(value)) return value;
const base = String(import.meta.env.VITE_API_BASE_URL || "").replace(
/\/$/,
"",
);
return `${base}/${value.replace(/^\/+/, "")}`;
};
const fetchMiniApps = async () => {
try {
const res = await getSiteMiniApps();
if (res.code !== 200) return;
miniApps.value = (res.data?.list || [])
.filter((app) => String(app.qrcode_url || "").trim())
.map((app) => ({ ...app, qrcodeSrc: resolveAssetUrl(app.qrcode_url) }));
} catch (error) {
// 侧栏二维码属于辅助信息,加载失败静默忽略,不影响菜单
}
};
const findMenuItem = (menus, targetIndex) => {
for (const menu of menus) {
if (menu.path === targetIndex) {
@@ -445,6 +500,8 @@ watch(
);
onMounted(() => {
fetchMiniApps();
if (!menuStore.menus || menuStore.menus.length === 0) {
setTimeout(() => {
fetchMenus();
@@ -462,6 +519,8 @@ onUnmounted(() => {
<style scoped lang="less">
.common-aside {
height: 100%;
// 底部二维码高度:菜单区据此预留滚动空间(无二维码时为 0)
--aside-footer-height: 0px;
transition:
width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.3s ease;
@@ -517,11 +576,13 @@ h3 {
// 菜单样式
:deep(.el-menu) {
border-right: none;
// 标题已移到 el-menu 之外,这里只减掉标题区高度
// 标题已移到 el-menu 之外,这里只减掉标题区高度;
// 底部再预留 footer 高度,保证最后一项能滚到二维码上方
height: calc(100% - 60px);
overflow-y: auto;
overflow-x: hidden;
padding: 16px 8px;
padding-bottom: calc(16px + var(--aside-footer-height));
background: transparent;
// 自定义滚动条样式
@@ -707,6 +768,84 @@ h3 {
text-overflow: ellipsis;
}
// 底部小程序码:绝对定位吸附侧栏底部(Main.vue 将 el-aside 强制为 display:block,
// 因此不用 flex 布局,避免样式被覆盖后失效)
.common-aside.has-footer {
--aside-footer-height: 148px;
}
.common-aside.is-collapsed.has-footer {
--aside-footer-height: 72px;
}
.aside-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
height: var(--aside-footer-height);
box-sizing: border-box;
padding: 12px 8px 14px;
border-top: 1px solid rgba(255, 255, 255, 0.16);
display: flex;
align-items: center;
justify-content: center;
html.dark & {
border-top-color: rgba(255, 255, 255, 0.08);
}
}
.mini-qrcode {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
cursor: zoom-in;
}
.mini-qrcode-img {
width: 96px;
height: 96px;
padding: 4px;
border-radius: 10px;
background: #fff;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
transition: width 0.3s ease, height 0.3s ease;
// el-image 的 img 不自带尺寸,需显式铺满容器(fit=contain 保留比例)
:deep(.el-image__inner) {
width: 100%;
height: 100%;
}
}
.mini-qrcode-name {
max-width: 180px;
font-size: 12px;
line-height: 1.4;
color: rgba(255, 255, 255, 0.8);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
html.dark & {
color: var(--el-text-color-regular);
}
}
// 折叠态只留二维码,尺寸收紧避免撑破 64px 窄栏
.mini-qrcode.is-collapsed {
.mini-qrcode-img {
width: 40px;
height: 40px;
padding: 2px;
border-radius: 6px;
}
}
// 响应式设计
// 注意:手机端(≤768px)侧栏的抽屉式布局由 Main.vue 统一控制(fixed 定位 + transform 动画),
// 此处不要再对 .common-aside 设置 width,避免覆盖父级布局逻辑。
@@ -7,11 +7,37 @@
@update:model-value="handleClose"
@opened="loadAll"
>
<!-- 标题 + 右上角阶段流转(与合同 / 项目详情一致) -->
<!-- 标题 + 右上角状态 / 阶段流转(与合同 / 项目详情一致) -->
<template #header>
<div class="drawer-header">
<span class="drawer-title">{{ business?.business_name || "商机详情" }}</span>
<div v-if="business" class="drawer-actions">
<!-- 状态流转:跟不下去的商机可废弃,废弃后可恢复为跟进中 -->
<el-dropdown
v-if="statusOptionsExcept(business.status).length"
trigger="click"
@command="(cmd) => handleChangeStatus(cmd)"
>
<el-button
link
type="info"
size="small"
:disabled="Number(business.locked) === 1 || Number(business.status) === 2"
>
状态流转<el-icon style="margin-left: 2px"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="i in statusOptionsExcept(business.status)"
:key="i.value"
:command="i.value"
>
{{ i.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown
v-if="stageOptionsExcept(business.stage).length"
trigger="click"
@@ -21,7 +47,11 @@
link
type="warning"
size="small"
:disabled="Number(business.locked) === 1 || Number(business.status) === 2"
:disabled="
Number(business.locked) === 1 ||
Number(business.status) === 2 ||
Number(business.status) === 3
"
>
阶段流转<el-icon style="margin-left: 2px"><ArrowDown /></el-icon>
</el-button>
@@ -42,6 +72,14 @@
</template>
<div v-if="business" class="biz-detail">
<el-alert
v-if="Number(business.status) === 3"
type="info"
:closable="false"
show-icon
title="该商机已废弃,可点击右上角「状态流转」恢复为「跟进中」后继续推进"
style="margin-bottom: 12px"
/>
<el-tabs v-model="activeTab" tab-position="left" class="detail-tabs">
<!-- 基本信息 -->
<el-tab-pane label="基本信息" name="basic">
@@ -98,8 +136,13 @@
import { ref, reactive, watch } from "vue";
import { ArrowDown } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { changeBusinessStage } from "@/api/crmPipeline";
import { BUSINESS_STAGE_OPTIONS, businessStageText } from "../../dict";
import { changeBusinessStage, changeBusinessStatus } from "@/api/crmPipeline";
import {
BUSINESS_STAGE_OPTIONS,
BUSINESS_STATUS_OPTIONS,
businessStageText,
businessStatusText,
} from "../../dict";
import DetailBasic from "./detail_basic.vue";
import DetailContact from "./detail_contact.vue";
import DetailFollow from "./detail_follow.vue";
@@ -139,6 +182,29 @@ function handleClose() {
emit("update:visible", false);
}
/**
* 可流转的状态:排除当前状态与「已转化项目」。
* 已转化必须走「转项目」流程(会锁定商机),不能只改状态。
*/
function statusOptionsExcept(cur) {
return BUSINESS_STATUS_OPTIONS.filter(
(i) => String(i.value) !== String(cur) && String(i.value) !== "2"
);
}
/** 状态流转:仅更新状态字段,成功后同步本地展示并通知列表刷新 */
async function handleChangeStatus(status) {
if (!props.business?.id) return;
try {
await changeBusinessStatus(props.business.id, Number(status));
ElMessage.success(`状态已更新为「${businessStatusText(status)}」`);
props.business.status = Number(status);
emit("refresh");
} catch (e) {
ElMessage.error(e.message || "状态更新失败");
}
}
/** 可流转的阶段:排除当前阶段 */
function stageOptionsExcept(cur) {
return BUSINESS_STAGE_OPTIONS.filter((i) => String(i.value) !== String(cur));
+53 -3
View File
@@ -82,7 +82,7 @@
<el-table-column label="下次联系时间" width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center" fixed="right">
<el-table-column label="操作" width="280" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" :disabled="row.locked === 1" @click="openEdit(row)">
编辑
@@ -91,11 +91,31 @@
link
type="warning"
size="small"
:disabled="row.locked === 1 || row.status === 2"
:disabled="row.locked === 1 || row.status === 2 || row.status === 3"
@click="openConvert(row)"
>
转项目
</el-button>
<el-button
v-if="row.status === 3"
link
type="success"
size="small"
:disabled="row.locked === 1"
@click="handleRestore(row)"
>
恢复
</el-button>
<el-button
v-else
link
type="info"
size="small"
:disabled="row.locked === 1 || row.status === 2"
@click="handleDiscard(row)"
>
废弃
</el-button>
<el-button
link
type="danger"
@@ -138,7 +158,7 @@
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
import { getBusinessList, deleteBusiness } from "@/api/crmPipeline";
import { getBusinessList, deleteBusiness, changeBusinessStatus } from "@/api/crmPipeline";
import BusinessEdit from "./components/edit.vue";
import BusinessConvert from "./components/convert.vue";
import BusinessDetail from "./components/detail.vue";
@@ -228,6 +248,36 @@ function handleConvertSuccess() {
ElMessage.success("商机已转化为项目,可在项目管理中继续跟进");
}
/** 废弃商机:保留全部过程数据,仅把状态置为已废弃,可随时恢复 */
async function handleDiscard(row) {
try {
await ElMessageBox.confirm(
`确定废弃商机「${row.business_name}」吗?废弃后可随时恢复为「跟进中」。`,
"废弃确认",
{ type: "warning" }
);
await changeBusinessStatus(row.id, 3);
ElMessage.success("商机已废弃");
fetchList();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "废弃失败");
}
}
/** 恢复商机:把已废弃的商机恢复为跟进中 */
async function handleRestore(row) {
try {
await ElMessageBox.confirm(`确定将商机「${row.business_name}」恢复为「跟进中」吗?`, "恢复确认", {
type: "info",
});
await changeBusinessStatus(row.id, 1);
ElMessage.success("商机已恢复为跟进中");
fetchList();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "恢复失败");
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确定删除商机「${row.business_name}」吗?删除后不可恢复。`, "删除确认", {
@@ -63,6 +63,14 @@
title="该线索已转化为商机并锁定,后续请在商机管理中操作;商机详情会按阶段展示线索 + 商机的联系人、跟进记录与附件"
style="margin-bottom: 12px"
/>
<el-alert
v-if="Number(clue.status) === 3"
type="info"
:closable="false"
show-icon
title="该线索已废弃,可点击右上角「状态流转」恢复为「跟进中」后继续跟进"
style="margin-bottom: 12px"
/>
<el-tabs v-model="activeTab" tab-position="left" class="detail-tabs">
<!-- 基本信息 -->
<el-tab-pane label="基本信息" name="basic">
+54 -4
View File
@@ -84,18 +84,38 @@
<el-table-column label="创建时间" width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<!-- 编辑 / 删除已收口到「线索详情」内,列表只保留转化 -->
<!-- 编辑 / 删除已收口到「线索详情」内,列表保留转化 / 废弃 -->
<el-button
link
type="warning"
size="small"
:disabled="row.locked === 1 || row.status === 2"
:disabled="row.locked === 1 || row.status === 2 || row.status === 3"
@click="openConvert(row)"
>
转化
</el-button>
<el-button
v-if="row.status === 3"
link
type="success"
size="small"
:disabled="row.locked === 1"
@click="handleRestore(row)"
>
恢复
</el-button>
<el-button
v-else
link
type="info"
size="small"
:disabled="row.locked === 1 || row.status === 2"
@click="handleDiscard(row)"
>
废弃
</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无线索" :image-size="80" /></template>
@@ -130,7 +150,7 @@
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
import { getClueList, deleteClue } from "@/api/crmPipeline";
import { getClueList, deleteClue, changeClueStatus } from "@/api/crmPipeline";
import ClueEdit from "./components/edit.vue";
import ClueConvert from "./components/convert.vue";
import ClueDetail from "./components/detail.vue";
@@ -218,6 +238,36 @@ function handleConvertSuccess() {
ElMessage.success("线索已转化,可在商机管理中继续跟进");
}
/** 废弃线索:保留全部过程数据,仅把状态置为已废弃,可随时恢复 */
async function handleDiscard(row) {
try {
await ElMessageBox.confirm(
`确定废弃线索「${row.clue_name}」吗?废弃后可随时恢复为「跟进中」。`,
"废弃确认",
{ type: "warning" }
);
await changeClueStatus(row.id, 3);
ElMessage.success("线索已废弃");
fetchList();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "废弃失败");
}
}
/** 恢复线索:把已废弃的线索恢复为跟进中 */
async function handleRestore(row) {
try {
await ElMessageBox.confirm(`确定将线索「${row.clue_name}」恢复为「跟进中」吗?`, "恢复确认", {
type: "info",
});
await changeClueStatus(row.id, 1);
ElMessage.success("线索已恢复为跟进中");
fetchList();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "恢复失败");
}
}
/** 详情内编辑:收起详情后打开编辑弹窗(详情数据即当前行) */
function handleDetailEdit() {
detailVisible.value = false;
+2 -2
View File
@@ -176,11 +176,11 @@ export const BUSINESS_STAGE_OPTIONS = [
{ label: "输单", value: "6" },
];
/** 商机状态 */
/** 商机状态:跳过一半无法继续推进的商机可废弃(可恢复为跟进中) */
export const BUSINESS_STATUS_OPTIONS = [
{ label: "跟进中", value: "1" },
{ label: "已转化项目", value: "2" },
{ label: "已关闭", value: "3" },
{ label: "已废弃", value: "3" },
];
/** 项目状态 */
@@ -0,0 +1,498 @@
<template>
<el-drawer
v-model="visible"
title="日报详情"
size="600px"
@closed="handleClosed"
>
<div v-loading="loading" class="report-detail">
<template v-if="detail">
<div class="detail-head">
<el-avatar :size="40" class="author-avatar">
{{ (detail.report.user_name || '?').slice(0, 1) }}
</el-avatar>
<div class="head-main">
<div class="author-line">
<span class="author-name">{{ detail.report.user_name }}</span>
<el-tag
:type="detail.report.status === 1 ? 'success' : 'info'"
size="small"
>
{{ detail.report.status === 1 ? '已提交' : '草稿' }}
</el-tag>
</div>
<div class="author-sub">
{{ detail.report.org_name || '未分配部门' }} ·
{{ detail.report.report_date }}
<template v-if="detail.report.submit_time">
· 提交于 {{ detail.report.submit_time }}
</template>
</div>
</div>
<div class="head-actions">
<el-button
v-if="detail.can_edit"
link
type="primary"
@click="$emit('edit', detail.report)"
>
编辑
</el-button>
<el-button
v-if="detail.can_delete"
link
type="danger"
@click="handleDeleteReport"
>
删除
</el-button>
</div>
</div>
<section class="detail-section">
<h4>今日完成</h4>
<div class="section-text">{{ detail.report.today_done || '—' }}</div>
</section>
<section class="detail-section">
<h4>明日计划</h4>
<div class="section-text">{{ detail.report.tomorrow_plan || '—' }}</div>
</section>
<section class="detail-section">
<h4>问题与建议</h4>
<div class="section-text">{{ detail.report.issues || '—' }}</div>
</section>
<section v-if="detail.report.attachments?.length" class="detail-section">
<h4>附件</h4>
<div class="attachment-list">
<a
v-for="file in detail.report.attachments"
:key="file.url"
:href="file.url"
target="_blank"
rel="noopener"
class="attachment-item"
>
<el-icon><Paperclip /></el-icon>
<span class="file-name">{{ file.name || '附件' }}</span>
<span v-if="file.size" class="file-size">{{ formatSize(file.size) }}</span>
</a>
</div>
</section>
<section class="detail-section comment-section">
<h4>评论 <span class="comment-count">{{ detail.comments.length }}</span></h4>
<el-empty
v-if="!detail.comments.length"
description="还没有评论"
:image-size="60"
/>
<div v-else class="comment-list">
<div
v-for="item in detail.comments"
:key="item.id"
class="comment-item"
:class="{ 'is-reply': item.reply_to_id > 0 }"
>
<div class="comment-head">
<span class="comment-user">{{ item.user_name }}</span>
<span v-if="item.reply_to_name" class="reply-to">
回复 {{ item.reply_to_name }}
</span>
<span class="comment-time">{{ item.create_time }}</span>
</div>
<div class="comment-content">{{ item.content }}</div>
<div class="comment-actions">
<el-button link type="primary" @click="startReply(item)">
回复
</el-button>
<el-button
v-if="item.can_delete"
link
type="danger"
@click="handleDeleteComment(item)"
>
删除
</el-button>
</div>
</div>
</div>
<div v-if="detail.can_comment" class="comment-editor">
<div v-if="replyTo" class="reply-banner">
正在回复 {{ replyTo.user_name }}
<el-button link type="primary" @click="replyTo = null">取消</el-button>
</div>
<el-input
v-model="commentText"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
:placeholder="replyTo ? `回复 ${replyTo.user_name}` : '写下你的评论或建议'"
/>
<div class="editor-actions">
<el-button
type="primary"
:loading="submitting"
@click="submitComment"
>
发表评论
</el-button>
</div>
</div>
<div v-else-if="detail.report.status !== 1" class="comment-disabled">
日报提交后才能评论
</div>
</section>
</template>
</div>
</el-drawer>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Paperclip } from '@element-plus/icons-vue'
import {
getDailyReportDetail,
addDailyReportComment,
deleteDailyReportComment,
deleteDailyReport
} from '@/api/oaDailyReport'
const props = defineProps({
modelValue: { type: Boolean, default: false },
reportId: { type: [Number, String], default: 0 }
})
const emit = defineEmits(['update:modelValue', 'edit', 'deleted'])
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const loading = ref(false)
const detail = ref(null)
const commentText = ref('')
const replyTo = ref(null)
const submitting = ref(false)
watch(
() => [props.modelValue, props.reportId],
([open]) => {
if (open && props.reportId) loadDetail()
},
{ immediate: true }
)
async function loadDetail() {
loading.value = true
try {
const res = await getDailyReportDetail(props.reportId)
detail.value = res?.data || null
if (!detail.value) ElMessage.error(res?.msg || '日报加载失败')
commentText.value = ''
replyTo.value = null
} catch (err) {
ElMessage.error(err?.message || '日报加载失败')
} finally {
loading.value = false
}
}
function startReply(comment) {
replyTo.value = { id: comment.id, user_name: comment.user_name }
}
async function submitComment() {
const content = commentText.value.trim()
if (!content) {
ElMessage.warning('请输入评论内容')
return
}
submitting.value = true
try {
await addDailyReportComment({
report_id: Number(props.reportId),
content,
reply_to_id: replyTo.value?.id || 0
})
ElMessage.success('评论成功')
commentText.value = ''
replyTo.value = null
await loadDetail()
} catch (err) {
ElMessage.error(err?.message || '评论失败')
} finally {
submitting.value = false
}
}
async function handleDeleteComment(comment) {
try {
await ElMessageBox.confirm('确定删除这条评论吗?', '删除评论', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
})
} catch {
return
}
try {
await deleteDailyReportComment(comment.id)
ElMessage.success('评论已删除')
await loadDetail()
} catch (err) {
ElMessage.error(err?.message || '删除失败')
}
}
async function handleDeleteReport() {
try {
await ElMessageBox.confirm(
'删除后不可恢复,且该日期的评论会一并删除。确定删除这条日报吗?',
'删除日报',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
} catch {
return
}
try {
await deleteDailyReport(props.reportId)
ElMessage.success('日报已删除')
visible.value = false
emit('deleted', Number(props.reportId))
} catch (err) {
ElMessage.error(err?.message || '删除失败')
}
}
function formatSize(size) {
const n = Number(size) || 0
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}
function handleClosed() {
detail.value = null
commentText.value = ''
replyTo.value = null
}
</script>
<style lang="less" scoped>
.report-detail {
min-height: 200px;
}
.detail-head {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 14px;
border-bottom: 1px solid #ebeef5;
}
.author-avatar {
background: #409eff;
color: #fff;
font-size: 16px;
}
.head-main {
flex: 1;
min-width: 0;
}
.author-line {
display: flex;
align-items: center;
gap: 8px;
}
.author-name {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.author-sub {
margin-top: 4px;
font-size: 12px;
color: #909399;
}
.head-actions {
display: flex;
align-items: center;
gap: 4px;
}
.detail-section {
padding: 14px 0;
border-bottom: 1px dashed #ebeef5;
h4 {
margin: 0 0 8px;
font-size: 14px;
color: #606266;
}
}
.section-text {
font-size: 14px;
line-height: 1.8;
color: #303133;
white-space: pre-wrap;
word-break: break-word;
}
.attachment-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.attachment-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: 1px solid #ebeef5;
border-radius: 6px;
color: #409eff;
text-decoration: none;
background: #fafafa;
}
.file-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 12px;
color: #909399;
}
.comment-count {
font-size: 12px;
color: #909399;
}
.comment-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.comment-item {
padding: 10px 12px;
border-radius: 6px;
background: #f7f8fa;
&.is-reply {
margin-left: 24px;
background: #f2f6fc;
}
}
.comment-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.comment-user {
font-weight: 600;
color: #303133;
}
.reply-to {
color: #909399;
}
.comment-time {
margin-left: auto;
color: #c0c4cc;
}
.comment-content {
margin-top: 6px;
font-size: 13px;
line-height: 1.7;
color: #303133;
white-space: pre-wrap;
word-break: break-word;
}
.comment-actions {
margin-top: 4px;
}
.comment-editor {
margin-top: 14px;
}
.reply-banner {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 12px;
color: #909399;
}
.editor-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.comment-disabled {
margin-top: 12px;
font-size: 12px;
color: #909399;
}
html.dark & {
.detail-head,
.detail-section {
border-color: var(--el-border-color-lighter);
}
.author-name,
.section-text,
.comment-user,
.comment-content {
color: var(--el-text-color-primary);
}
.author-sub,
.detail-section h4,
.comment-time,
.file-size,
.comment-disabled,
.reply-banner {
color: var(--el-text-color-secondary);
}
.attachment-item,
.comment-item {
background: var(--el-fill-color-light);
border-color: var(--el-border-color-lighter);
}
.attachment-item {
color: var(--el-color-primary);
}
}
</style>
@@ -0,0 +1,286 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑日报' : '写日报'"
width="720px"
:close-on-click-modal="false"
@closed="handleClosed"
>
<el-form v-loading="loading" :model="form" label-width="88px">
<el-form-item label="日报日期" required>
<el-date-picker
v-model="form.date"
type="date"
value-format="YYYY-MM-DD"
:clearable="false"
disabled
style="width: 180px"
/>
<span class="date-tip">
可补写 {{ windowStart }} 至 {{ windowEnd }}
</span>
</el-form-item>
<el-form-item label="今日完成">
<el-input
v-model="form.today_done"
type="textarea"
:rows="5"
maxlength="5000"
show-word-limit
placeholder="今天完成了哪些工作?可按条列出"
/>
</el-form-item>
<el-form-item label="明日计划">
<el-input
v-model="form.tomorrow_plan"
type="textarea"
:rows="4"
maxlength="5000"
show-word-limit
placeholder="明天计划推进哪些事项?"
/>
</el-form-item>
<el-form-item label="问题与建议">
<el-input
v-model="form.issues"
type="textarea"
:rows="3"
maxlength="5000"
show-word-limit
placeholder="遇到的问题、需要的支持或改进建议(选填)"
/>
</el-form-item>
<el-form-item label="附件">
<div class="attachment-block">
<el-upload
:show-file-list="false"
:http-request="handleUpload"
accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.pdf"
>
<el-button :icon="Upload" :loading="uploading">上传附件</el-button>
</el-upload>
<span class="upload-tip">图片或 PDF,单个不超过 10MB</span>
<div v-if="form.attachments.length" class="attachment-list">
<div
v-for="(file, index) in form.attachments"
:key="file.url || index"
class="attachment-item"
>
<el-icon><Paperclip /></el-icon>
<a :href="file.url" target="_blank" rel="noopener" class="file-name">
{{ file.name || '附件' }}
</a>
<el-button link type="danger" @click="removeAttachment(index)">
移除
</el-button>
</div>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button :loading="savingDraft" @click="handleSave(false)">保存草稿</el-button>
<el-button type="primary" :loading="savingSubmit" @click="handleSave(true)">
提交日报
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Upload, Paperclip } from '@element-plus/icons-vue'
import { uploadFile } from '@/api/file'
import { saveDailyReport } from '@/api/oaDailyReport'
const props = defineProps({
modelValue: { type: Boolean, default: false },
// 传入已有日报时为编辑,否则为新建
report: { type: Object, default: null },
defaultDate: { type: String, default: '' },
windowStart: { type: String, default: '' },
windowEnd: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue', 'saved'])
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const isEdit = computed(() => Boolean(props.report?.id))
const loading = ref(false)
const uploading = ref(false)
const savingDraft = ref(false)
const savingSubmit = ref(false)
const form = reactive({
date: '',
today_done: '',
tomorrow_plan: '',
issues: '',
attachments: []
})
function fillForm(report) {
form.date = report?.report_date || props.defaultDate || todayStr()
form.today_done = report?.today_done || ''
form.tomorrow_plan = report?.tomorrow_plan || ''
form.issues = report?.issues || ''
form.attachments = Array.isArray(report?.attachments)
? report.attachments.map((item) => ({ ...item }))
: []
}
watch(
() => props.modelValue,
(open) => {
if (open) fillForm(props.report)
},
{ immediate: true }
)
function todayStr() {
const now = new Date()
const pad = (n) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
async function handleUpload(options) {
const file = options.file
if (!file) return
const fd = new FormData()
fd.append('file', file)
uploading.value = true
try {
const res = await uploadFile(fd, { scope: 'user' })
const data = res?.data || {}
if (!data.url) throw new Error(res?.msg || '上传失败')
if (form.attachments.length >= 20) {
ElMessage.warning('附件最多 20 个')
return
}
form.attachments.push({
id: data.id || 0,
url: data.url,
name: data.name || file.name,
size: file.size || 0
})
} catch (err) {
ElMessage.error(err?.message || '上传失败')
} finally {
uploading.value = false
}
}
function removeAttachment(index) {
form.attachments.splice(index, 1)
}
async function handleSave(submit) {
if (!form.date) {
ElMessage.warning('请选择日报日期')
return
}
const hasContent =
form.today_done.trim() || form.tomorrow_plan.trim() || form.issues.trim()
if (!hasContent) {
ElMessage.warning('请至少填写今日完成、明日计划、问题与建议中的一项')
return
}
const setLoading = submit ? savingSubmit : savingDraft
setLoading.value = true
try {
const res = await saveDailyReport({
date: form.date,
today_done: form.today_done,
tomorrow_plan: form.tomorrow_plan,
issues: form.issues,
attachments: JSON.stringify(form.attachments),
submit
})
ElMessage.success(submit ? '日报已提交' : '草稿已保存')
emit('saved', res?.data || null)
visible.value = false
} catch (err) {
ElMessage.error(err?.message || '保存失败')
} finally {
setLoading.value = false
}
}
function handleClosed() {
loading.value = false
uploading.value = false
}
</script>
<style lang="less" scoped>
.date-tip {
margin-left: 10px;
font-size: 12px;
color: #909399;
}
.attachment-block {
width: 100%;
}
.upload-tip {
margin-left: 10px;
font-size: 12px;
color: #909399;
}
.attachment-list {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.attachment-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: 1px solid #ebeef5;
border-radius: 6px;
background: #fafafa;
}
.file-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #409eff;
text-decoration: none;
}
html.dark & {
.date-tip,
.upload-tip {
color: var(--el-text-color-secondary);
}
.attachment-item {
background: var(--el-fill-color-light);
border-color: var(--el-border-color-lighter);
}
.file-name {
color: var(--el-color-primary);
}
}
</style>
@@ -0,0 +1,487 @@
<template>
<div v-loading="loading" class="report-view">
<template v-if="detail">
<div class="detail-head">
<el-avatar :size="40" class="author-avatar">
{{ (detail.report.user_name || '?').slice(0, 1) }}
</el-avatar>
<div class="head-main">
<div class="author-line">
<span class="author-name">{{ detail.report.user_name }}</span>
<el-tag
:type="detail.report.status === 1 ? 'success' : 'info'"
size="small"
>
{{ detail.report.status === 1 ? '已提交' : '草稿' }}
</el-tag>
</div>
<div class="author-sub">
{{ detail.report.org_name || '未分配部门' }} ·
{{ detail.report.report_date }}
<template v-if="detail.report.submit_time">
· 提交于 {{ detail.report.submit_time }}
</template>
</div>
</div>
<div class="head-actions">
<el-button
v-if="detail.can_edit"
link
type="primary"
@click="$emit('edit', detail.report)"
>
编辑
</el-button>
<el-button
v-if="detail.can_delete"
link
type="danger"
@click="handleDeleteReport"
>
删除
</el-button>
</div>
</div>
<section class="detail-section">
<h4>今日完成</h4>
<div class="section-text">{{ detail.report.today_done || '—' }}</div>
</section>
<section class="detail-section">
<h4>明日计划</h4>
<div class="section-text">{{ detail.report.tomorrow_plan || '—' }}</div>
</section>
<section class="detail-section">
<h4>问题与建议</h4>
<div class="section-text">{{ detail.report.issues || '—' }}</div>
</section>
<section v-if="detail.report.attachments?.length" class="detail-section">
<h4>附件</h4>
<div class="attachment-list">
<a
v-for="file in detail.report.attachments"
:key="file.url"
:href="file.url"
target="_blank"
rel="noopener"
class="attachment-item"
>
<el-icon><Paperclip /></el-icon>
<span class="file-name">{{ file.name || '附件' }}</span>
<span v-if="file.size" class="file-size">{{ formatSize(file.size) }}</span>
</a>
</div>
</section>
<section class="detail-section comment-section">
<h4>评论 <span class="comment-count">{{ detail.comments.length }}</span></h4>
<el-empty
v-if="!detail.comments.length"
description="还没有评论"
:image-size="60"
/>
<div v-else class="comment-list">
<div
v-for="item in detail.comments"
:key="item.id"
class="comment-item"
:class="{ 'is-reply': item.reply_to_id > 0 }"
>
<div class="comment-head">
<span class="comment-user">{{ item.user_name }}</span>
<span v-if="item.reply_to_name" class="reply-to">
回复 {{ item.reply_to_name }}
</span>
<span class="comment-time">{{ item.create_time }}</span>
</div>
<div class="comment-content">{{ item.content }}</div>
<div class="comment-actions">
<el-button link type="primary" @click="startReply(item)">
回复
</el-button>
<el-button
v-if="item.can_delete"
link
type="danger"
@click="handleDeleteComment(item)"
>
删除
</el-button>
</div>
</div>
</div>
<div v-if="detail.can_comment" class="comment-editor">
<div v-if="replyTo" class="reply-banner">
正在回复 {{ replyTo.user_name }}
<el-button link type="primary" @click="replyTo = null">取消</el-button>
</div>
<el-input
v-model="commentText"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
:placeholder="replyTo ? `回复 ${replyTo.user_name}` : '写下你的评论或建议'"
/>
<div class="editor-actions">
<el-button
type="primary"
:loading="submitting"
@click="submitComment"
>
发表评论
</el-button>
</div>
</div>
<div v-else-if="detail.report.status !== 1" class="comment-disabled">
日报提交后才能评论
</div>
</section>
</template>
<el-empty
v-else-if="!loading"
description="选择左侧日报查看详情"
:image-size="80"
/>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Paperclip } from '@element-plus/icons-vue'
import {
getDailyReportDetail,
addDailyReportComment,
deleteDailyReportComment,
deleteDailyReport
} from '@/api/oaDailyReport'
const props = defineProps({
reportId: { type: [Number, String], default: 0 }
})
const emit = defineEmits(['edit', 'deleted', 'changed'])
const loading = ref(false)
const detail = ref(null)
const commentText = ref('')
const replyTo = ref(null)
const submitting = ref(false)
watch(
() => props.reportId,
(id) => {
if (id) loadDetail()
else detail.value = null
},
{ immediate: true }
)
async function loadDetail() {
loading.value = true
try {
const res = await getDailyReportDetail(props.reportId)
detail.value = res?.data || null
if (!detail.value) ElMessage.error(res?.msg || '日报加载失败')
commentText.value = ''
replyTo.value = null
} catch (err) {
detail.value = null
ElMessage.error(err?.message || '日报加载失败')
} finally {
loading.value = false
}
}
function startReply(comment) {
replyTo.value = { id: comment.id, user_name: comment.user_name }
}
async function submitComment() {
const content = commentText.value.trim()
if (!content) {
ElMessage.warning('请输入评论内容')
return
}
submitting.value = true
try {
await addDailyReportComment({
report_id: Number(props.reportId),
content,
reply_to_id: replyTo.value?.id || 0
})
ElMessage.success('评论成功')
commentText.value = ''
replyTo.value = null
await loadDetail()
emit('changed')
} catch (err) {
ElMessage.error(err?.message || '评论失败')
} finally {
submitting.value = false
}
}
async function handleDeleteComment(comment) {
try {
await ElMessageBox.confirm('确定删除这条评论吗?', '删除评论', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
})
} catch {
return
}
try {
await deleteDailyReportComment(comment.id)
ElMessage.success('评论已删除')
await loadDetail()
emit('changed')
} catch (err) {
ElMessage.error(err?.message || '删除失败')
}
}
async function handleDeleteReport() {
try {
await ElMessageBox.confirm(
'删除后不可恢复,且该日期的评论会一并删除。确定删除这条日报吗?',
'删除日报',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
} catch {
return
}
try {
await deleteDailyReport(props.reportId)
ElMessage.success('日报已删除')
emit('deleted', Number(props.reportId))
} catch (err) {
ElMessage.error(err?.message || '删除失败')
}
}
function formatSize(size) {
const n = Number(size) || 0
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}
</script>
<style lang="less" scoped>
.report-view {
min-height: 200px;
}
.detail-head {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 14px;
border-bottom: 1px solid #ebeef5;
}
.author-avatar {
background: #409eff;
color: #fff;
font-size: 16px;
}
.head-main {
flex: 1;
min-width: 0;
}
.author-line {
display: flex;
align-items: center;
gap: 8px;
}
.author-name {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.author-sub {
margin-top: 4px;
font-size: 12px;
color: #909399;
}
.head-actions {
display: flex;
align-items: center;
gap: 4px;
}
.detail-section {
padding: 14px 0;
border-bottom: 1px dashed #ebeef5;
h4 {
margin: 0 0 8px;
font-size: 14px;
color: #606266;
}
}
.section-text {
font-size: 14px;
line-height: 1.8;
color: #303133;
white-space: pre-wrap;
word-break: break-word;
}
.attachment-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.attachment-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: 1px solid #ebeef5;
border-radius: 6px;
color: #409eff;
text-decoration: none;
background: #fafafa;
}
.file-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 12px;
color: #909399;
}
.comment-count {
font-size: 12px;
color: #909399;
}
.comment-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.comment-item {
padding: 10px 12px;
border-radius: 6px;
background: #f7f8fa;
&.is-reply {
margin-left: 24px;
background: #f2f6fc;
}
}
.comment-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.comment-user {
font-weight: 600;
color: #303133;
}
.reply-to {
color: #909399;
}
.comment-time {
margin-left: auto;
color: #c0c4cc;
}
.comment-content {
margin-top: 6px;
font-size: 13px;
line-height: 1.7;
color: #303133;
white-space: pre-wrap;
word-break: break-word;
}
.comment-actions {
margin-top: 4px;
}
.comment-editor {
margin-top: 14px;
}
.reply-banner {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 12px;
color: #909399;
}
.editor-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.comment-disabled {
margin-top: 12px;
font-size: 12px;
color: #909399;
}
html.dark & {
.detail-head,
.detail-section {
border-color: var(--el-border-color-lighter);
}
.author-name,
.section-text,
.comment-user,
.comment-content {
color: var(--el-text-color-primary);
}
.author-sub,
.detail-section h4,
.comment-time,
.file-size,
.comment-disabled,
.reply-banner {
color: var(--el-text-color-secondary);
}
.attachment-item,
.comment-item {
background: var(--el-fill-color-light);
border-color: var(--el-border-color-lighter);
}
.attachment-item {
color: var(--el-color-primary);
}
}
</style>
@@ -0,0 +1,918 @@
<template>
<div class="dailyreport-page">
<div class="page-header">
<div>
<h2>工作日报</h2>
<p>按日记录今日完成、明日计划与问题建议,主管可查看团队汇总并进行点评</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="refreshAll">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openWriteToday">
写今日日报
</el-button>
</div>
</div>
<el-tabs v-model="activeTab" class="report-tabs" @tab-change="handleTabChange">
<!-- ==================== 我的日报 ==================== -->
<el-tab-pane label="我的日报" name="mine">
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">本月日报</div>
<div class="stat-value is-blue">{{ summary.month_total || 0 }}</div>
<div class="stat-sub">条记录</div>
</div>
<div class="stat-card">
<div class="stat-label">本月已提交</div>
<div class="stat-value is-green">{{ summary.month_submitted || 0 }}</div>
<div class="stat-sub">条已提交</div>
</div>
<div class="stat-card">
<div class="stat-label">今日状态</div>
<div class="stat-value" :class="todayStatusClass">
{{ todayStatusText }}
</div>
<div class="stat-sub">{{ summary.window_end || todayStr() }}</div>
</div>
<div class="stat-card">
<div class="stat-label">补写窗口</div>
<div class="stat-value is-orange window-value">
{{ summary.window_start || '—' }}
</div>
<div class="stat-sub">至 {{ summary.window_end || '—' }},共 {{ profile.backfill_days || 7 }} 天</div>
</div>
</div>
<div class="filter-bar">
<el-date-picker
v-model="mineRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
:disabled-date="disableFutureDate"
unlink-panels
style="width: 260px"
/>
<el-button type="primary" :icon="Search" @click="searchMine">
查询
</el-button>
<el-button :icon="Refresh" @click="resetMineFilters">重置</el-button>
<div class="backfill-group">
<span class="backfill-label">补写往日</span>
<el-date-picker
v-model="backfillDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
:disabled-date="disableOutsideWindow"
:clearable="false"
style="width: 150px"
/>
<el-button @click="openBackfill">填写该日</el-button>
</div>
</div>
<div class="split-layout">
<aside class="list-pane">
<div class="list-head">
<span>我的日报</span>
<span class="list-count">共 {{ pagination.total }} 条</span>
</div>
<div v-loading="loading" class="report-cards">
<div
v-for="row in list"
:key="row.id"
class="report-card"
:class="{ active: mineSelectedId === row.id }"
@click="mineSelectedId = row.id"
>
<div class="card-top">
<span class="card-date">
{{ row.report_date }}
<el-tag v-if="row.report_date === todayStr()" size="small" effect="plain">
今天
</el-tag>
</span>
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '已提交' : '草稿' }}
</el-tag>
</div>
<div class="card-summary">
{{ row.today_done || row.tomorrow_plan || row.issues || '(内容为空)' }}
</div>
<div class="card-foot">
<span>{{ row.comment_count || 0 }} 条评论</span>
<span>{{ row.submit_time || '未提交' }}</span>
</div>
</div>
<el-empty
v-if="!loading && !list.length"
description="还没有日报,点击右上角「写今日日报」开始记录"
:image-size="70"
/>
</div>
<div v-if="pagination.total > pagination.pageSize" class="list-pagination">
<el-pagination
small
layout="prev, pager, next"
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
@current-change="handleMinePageChange"
/>
</div>
</aside>
<section class="detail-pane">
<ReportView
v-if="mineSelectedId"
:key="`mine-${mineSelectedId}-${detailVersion}`"
:report-id="mineSelectedId"
@edit="handleDetailEdit"
@deleted="handleMineDeleted"
@changed="loadMine"
/>
<el-empty
v-else
description="选择左侧日报查看详情"
:image-size="80"
/>
</section>
</div>
</el-tab-pane>
<!-- ==================== 团队日报 ==================== -->
<el-tab-pane v-if="profile.can_view_team" label="团队日报" name="team">
<div class="filter-bar">
<el-date-picker
v-model="teamFilters.date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
:disabled-date="disableFutureDate"
:clearable="false"
style="width: 150px"
/>
<el-tree-select
v-model="teamFilters.org_id"
:data="orgTree"
:props="{ value: 'id', label: 'name', children: 'children' }"
node-key="id"
check-strictly
clearable
placeholder="全部部门"
style="width: 200px"
/>
<el-input
v-model="teamFilters.keyword"
clearable
placeholder="搜索成员姓名/账号"
style="width: 200px"
@keyup.enter="searchTeam"
/>
<el-button type="primary" :icon="Search" @click="searchTeam">查询</el-button>
<el-button :icon="Refresh" @click="resetTeamFilters">重置</el-button>
</div>
<div class="stats-row team-stats">
<div class="stat-card">
<div class="stat-label">应提交</div>
<div class="stat-value is-blue">{{ teamStats.total || 0 }}</div>
<div class="stat-sub">名团队成员</div>
</div>
<div class="stat-card">
<div class="stat-label">已提交</div>
<div class="stat-value is-green">{{ teamStats.submitted || 0 }}</div>
<div class="stat-sub">人已提交</div>
</div>
<div class="stat-card">
<div class="stat-label">未提交</div>
<div class="stat-value is-red">{{ teamStats.missing || 0 }}</div>
<div class="stat-sub">人待提交</div>
</div>
<div class="stat-card">
<div class="stat-label">提交率</div>
<div class="stat-value is-orange">{{ teamStats.rate || 0 }}%</div>
<div class="stat-sub">{{ teamRes.date || teamFilters.date }}</div>
</div>
</div>
<div class="split-layout">
<aside class="list-pane">
<div class="list-head">
<span>团队成员</span>
<span class="list-count">共 {{ teamPagination.total }} 人</span>
</div>
<div v-loading="teamLoading" class="report-cards">
<div
v-for="row in teamList"
:key="row.user_id"
class="report-card member-card"
:class="{ active: teamSelectedId === row.report_id }"
@click="selectTeam(row)"
>
<div class="card-top">
<span class="member-name">{{ row.user_name }}</span>
<el-tag v-if="row.submitted" type="success" size="small">已提交</el-tag>
<el-tag v-else-if="row.status === 0" type="info" size="small">草稿</el-tag>
<el-tag v-else type="danger" size="small" effect="plain">未提交</el-tag>
</div>
<div class="card-summary member-org">
{{ row.org_name || '未分配部门' }}
<span v-if="row.account" class="member-account">{{ row.account }}</span>
</div>
<div class="card-foot">
<span>{{ row.submit_time || '—' }}</span>
<el-tag v-if="row.has_issue" type="warning" size="small" effect="plain">
有反馈
</el-tag>
</div>
</div>
<el-empty
v-if="!teamLoading && !teamList.length"
description="暂无团队成员"
:image-size="70"
/>
</div>
<div v-if="teamPagination.total > teamPagination.pageSize" class="list-pagination">
<el-pagination
small
layout="prev, pager, next"
:current-page="teamPagination.page"
:page-size="teamPagination.pageSize"
:total="teamPagination.total"
@current-change="handleTeamPageChange"
/>
</div>
</aside>
<section class="detail-pane">
<ReportView
v-if="teamSelectedId"
:key="`team-${teamSelectedId}-${detailVersion}`"
:report-id="teamSelectedId"
@edit="handleDetailEdit"
@deleted="handleTeamDeleted"
@changed="loadTeam"
/>
<el-empty
v-else
description="选择左侧已提交的成员查看日报"
:image-size="80"
/>
</section>
</div>
</el-tab-pane>
</el-tabs>
<ReportEdit
v-model="editVisible"
:report="editingReport"
:default-date="editDefaultDate"
:window-start="profile.window_start || summary.window_start"
:window-end="profile.window_end || summary.window_end"
@saved="handleSaved"
/>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import {
getDailyReportProfile,
getMyDailyReports,
getDailyReportDetail,
getTeamDailyReports,
getTeamDailyReportOrgs
} from '@/api/oaDailyReport'
import ReportEdit from './components/ReportEdit.vue'
import ReportView from './components/ReportView.vue'
const activeTab = ref('mine')
const profile = ref({
is_admin: false,
can_view_team: false,
window_start: '',
window_end: '',
backfill_days: 7
})
// ---------------- 我的日报 ----------------
const loading = ref(false)
const list = ref([])
const summary = ref({})
const mineRange = ref([])
const backfillDate = ref(todayStr())
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const mineSelectedId = ref(0)
// ---------------- 团队日报 ----------------
const teamLoading = ref(false)
const teamList = ref([])
const teamStats = ref({ total: 0, submitted: 0, missing: 0, rate: 0 })
const teamRes = ref({})
const orgOptions = ref([])
const teamFilters = reactive({ date: todayStr(), org_id: null, keyword: '' })
const teamPagination = reactive({ page: 1, pageSize: 10, total: 0 })
const teamSelectedId = ref(0)
// ---------------- 编辑弹窗 ----------------
const editVisible = ref(false)
const editingReport = ref(null)
const editDefaultDate = ref(todayStr())
// 详情刷新标记:编辑保存后递增,触发右侧详情重新加载
const detailVersion = ref(0)
const orgTree = computed(() => buildOrgTree(orgOptions.value))
const todayStatusText = computed(() => {
const status = summary.value.today_status
if (status === 1) return '已提交'
if (status === 0) return '草稿'
return '未填写'
})
const todayStatusClass = computed(() => {
const status = summary.value.today_status
if (status === 1) return 'is-green'
if (status === 0) return 'is-orange'
return 'is-red'
})
onMounted(async () => {
await loadProfile()
await loadMine()
})
async function loadProfile() {
try {
const res = await getDailyReportProfile()
profile.value = { ...profile.value, ...(res?.data || {}) }
if (profile.value.window_end && profile.value.window_start) {
backfillDate.value = profile.value.window_end
}
if (!profile.value.can_view_team && activeTab.value === 'team') {
activeTab.value = 'mine'
}
} catch (err) {
ElMessage.error(err?.message || '日报身份加载失败,请确认已执行建表脚本')
}
}
async function loadMine() {
loading.value = true
try {
const res = await getMyDailyReports({
page: pagination.page,
pageSize: pagination.pageSize,
start_date: mineRange.value?.[0] || '',
end_date: mineRange.value?.[1] || ''
})
const data = res?.data || {}
list.value = data.list || []
pagination.total = data.total || 0
summary.value = data.summary || {}
// 选中项仍在当前列表则保持,否则默认选中第一条
const ids = list.value.map((x) => x.id)
if (!ids.includes(mineSelectedId.value)) {
mineSelectedId.value = ids[0] || 0
}
} catch (err) {
ElMessage.error(err?.message || '日报列表加载失败')
} finally {
loading.value = false
}
}
function handleMinePageChange(page) {
pagination.page = page
loadMine()
}
function searchMine() {
pagination.page = 1
loadMine()
}
function resetMineFilters() {
mineRange.value = []
pagination.page = 1
loadMine()
}
async function openWriteToday() {
if (summary.value.today_report_id) {
await openEditById(summary.value.today_report_id)
return
}
openEdit(null, todayStr())
}
async function openBackfill() {
const date = backfillDate.value
if (!date) {
ElMessage.warning('请选择要补写的日期')
return
}
if (!inWindow(date)) {
ElMessage.warning(`只能补写最近 ${profile.value.backfill_days || 7} 天的日报`)
return
}
try {
const res = await getMyDailyReports({ page: 1, pageSize: 1, start_date: date, end_date: date })
const existed = res?.data?.list?.[0]
if (existed) {
editingReport.value = existed
editDefaultDate.value = date
editVisible.value = true
return
}
} catch (err) {
ElMessage.error(err?.message || '查询失败')
return
}
openEdit(null, date)
}
async function openEditById(id) {
try {
const res = await getDailyReportDetail(id)
const report = res?.data?.report
if (report) openEdit(report)
} catch (err) {
ElMessage.error(err?.message || '日报加载失败')
}
}
function openEdit(report, date = '') {
editingReport.value = report || null
editDefaultDate.value = date || report?.report_date || todayStr()
editVisible.value = true
}
function handleDetailEdit(report) {
openEdit(report)
}
function handleMineDeleted() {
mineSelectedId.value = 0
loadMine()
if (profile.value.can_view_team && activeTab.value === 'team') loadTeam()
}
function handleTeamDeleted() {
teamSelectedId.value = 0
loadTeam()
}
function handleSaved() {
detailVersion.value++
loadMine()
if (profile.value.can_view_team) loadTeam()
}
// ---------------- 团队日报 ----------------
function handleTabChange(name) {
if (name !== 'team') return
if (!profile.value.can_view_team) return
if (!orgOptions.value.length) loadTeamOrgs()
loadTeam()
}
async function loadTeamOrgs() {
try {
const res = await getTeamDailyReportOrgs()
orgOptions.value = res?.data?.list || []
} catch (err) {
ElMessage.error(err?.message || '部门加载失败')
}
}
async function loadTeam() {
if (!profile.value.can_view_team) return
teamLoading.value = true
try {
const res = await getTeamDailyReports({
date: teamFilters.date,
org_id: teamFilters.org_id || 0,
keyword: teamFilters.keyword.trim(),
page: teamPagination.page,
pageSize: teamPagination.pageSize
})
const data = res?.data || {}
teamRes.value = data
teamList.value = data.list || []
teamPagination.total = data.total || 0
teamStats.value = data.stats || { total: 0, submitted: 0, missing: 0, rate: 0 }
// 选中项仍已提交则保持,否则默认选中第一个已提交成员
const submitted = teamList.value.filter((x) => x.submitted && x.report_id)
if (!submitted.some((x) => x.report_id === teamSelectedId.value)) {
teamSelectedId.value = submitted[0]?.report_id || 0
}
} catch (err) {
ElMessage.error(err?.message || '团队日报加载失败')
} finally {
teamLoading.value = false
}
}
function handleTeamPageChange(page) {
teamPagination.page = page
loadTeam()
}
function searchTeam() {
teamPagination.page = 1
loadTeam()
}
function resetTeamFilters() {
teamFilters.date = todayStr()
teamFilters.org_id = null
teamFilters.keyword = ''
teamPagination.page = 1
loadTeam()
}
function selectTeam(row) {
if (!row.submitted || !row.report_id) {
ElMessage.info('该成员当日未提交日报')
return
}
teamSelectedId.value = row.report_id
}
function refreshAll() {
loadProfile()
if (activeTab.value === 'mine') {
loadMine()
} else {
loadTeam()
}
}
// ---------------- 工具 ----------------
function todayStr() {
const now = new Date()
const pad = (n) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
function inWindow(date) {
const start = profile.value.window_start || summary.value.window_start
const end = profile.value.window_end || summary.value.window_end
if (!start || !end) return date === todayStr()
return date >= start && date <= end
}
function disableFutureDate(date) {
const today = todayStr()
const pad = (n) => String(n).padStart(2, '0')
const value = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
return value > today
}
function disableOutsideWindow(date) {
const pad = (n) => String(n).padStart(2, '0')
const value = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
const start = profile.value.window_start || summary.value.window_start
const end = profile.value.window_end || summary.value.window_end
if (!start || !end) return value !== todayStr()
return value < start || value > end
}
function buildOrgTree(rows) {
const map = new Map()
rows.forEach((row) => map.set(row.id, { ...row, children: [] }))
const roots = []
map.forEach((node) => {
const parent = map.get(node.parent_id)
if (parent) parent.children.push(node)
else roots.push(node)
})
return roots
}
</script>
<style lang="less" scoped>
.dailyreport-page {
padding: 16px 20px 24px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.page-header h2 {
margin: 0 0 4px;
font-size: 20px;
}
.page-header p {
margin: 0;
color: #909399;
font-size: 13px;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.report-tabs {
margin-top: 4px;
}
.stats-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.team-stats {
margin-top: 4px;
}
.stat-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 14px 16px;
}
.stat-label {
font-size: 12px;
color: #909399;
}
.stat-value {
margin-top: 6px;
font-size: 24px;
font-weight: 600;
line-height: 1.2;
}
.stat-value.is-green {
color: #67c23a;
}
.stat-value.is-blue {
color: #409eff;
}
.stat-value.is-orange {
color: #e6a23c;
}
.stat-value.is-red {
color: #f56c6c;
}
.window-value {
font-size: 16px;
}
.stat-sub {
margin-top: 4px;
font-size: 12px;
color: #909399;
}
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.backfill-group {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.backfill-label {
font-size: 13px;
color: #606266;
}
// ---------------- 左右结构 ----------------
.split-layout {
display: flex;
align-items: stretch;
gap: 14px;
}
.list-pane {
flex: 0 0 340px;
width: 340px;
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
overflow: hidden;
}
.list-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid #ebeef5;
font-size: 13px;
font-weight: 600;
color: #303133;
}
.list-count {
font-weight: 400;
color: #909399;
}
.report-cards {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
max-height: 548px;
min-height: 300px;
overflow-y: auto;
}
.report-card {
padding: 10px 12px;
border: 1px solid #ebeef5;
border-radius: 8px;
background: #fff;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
&:hover {
border-color: #c6e2ff;
background: #f5faff;
}
&.active {
border-color: #409eff;
background: #ecf5ff;
}
}
.card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.card-date {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.member-name {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.card-summary {
margin-top: 6px;
font-size: 12px;
line-height: 1.6;
color: #606266;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.member-org {
display: flex;
align-items: center;
gap: 6px;
}
.member-account {
color: #c0c4cc;
}
.card-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
font-size: 12px;
color: #909399;
}
.list-pagination {
display: flex;
justify-content: center;
padding: 8px 10px 10px;
border-top: 1px solid #ebeef5;
}
.detail-pane {
flex: 1;
min-width: 0;
padding: 16px 20px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
min-height: 480px;
max-height: 660px;
overflow-y: auto;
}
@media (max-width: 1200px) {
.split-layout {
flex-direction: column;
}
.list-pane {
flex: none;
width: 100%;
}
.report-cards {
max-height: 320px;
}
.detail-pane {
max-height: none;
}
}
html.dark & {
.page-header p,
.stat-label,
.stat-sub,
.member-account,
.list-count,
.card-foot {
color: var(--el-text-color-secondary);
}
.stat-card,
.list-pane,
.detail-pane,
.report-card {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
}
.list-head {
border-color: var(--el-border-color-lighter);
color: var(--el-text-color-primary);
}
.report-card {
&:hover {
border-color: var(--el-color-primary);
background: var(--el-fill-color-light);
}
&.active {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
}
.card-date,
.member-name {
color: var(--el-text-color-primary);
}
.card-summary {
color: var(--el-text-color-regular);
}
.list-pagination {
border-color: var(--el-border-color-lighter);
}
.backfill-label {
color: var(--el-text-color-regular);
}
}
</style>
@@ -40,19 +40,12 @@
<!-- 申请列表 -->
<el-table :data="applies" style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column label="申请人" min-width="140" align="center">
<template #default="scope">
<div>{{ scope.row.identity_nickname || "-" }}</div>
<div class="sub-text">{{ scope.row.identity_mobile || "" }}</div>
</template>
</el-table-column>
<el-table-column
prop="tenant_name"
label="申请加入企业"
min-width="160"
align="center"
/>
<el-table-column label="联系方式" min-width="150" align="center">
<template #default="scope">
<div>{{ scope.row.contact_person || "-" }}</div>
@@ -153,7 +146,6 @@ import { formatDateTime } from "@/utils/datetime";
interface TenantApply {
id: number;
identity_id: number;
tenant_name: string;
identity_nickname: string;
identity_mobile: string;
contact_person: string;