更新若干功能

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;
+237
View File
@@ -0,0 +1,237 @@
package controllers
import (
"encoding/json"
"io"
"strconv"
"strings"
"server/pkg/identity"
"server/services/dailyreport"
)
// OA 工作日报(小程序端)。与后台 /backend/oa/dailyreport/* 共用
// services/dailyreport 的权限与校验逻辑,仅在响应包装与上传入口上有差异。
// dailyReportCtx 解析移动端令牌并组装日报身份。
func (c *AppOaController) dailyReportCtx() (*dailyreport.UserContext, bool) {
claims, err := c.oaClaims()
if err != nil {
code := identity.Status(err)
c.oaErr(code, code, err.Error())
return nil, false
}
u, err := dailyreport.BuildContext(
uint64(claims.TenantId), uint64(claims.UserID), claims.Username, claims.UserType == "platform")
if err != nil {
code := dailyreport.StatusOf(err, 500)
msg := err.Error()
if code == 500 {
msg = "操作失败,请稍后重试"
}
c.oaErr(code, code, msg)
return nil, false
}
return u, true
}
func (c *AppOaController) dailyReportFail(err error) {
code := dailyreport.StatusOf(err, 500)
msg := err.Error()
if code == 500 {
msg = "操作失败,请稍后重试"
}
c.oaErr(code, code, msg)
}
func (c *AppOaController) dailyReportID() uint64 {
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
c.oaErr(400, 400, "无效ID")
}
return id
}
// DailyReportProfile GET /api/oa/dailyreport/profile
func (c *AppOaController) DailyReportProfile() {
u, ok := c.dailyReportCtx()
if !ok {
return
}
teamOrgs := u.TeamOrgIDs
if teamOrgs == nil {
teamOrgs = []uint64{}
}
c.oaOk(map[string]interface{}{
"user_id": u.UID,
"user_name": u.Name,
"org_id": u.OrgID,
"org_name": u.OrgName,
"is_admin": u.IsAdmin,
"can_view_team": u.CanViewTeam,
"team_org_ids": teamOrgs,
"window_start": dailyreport.WindowStart(),
"window_end": dailyreport.Today(),
"backfill_days": dailyreport.BackfillDays,
})
}
// DailyReportMineList GET /api/oa/dailyreport/mine/list
func (c *AppOaController) DailyReportMineList() {
u, ok := c.dailyReportCtx()
if !ok {
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
res, err := dailyreport.ListMine(u, dailyreport.MineQuery{
StartDate: strings.TrimSpace(c.GetString("start_date")),
EndDate: strings.TrimSpace(c.GetString("end_date")),
Page: page,
PageSize: pageSize,
})
if err != nil {
c.dailyReportFail(err)
return
}
c.oaOk(res)
}
// DailyReportSave POST /api/oa/dailyreport/save
func (c *AppOaController) DailyReportSave() {
u, ok := c.dailyReportCtx()
if !ok {
return
}
var in dailyreport.SaveInput
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil || json.Unmarshal(raw, &in) != nil {
c.oaErr(400, 400, "参数错误")
return
}
item, err := dailyreport.Save(u, in)
if err != nil {
c.dailyReportFail(err)
return
}
c.oaOk(item)
}
// DailyReportDetail GET /api/oa/dailyreport/detail/:id
func (c *AppOaController) DailyReportDetail() {
u, ok := c.dailyReportCtx()
if !ok {
return
}
id := c.dailyReportID()
if id == 0 {
return
}
detail, err := dailyreport.GetDetail(u, id)
if err != nil {
c.dailyReportFail(err)
return
}
c.oaOk(detail)
}
// DailyReportDelete DELETE /api/oa/dailyreport/delete/:id
func (c *AppOaController) DailyReportDelete() {
u, ok := c.dailyReportCtx()
if !ok {
return
}
id := c.dailyReportID()
if id == 0 {
return
}
if err := dailyreport.Delete(u, id); err != nil {
c.dailyReportFail(err)
return
}
c.oaOk(map[string]interface{}{"id": id})
}
// DailyReportTeamStatus GET /api/oa/dailyreport/team/status
func (c *AppOaController) DailyReportTeamStatus() {
u, ok := c.dailyReportCtx()
if !ok {
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
orgID, _ := c.GetInt64("org_id", 0)
res, err := dailyreport.TeamStatus(u, dailyreport.TeamQuery{
Date: strings.TrimSpace(c.GetString("date")),
OrgID: uint64(orgID),
Keyword: strings.TrimSpace(c.GetString("keyword")),
Page: page,
PageSize: pageSize,
})
if err != nil {
c.dailyReportFail(err)
return
}
c.oaOk(res)
}
// DailyReportTeamOrgs GET /api/oa/dailyreport/team/orgs
func (c *AppOaController) DailyReportTeamOrgs() {
u, ok := c.dailyReportCtx()
if !ok {
return
}
orgs, err := dailyreport.TeamOrgs(u)
if err != nil {
c.dailyReportFail(err)
return
}
c.oaOk(map[string]interface{}{"list": orgs})
}
// DailyReportComment POST /api/oa/dailyreport/comment
func (c *AppOaController) DailyReportComment() {
u, ok := c.dailyReportCtx()
if !ok {
return
}
var payload oaDailyReportCommentPayload
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil || json.Unmarshal(raw, &payload) != nil {
c.oaErr(400, 400, "参数错误")
return
}
if payload.ReportID == 0 {
c.oaErr(400, 400, "缺少日报ID")
return
}
comment, err := dailyreport.AddComment(u, payload.ReportID, payload.Content, payload.ReplyToID)
if err != nil {
c.dailyReportFail(err)
return
}
c.oaOk(comment)
}
// DailyReportCommentDelete DELETE /api/oa/dailyreport/comment/:id
func (c *AppOaController) DailyReportCommentDelete() {
u, ok := c.dailyReportCtx()
if !ok {
return
}
id := c.dailyReportID()
if id == 0 {
return
}
if err := dailyreport.DeleteComment(u, id); err != nil {
c.dailyReportFail(err)
return
}
c.oaOk(map[string]interface{}{"id": id})
}
// DailyReportUpload POST /api/oa/dailyreport/upload(multipart,字段名 file)
// 复用 OA 附件上传:图片 / PDF,单个文件 ≤10MB,落入租户文件管理。
func (c *AppOaController) DailyReportUpload() {
c.uploadTenantImage()
}
+80
View File
@@ -348,6 +348,10 @@ func (c *BackendCrmBusinessController) ChangeStage() {
pipelineErr(&c.Controller, 400, 400, "商机已转化并锁定,不能流转阶段")
return
}
if biz.Status == 3 {
pipelineErr(&c.Controller, 400, 400, "商机已废弃,请先恢复为「跟进中」再流转阶段")
return
}
// 阶段未变化:直接幂等返回,不写日志
if strings.TrimSpace(biz.Stage) == stage {
pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID, "stage": biz.Stage})
@@ -364,6 +368,78 @@ func (c *BackendCrmBusinessController) ChangeStage() {
pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID, "stage": stage})
}
// ChangeStatus POST /backend/crm/business/:id/status
// 商机状态流转:1跟进中 / 3已废弃。
// 「已转化项目(2)」不走本接口,须用「转项目」完成(会锁定商机)。
func (c *BackendCrmBusinessController) ChangeStatus() {
claims, err := pipelineClaims(&c.Controller)
if err != nil {
code := identity.Status(err)
pipelineErr(&c.Controller, code, code, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
pipelineErr(&c.Controller, 400, 400, "无效的ID")
return
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
var p struct {
Status int8 `json:"status"`
}
if err := json.Unmarshal(raw, &p); err != nil {
pipelineErr(&c.Controller, 400, 400, "参数错误")
return
}
if p.Status != 1 && p.Status != 3 {
pipelineErr(&c.Controller, 400, 400, "仅支持流转为「跟进中 / 已废弃」,转为已转化项目请使用「转项目」")
return
}
tenantID := pipelineTenantID(claims)
var biz models.TenantCrmBusiness
if err := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
Filter("id", id).Filter("tenant_id", tenantID).
Filter("delete_time__isnull", true).One(&biz); err != nil {
pipelineErr(&c.Controller, 404, 404, "商机未找到")
return
}
// 数据范围校验:非管理员仅能操作本人(负责人或创建人)的数据
if !canAccessCrmRecord(claims, biz.OwnerUserID, biz.CreateUserID) {
pipelineErr(&c.Controller, 403, 403, "无权操作该商机数据")
return
}
if biz.Locked == 1 || biz.Status == 2 {
pipelineErr(&c.Controller, 400, 400, "商机已转化并锁定,不能流转状态")
return
}
// 状态未变化:直接幂等返回,不写日志
if biz.Status == p.Status {
pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID, "status": biz.Status})
return
}
if _, err := models.Orm.QueryTable(new(models.TenantCrmBusiness)).
Filter("id", id).Filter("tenant_id", tenantID).
Update(map[string]interface{}{"status": p.Status, "update_time": time.Now()}); err != nil {
pipelineErr(&c.Controller, 500, 500, "状态更新失败: "+err.Error())
return
}
crmWriteLog(tenantID, 2, biz.ID, "status",
fmt.Sprintf("商机状态:%s → %s", businessStatusName(biz.Status), businessStatusName(p.Status)), claims)
pipelineOk(&c.Controller, map[string]interface{}{"id": biz.ID, "status": p.Status})
}
// businessStatusName 商机状态文案(用于操作日志):1跟进中 / 2已转化项目 / 3已废弃
func businessStatusName(s int8) string {
switch s {
case 2:
return "已转化项目"
case 3:
return "已废弃"
default:
return "跟进中"
}
}
// businessStageName 商机阶段文案(用于操作日志):1初步接洽 … 5赢单 / 6输单
func businessStageName(s string) string {
switch strings.TrimSpace(s) {
@@ -480,6 +556,10 @@ func (c *BackendCrmBusinessController) Convert() {
pipelineErr(&c.Controller, 400, 400, "商机已转化,不能重复转化")
return
}
if biz.Status == 3 {
pipelineErr(&c.Controller, 400, 400, "商机已废弃,请先恢复为「跟进中」再转项目")
return
}
projectName := strings.TrimSpace(p.ProjectName)
if projectName == "" {
+4
View File
@@ -475,6 +475,10 @@ func (c *BackendCrmClueController) Convert() {
pipelineErr(&c.Controller, 400, 400, "线索已转化,不能重复转化")
return
}
if clue.Status == 3 {
pipelineErr(&c.Controller, 400, 400, "线索已废弃,请先恢复为「跟进中」再转化商机")
return
}
// 1. 处理客户:新建正式客户 或 关联已有客户
customerName := strings.TrimSpace(p.Customer.CustomerName)
+8 -5
View File
@@ -57,12 +57,15 @@ func isTenantAdmin(userID int, tenantID int) bool {
if tu.GroupID == 0 {
return true
}
// 「租户管理员」「租户用户」是 cid=2 且 tenant_id=0 的全局预置角色,
// 只有租户自建角色才带自己的 tenant_id,因此两种归属都要接受,
// 否则全局角色会因 tenant_id 不匹配而永远查不到角色。
cond := orm.NewCondition().
And("id", tu.GroupID).
And("cid", 2).
AndCond(orm.NewCondition().Or("tenant_id", 0).Or("tenant_id", tenantID))
var role models.AdminRole
if err := models.Orm.QueryTable(new(models.AdminRole)).
Filter("id", tu.GroupID).
Filter("cid", 2).
Filter("tenant_id", tenantID).
One(&role); err != nil {
if err := models.Orm.QueryTable(new(models.AdminRole)).SetCond(cond).One(&role); err != nil {
return false
}
if role.Name == "租户管理员" {
+261
View File
@@ -0,0 +1,261 @@
package controllers
import (
"encoding/json"
"io"
"strconv"
"strings"
"server/pkg/identity"
"server/pkg/jwtutil"
"server/services/dailyreport"
beego "github.com/beego/beego/v2/server/web"
)
// BackendOaDailyReportController OA工作日报(后台 PC 端)。
//
// 可见范围由 services/dailyreport 统一判定:
// - 本人:自己的全部日报(含草稿),补写窗口内可编辑/删除;
// - 部门主管:本人作为负责人的部门及其下级部门成员「已提交」的日报;
// - 租户管理员:全租户日报,可删除任意日报与评论。
type BackendOaDailyReportController struct {
beego.Controller
}
func (c *BackendOaDailyReportController) odrClaims() (*jwtutil.Claims, error) {
return identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndBackend)
}
func (c *BackendOaDailyReportController) odrErr(status, bizCode int, msg string) {
c.Ctx.Output.SetStatus(status)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *BackendOaDailyReportController) odrOk(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
func (c *BackendOaDailyReportController) odrFail(err error) {
code := dailyreport.StatusOf(err, 500)
msg := err.Error()
if code == 500 {
msg = "操作失败,请稍后重试"
}
c.odrErr(code, code, msg)
}
// odrCtx 解析令牌并组装日报身份;失败时已输出响应。
func (c *BackendOaDailyReportController) odrCtx() (*dailyreport.UserContext, bool) {
claims, err := c.odrClaims()
if err != nil {
code := identity.Status(err)
c.odrErr(code, code, err.Error())
return nil, false
}
u, err := dailyreport.BuildContext(
uint64(claims.TenantId), uint64(claims.UserID), claims.Username, claims.UserType == "platform")
if err != nil {
c.odrFail(err)
return nil, false
}
return u, true
}
func (c *BackendOaDailyReportController) odrIDParam(name string) uint64 {
id, _ := strconv.ParseUint(c.Ctx.Input.Param(name), 10, 64)
if id == 0 {
c.odrErr(400, 400, "无效ID")
}
return id
}
// Profile GET /backend/oa/dailyreport/profile
// 返回当前用户身份、补写窗口与团队可见性,供页面决定是否显示「团队日报」Tab。
func (c *BackendOaDailyReportController) Profile() {
u, ok := c.odrCtx()
if !ok {
return
}
teamOrgs := u.TeamOrgIDs
if teamOrgs == nil {
teamOrgs = []uint64{}
}
c.odrOk(map[string]interface{}{
"user_id": u.UID,
"user_name": u.Name,
"org_id": u.OrgID,
"org_name": u.OrgName,
"is_admin": u.IsAdmin,
"can_view_team": u.CanViewTeam,
"team_org_ids": teamOrgs,
"window_start": dailyreport.WindowStart(),
"window_end": dailyreport.Today(),
"backfill_days": dailyreport.BackfillDays,
})
}
// MineList GET /backend/oa/dailyreport/mine/list
// 我的日报分页列表,支持 start_date/end_date 筛选。
func (c *BackendOaDailyReportController) MineList() {
u, ok := c.odrCtx()
if !ok {
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
res, err := dailyreport.ListMine(u, dailyreport.MineQuery{
StartDate: strings.TrimSpace(c.GetString("start_date")),
EndDate: strings.TrimSpace(c.GetString("end_date")),
Page: page,
PageSize: pageSize,
})
if err != nil {
c.odrFail(err)
return
}
c.odrOk(res)
}
// Save POST /backend/oa/dailyreport/save
// 新建或更新日报;submit=true 提交,false 存草稿。
func (c *BackendOaDailyReportController) Save() {
u, ok := c.odrCtx()
if !ok {
return
}
var in dailyreport.SaveInput
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil || json.Unmarshal(raw, &in) != nil {
c.odrErr(400, 400, "参数错误")
return
}
item, err := dailyreport.Save(u, in)
if err != nil {
c.odrFail(err)
return
}
c.odrOk(item)
}
// Detail GET /backend/oa/dailyreport/detail/:id
func (c *BackendOaDailyReportController) Detail() {
u, ok := c.odrCtx()
if !ok {
return
}
id := c.odrIDParam(":id")
if id == 0 {
return
}
detail, err := dailyreport.GetDetail(u, id)
if err != nil {
c.odrFail(err)
return
}
c.odrOk(detail)
}
// Delete DELETE /backend/oa/dailyreport/delete/:id
func (c *BackendOaDailyReportController) Delete() {
u, ok := c.odrCtx()
if !ok {
return
}
id := c.odrIDParam(":id")
if id == 0 {
return
}
if err := dailyreport.Delete(u, id); err != nil {
c.odrFail(err)
return
}
c.odrOk(map[string]interface{}{"id": id})
}
// TeamStatus GET /backend/oa/dailyreport/team/status
// 团队日报汇总:date/org_id/keyword/page/pageSize。
func (c *BackendOaDailyReportController) TeamStatus() {
u, ok := c.odrCtx()
if !ok {
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
orgID, _ := c.GetInt64("org_id", 0)
res, err := dailyreport.TeamStatus(u, dailyreport.TeamQuery{
Date: strings.TrimSpace(c.GetString("date")),
OrgID: uint64(orgID),
Keyword: strings.TrimSpace(c.GetString("keyword")),
Page: page,
PageSize: pageSize,
})
if err != nil {
c.odrFail(err)
return
}
c.odrOk(res)
}
// TeamOrgs GET /backend/oa/dailyreport/team/orgs
func (c *BackendOaDailyReportController) TeamOrgs() {
u, ok := c.odrCtx()
if !ok {
return
}
orgs, err := dailyreport.TeamOrgs(u)
if err != nil {
c.odrFail(err)
return
}
c.odrOk(map[string]interface{}{"list": orgs})
}
type oaDailyReportCommentPayload struct {
ReportID uint64 `json:"report_id"`
Content string `json:"content"`
ReplyToID uint64 `json:"reply_to_id"`
}
// Comment POST /backend/oa/dailyreport/comment
func (c *BackendOaDailyReportController) Comment() {
u, ok := c.odrCtx()
if !ok {
return
}
var payload oaDailyReportCommentPayload
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil || json.Unmarshal(raw, &payload) != nil {
c.odrErr(400, 400, "参数错误")
return
}
if payload.ReportID == 0 {
c.odrErr(400, 400, "缺少日报ID")
return
}
comment, err := dailyreport.AddComment(u, payload.ReportID, payload.Content, payload.ReplyToID)
if err != nil {
c.odrFail(err)
return
}
c.odrOk(comment)
}
// DeleteComment DELETE /backend/oa/dailyreport/comment/:id
func (c *BackendOaDailyReportController) DeleteComment() {
u, ok := c.odrCtx()
if !ok {
return
}
id := c.odrIDParam(":id")
if id == 0 {
return
}
if err := dailyreport.DeleteComment(u, id); err != nil {
c.odrFail(err)
return
}
c.odrOk(map[string]interface{}{"id": id})
}
+40
View File
@@ -0,0 +1,40 @@
package controllers
import (
"server/services/wechatmini"
beego "github.com/beego/beego/v2/server/web"
)
// =============================================================
// 官网前台只读接口:小程序公开信息
//
// 供 website SPA / H5 / 其他端调取平台端「小程序配置」里维护的
// 小程序名称、AppID 与二维码图片地址,无需登录。
// GET /site/miniapp 已启用小程序列表(含 qrcode_url)
//
// 说明:qrcode_url 形如 /uploads/xxx(本地存储)或 CDN 绝对地址,
// 相对地址由前端拼接接口域名。
// =============================================================
// IndexMiniAppController 小程序公开信息
type IndexMiniAppController struct {
beego.Controller
}
// List GET /site/miniapp 已启用小程序列表(含二维码图片地址)
func (c *IndexMiniAppController) List() {
list, err := wechatmini.ListEnabledApps()
if err != nil {
c.Ctx.Output.SetStatus(500)
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "获取小程序列表失败"}
_ = c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
"data": map[string]interface{}{"list": list},
}
_ = c.ServeJSON()
}
+2
View File
@@ -155,6 +155,7 @@ func (c *PlatformWechatMiniController) SaveApp() {
Name string `json:"name"`
AppID string `json:"app_id"`
AppSecret string `json:"app_secret"`
QrcodeURL string `json:"qrcode_url"`
Enabled int `json:"enabled"`
AutoBind int `json:"auto_bind"`
Remark string `json:"remark"`
@@ -177,6 +178,7 @@ func (c *PlatformWechatMiniController) SaveApp() {
Name: p.Name,
AppID: p.AppID,
AppSecret: p.AppSecret,
QrcodeURL: p.QrcodeURL,
Enabled: p.Enabled == 1,
AutoBind: p.AutoBind == 1,
Remark: p.Remark,
@@ -0,0 +1,44 @@
-- =============================================================
-- OA工作日报:删除改为软删除(已建表环境执行)
--
-- 背景:原设计为物理删除(日报被删时同步删除评论),现改为软删除。
-- 日报:is_deleted=0 未删除;删除时写入记录 ID 作为删除批次标记。
-- 评论:is_deleted=0 未删除;删除时置 1。
--
-- 为什么唯一键要加 is_deleted:
-- 原唯一键 (tid, user_id, report_date) 会让「删除后同一天重新填写」与
-- 历史软删记录冲突。把 is_deleted 加入唯一键后,删除时写入记录 ID
-- (每条记录 ID 唯一),同一天可反复「填写→删除→重新填写」。
--
-- 适用:已执行过 create_oa_dailyreport.sql 的环境;
-- 新库直接执行 create_oa_dailyreport.sql 即为软删除结构,无需本脚本。
-- 幂等:重复执行安全。
-- =============================================================
USE `go-platform`;
-- 1) is_deleted 扩为 BIGINT UNSIGNED,用于存放删除批次(记录 ID)。MODIFY 幂等。
ALTER TABLE `yz_backend_oa_daily_report`
MODIFY COLUMN `is_deleted` BIGINT UNSIGNED NOT NULL DEFAULT 0
COMMENT '软删除标记:0=未删除;删除时写入记录ID(避免唯一键冲突)';
-- 2) 唯一键加入 is_deleted(先判断当前索引列,避免重复执行报 1091)
SET @idx_cols := (
SELECT GROUP_CONCAT(`COLUMN_NAME` ORDER BY `SEQ_IN_INDEX`)
FROM `information_schema`.`STATISTICS`
WHERE `TABLE_SCHEMA` = DATABASE()
AND `TABLE_NAME` = 'yz_backend_oa_daily_report'
AND `INDEX_NAME` = 'uk_tid_user_date'
);
SET @ddl := IF(
@idx_cols = 'tid,user_id,report_date,is_deleted',
'SELECT ''uk_tid_user_date 已包含 is_deleted,跳过'' AS msg',
'ALTER TABLE `yz_backend_oa_daily_report` DROP INDEX `uk_tid_user_date`, ADD UNIQUE KEY `uk_tid_user_date` (`tid`, `user_id`, `report_date`, `is_deleted`)'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 3) 核对结果
SHOW INDEX FROM `yz_backend_oa_daily_report` WHERE `Key_name` = 'uk_tid_user_date';
SHOW FULL COLUMNS FROM `yz_backend_oa_daily_report` WHERE `Field` = 'is_deleted';
@@ -0,0 +1,11 @@
-- 微信小程序多应用表补充「小程序码」字段
-- 平台端「小程序配置 - 新增/编辑小程序」上传并保存二维码图片地址,
-- 官网 / H5 等其他端通过 GET /site/miniapp 读取 qrcode_url 展示。
-- 存储格式:上传后的访问地址(本地为 /uploads/...,七牛为 CDN 绝对地址)
ALTER TABLE `yz_platform_wechat_mini_app`
ADD COLUMN `qrcode_url` varchar(512) NOT NULL DEFAULT '' COMMENT '小程序码图片地址(供官网/H5等其他端调取)' AFTER `app_secret`;
-- 结果校验
SELECT id, name, app_id, enabled, qrcode_url
FROM yz_platform_wechat_mini_app
WHERE delete_time IS NULL;
+66
View File
@@ -0,0 +1,66 @@
-- =============================================================
-- OA 工作日报 —— 表结构
--
-- 对应代码:
-- models/oa_daily_report.go
-- services/oauth/dailyreport_service.go
-- controllers/backend_oa_dailyreport.go(后台 PC 端)
-- controllers/app_oa_dailyreport.go(小程序端)
--
-- 设计要点:
-- 1. 每人每天一条日报,用 (tid, user_id, report_date, is_deleted) 唯一键约束;
-- user_id 存 SystemTenantUser.uid(不是 JWT 里的 identity_id)。
-- 2. 删除为软删除:is_deleted=0 表示未删除;删除时写入记录 ID 作为删除批次标记,
-- 使唯一键在「删除后同一天重新填写」时不与历史软删记录冲突。
-- 评论同步软删除(is_deleted=1)。
-- 3. 同一天允许先存草稿(status=0)后提交(status=1)。
-- 4. 不显式指定 COLLATE,沿用数据库默认排序规则,避免与其他表
-- JOIN 字符串时出现 Illegal mix of collations。
-- =============================================================
SET NAMES utf8mb4;
-- ---------------- 1. 工作日报主表 ----------------
CREATE TABLE IF NOT EXISTS `yz_backend_oa_daily_report` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
`user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '作者uid(yz_system_tenant_user.uid)',
`user_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '作者姓名快照',
`org_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '作者部门ID(yz_backend_organization.id)',
`org_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '部门名称快照',
`report_date` DATE NOT NULL COMMENT '日报日期',
`today_done` TEXT COMMENT '今日完成',
`tomorrow_plan` TEXT COMMENT '明日计划',
`issues` TEXT COMMENT '问题与建议',
`attachments` TEXT COMMENT '附件JSON数组:[{"id":1,"url":"","name":"","size":0}]',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '0草稿 1已提交',
`submit_time` DATETIME DEFAULT NULL COMMENT '提交时间',
`is_deleted` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '软删除标记:0=未删除;删除时写入记录ID(避免唯一键冲突)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tid_user_date` (`tid`, `user_id`, `report_date`, `is_deleted`),
KEY `idx_tid_date` (`tid`, `report_date`),
KEY `idx_tid_org_date` (`tid`, `org_id`, `report_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作日报';
-- ---------------- 2. 日报评论表 ----------------
-- reply_to_id 指向同表评论ID,用于两级回复(0 表示直接评论日报)。
CREATE TABLE IF NOT EXISTS `yz_backend_oa_daily_report_comment` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
`report_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '日报ID',
`user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '评论人uid',
`user_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '评论人姓名快照',
`content` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '评论内容',
`reply_to_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '回复的评论ID,0=直接评论',
`reply_to_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '被回复人姓名快照',
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记:0=未删除 1=已删除',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_report` (`report_id`, `id`),
KEY `idx_tid` (`tid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作日报评论';
+2 -1
View File
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS yz_platform_wechat_mini_app (
name varchar(128) NOT NULL DEFAULT '' COMMENT '应用名称',
app_id varchar(64) NOT NULL COMMENT '小程序 AppID',
app_secret varchar(512) NOT NULL DEFAULT '' COMMENT 'AppSecret(AES-256-GCM 加密存储)',
qrcode_url varchar(512) NOT NULL DEFAULT '' COMMENT '小程序码图片地址(供官网/H5等其他端调取)',
enabled tinyint NOT NULL DEFAULT 0 COMMENT '1=启用 0=停用',
auto_bind tinyint NOT NULL DEFAULT 1 COMMENT '1=手机号命中身份后自动绑定小程序',
remark varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
@@ -32,6 +33,6 @@ WHERE c.app_id <> ''
);
-- 结果校验
SELECT id, name, app_id, enabled, auto_bind, remark
SELECT id, name, app_id, enabled, auto_bind, remark, qrcode_url
FROM yz_platform_wechat_mini_app
WHERE delete_time IS NULL;
+64
View File
@@ -0,0 +1,64 @@
-- ============================================================
-- OA 工作日报 —— 菜单配置(yz_system_menu)
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < oa_dailyreport_menus.sql
--
-- 挂载位置:OA → 个人办公(path = '/apps/oa/personal')
-- 页面:我的日报 + 团队日报(同一页面内 Tab,团队 Tab 由接口权限决定是否显示)
-- 说明:platforms 显式声明为 Web + APP(JSON:["web","app"]),小程序端
-- 通过 utils/nav.js 的原生规则跳转到 pages/oa/dailyreport/index。
--
-- 注意:租户端角色(cid=2)的 rights 是显式菜单 ID 列表,菜单接口会按
-- rights 过滤。只插菜单不授权,会导致「个人办公」目录可见但
-- 「工作日报」被过滤掉。因此本脚本同时把菜单 ID 追加到角色 rights。
-- ============================================================
SET @personal_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa/personal' AND delete_time IS NULL
LIMIT 1
);
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
SELECT @personal_id, '工作日报', '/apps/oa/dailyreport', '/apps/oa/dailyreport/index.vue',
'Notebook', 3, 1, 1, '[2]', '["web","app"]', 2, 'system',
'按日填写今日完成/明日计划/问题建议,支持附件与评论;主管可查看团队汇总'
FROM DUAL
WHERE @personal_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/dailyreport' AND delete_time IS NULL) x);
-- 已有菜单的组件路径统一为带前导斜杠(与个人办公下其他菜单一致)
UPDATE yz_system_menu
SET component_path = '/apps/oa/dailyreport/index.vue'
WHERE path = '/apps/oa/dailyreport'
AND delete_time IS NULL
AND component_path = 'apps/oa/dailyreport/index.vue';
SET @report_menu_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa/dailyreport' AND delete_time IS NULL
LIMIT 1
);
-- ---------- 角色授权:把菜单追加到所有租户端角色的 rights ----------
-- 只处理 rights 非空(显式授权)的角色;rights 为空表示全权限,无需追加。
UPDATE yz_system_admin_role
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @report_menu_id) AS CHAR)
WHERE cid = 2
AND rights IS NOT NULL
AND TRIM(rights) <> ''
AND JSON_VALID(rights)
AND @report_menu_id IS NOT NULL
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@report_menu_id AS JSON));
-- ---------- 结果校验 ----------
SELECT id, pid, title, path, component_path, platforms, type, sort
FROM yz_system_menu
WHERE delete_time IS NULL AND path = '/apps/oa/dailyreport';
SELECT id, name, tenant_id,
JSON_CONTAINS(CAST(rights AS JSON), CAST(@report_menu_id AS JSON)) AS has_dailyreport
FROM yz_system_admin_role
WHERE cid = 2 AND rights IS NOT NULL AND TRIM(rights) <> ''
ORDER BY tenant_id, id;
+3
View File
@@ -192,6 +192,9 @@ func Init(_ string) {
new(OaSchedule),
new(OaHoliday),
new(OaNotice),
// 工作日报(docs/sql/create_oa_dailyreport.sql)
new(OaDailyReport),
new(OaDailyReportComment),
new(OaDocCategory),
new(OaDoc),
new(OaDocLink),
+59
View File
@@ -0,0 +1,59 @@
package models
import "time"
// OaDailyReport OA工作日报 yz_backend_oa_daily_report
//
// 一人一天一条:user_id 存 yz_system_tenant_user.uid(不是 JWT 的 identity_id),
// 由 (tid, user_id, report_date, is_deleted) 唯一键保证。
// 草稿 status=0,提交后 status=1;删除为软删除。
//
// IsDeleted 同时承担两个职责:0=未删除;删除时写入记录 ID 作为删除批次标记,
// 这样唯一键在「删掉后同一天重新填写」时不会与历史软删记录冲突。
type OaDailyReport struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
UserID uint64 `orm:"column(user_id)" json:"user_id"`
UserName string `orm:"column(user_name);size(50);default()" json:"user_name"`
OrgID uint64 `orm:"column(org_id);default(0)" json:"org_id"`
OrgName string `orm:"column(org_name);size(100);default()" json:"org_name"`
ReportDate string `orm:"column(report_date);type(date)" json:"report_date"`
TodayDone string `orm:"column(today_done);type(text);null" json:"today_done"`
TomorrowPlan string `orm:"column(tomorrow_plan);type(text);null" json:"tomorrow_plan"`
Issues string `orm:"column(issues);type(text);null" json:"issues"`
// Attachments 附件JSON数组:[{"id":1,"url":"","name":"","size":0}]
Attachments string `orm:"column(attachments);type(text);null" json:"attachments"`
Status int8 `orm:"column(status);default(1)" json:"status"` // 0草稿 1已提交
SubmitTime *time.Time `orm:"column(submit_time);type(datetime);null" json:"submit_time"`
IsDeleted uint64 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *OaDailyReport) TableName() string {
return "yz_backend_oa_daily_report"
}
// OaDailyReportComment OA工作日报评论 yz_backend_oa_daily_report_comment
//
// ReplyToID 指向同表评论ID,实现两级回复;0 表示直接评论日报。
// 删除为软删除(is_deleted=1)。
type OaDailyReportComment struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
ReportID uint64 `orm:"column(report_id)" json:"report_id"`
UserID uint64 `orm:"column(user_id)" json:"user_id"`
UserName string `orm:"column(user_name);size(50);default()" json:"user_name"`
Content string `orm:"column(content);size(1000)" json:"content"`
ReplyToID uint64 `orm:"column(reply_to_id);default(0)" json:"reply_to_id"`
ReplyToName string `orm:"column(reply_to_name);size(50);default()" json:"reply_to_name"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *OaDailyReportComment) TableName() string {
return "yz_backend_oa_daily_report_comment"
}
+1 -1
View File
@@ -26,7 +26,7 @@ type TenantCrmBusiness struct {
ContactWechat string `orm:"column(contact_wechat);size(64)" json:"contact_wechat"`
ContactQQ string `orm:"column(contact_qq);size(20)" json:"contact_qq"`
Address string `orm:"column(address);size(255)" json:"address"`
Status int8 `orm:"column(status);default(1)" json:"status"` // 1跟进中/2已转化项目/3已关闭
Status int8 `orm:"column(status);default(1)" json:"status"` // 1跟进中/2已转化项目/3已废弃
Locked int8 `orm:"column(locked);default(0)" json:"locked"` // 转化项目后锁定
ProjectID *uint64 `orm:"column(project_id);null" json:"project_id"` // 转化后的项目ID
ConvertTime *time.Time `orm:"column(convert_time);type(datetime);null" json:"convert_time"`
+2 -1
View File
@@ -30,7 +30,8 @@ type WechatMiniApp struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Name string `orm:"column(name);size(128);default('')" json:"name"`
AppID string `orm:"column(app_id);size(64)" json:"app_id"`
AppSecret string `orm:"column(app_secret);size(512)" json:"-"` // 加密存储,不回显
AppSecret string `orm:"column(app_secret);size(512)" json:"-"` // 加密存储,不回显
QrcodeURL string `orm:"column(qrcode_url);size(512);default('')" json:"qrcode_url"` // 小程序码图片地址,供官网 / H5 等其他端调取
Enabled int8 `orm:"column(enabled);default(0)" json:"enabled"`
AutoBind int8 `orm:"column(auto_bind);default(1)" json:"auto_bind"` // 1:手机号命中身份后自动绑定
Remark string `orm:"column(remark);size(255)" json:"remark"`
+12
View File
@@ -108,6 +108,18 @@ func Register() {
beego.Router("/api/oa/schedule/upload", &controllers.AppOaController{}, "post:ScheduleUploadImage")
beego.Router("/api/oa/schedule/images/:id", &controllers.AppOaController{}, "post:ScheduleImages")
// OA 工作日报(与 /backend/oa/dailyreport/* 同一套数据;小程序「工作日报」页面使用)
beego.Router("/api/oa/dailyreport/profile", &controllers.AppOaController{}, "get:DailyReportProfile")
beego.Router("/api/oa/dailyreport/mine/list", &controllers.AppOaController{}, "get:DailyReportMineList")
beego.Router("/api/oa/dailyreport/save", &controllers.AppOaController{}, "post:DailyReportSave")
beego.Router("/api/oa/dailyreport/detail/:id", &controllers.AppOaController{}, "get:DailyReportDetail")
beego.Router("/api/oa/dailyreport/delete/:id", &controllers.AppOaController{}, "delete:DailyReportDelete")
beego.Router("/api/oa/dailyreport/team/status", &controllers.AppOaController{}, "get:DailyReportTeamStatus")
beego.Router("/api/oa/dailyreport/team/orgs", &controllers.AppOaController{}, "get:DailyReportTeamOrgs")
beego.Router("/api/oa/dailyreport/comment", &controllers.AppOaController{}, "post:DailyReportComment")
beego.Router("/api/oa/dailyreport/comment/:id", &controllers.AppOaController{}, "delete:DailyReportCommentDelete")
beego.Router("/api/oa/dailyreport/upload", &controllers.AppOaController{}, "post:DailyReportUpload")
// OA 报销管理(与 /backend/reimbursements* 同一套数据;小程序「报销管理」页面使用)
beego.Router("/api/oa/expense/list", &controllers.AppOaController{}, "get:ExpenseList")
beego.Router("/api/oa/expense/types", &controllers.AppOaController{}, "get:ExpenseTypes")
+13
View File
@@ -349,6 +349,17 @@ func RegisterAuthRoutes() {
// 中国法定节假日/调休日(日历控件休/班标记,数据全局共享、按年缓存)
beego.Router("/backend/oa/holiday", &controllers.BackendOaScheduleController{}, "get:Holidays")
// OA工作日报(本人 / 部门主管 / 管理员三级可见,支持评论)
beego.Router("/backend/oa/dailyreport/profile", &controllers.BackendOaDailyReportController{}, "get:Profile")
beego.Router("/backend/oa/dailyreport/mine/list", &controllers.BackendOaDailyReportController{}, "get:MineList")
beego.Router("/backend/oa/dailyreport/save", &controllers.BackendOaDailyReportController{}, "post:Save")
beego.Router("/backend/oa/dailyreport/detail/:id", &controllers.BackendOaDailyReportController{}, "get:Detail")
beego.Router("/backend/oa/dailyreport/delete/:id", &controllers.BackendOaDailyReportController{}, "delete:Delete")
beego.Router("/backend/oa/dailyreport/team/status", &controllers.BackendOaDailyReportController{}, "get:TeamStatus")
beego.Router("/backend/oa/dailyreport/team/orgs", &controllers.BackendOaDailyReportController{}, "get:TeamOrgs")
beego.Router("/backend/oa/dailyreport/comment", &controllers.BackendOaDailyReportController{}, "post:Comment")
beego.Router("/backend/oa/dailyreport/comment/:id", &controllers.BackendOaDailyReportController{}, "delete:DeleteComment")
// OA通知公告
beego.Router("/backend/oa/notice/list", &controllers.BackendOaNoticeController{}, "get:List")
beego.Router("/backend/oa/notice/portal", &controllers.BackendOaNoticeController{}, "get:Portal")
@@ -463,6 +474,8 @@ func registerOrganizationRoutes(module string) {
beego.Router("/backend/crm/business/:id/convert", &controllers.BackendCrmBusinessController{}, "post:Convert")
// 商机阶段流转:1初步接洽 2需求确认 3方案报价 4商务谈判 5赢单 6输单
beego.Router("/backend/crm/business/:id/stage", &controllers.BackendCrmBusinessController{}, "post:ChangeStage")
// 商机状态流转:1跟进中 / 3已废弃(「已转化项目」须走「转项目」流程)
beego.Router("/backend/crm/business/:id/status", &controllers.BackendCrmBusinessController{}, "post:ChangeStatus")
// CRM项目管理
beego.Router("/backend/crm/project/list", &controllers.BackendCrmProjectController{}, "get:List")
+3
View File
@@ -20,6 +20,9 @@ func Register() {
// 官网页头导航菜单(平台「官网管理 - 导航菜单」配置,仅返回启用且显示的项)
beego.Router("/site/nav", &controllers.IndexWebsiteController{}, "get:Nav")
// 小程序公开信息(平台「小程序配置」维护,供官网 / H5 等其他端调取二维码)
beego.Router("/site/miniapp", &controllers.IndexMiniAppController{}, "get:List")
// 官网端平台更新公告(公开读取,与平台端 /platform/upgrade 各自独立)
beego.Router("/site/upgrade/list", &controllers.IndexUpgradeNoticeController{}, "get:List")
+282
View File
@@ -0,0 +1,282 @@
package dailyreport
import (
"errors"
"fmt"
"strings"
"time"
"server/models"
"server/services"
"github.com/beego/beego/v2/client/orm"
)
// 日报补写窗口:含今天在内的最近 7 天。
const BackfillDays = 7
// 业务错误:控制器按 Code 决定 HTTP 状态码与提示。
type Error struct {
Code int
Msg string
}
func (e *Error) Error() string { return e.Msg }
func errBad(msg string) error { return &Error{Code: 400, Msg: msg} }
func errForbidden(msg string) error { return &Error{Code: 403, Msg: msg} }
func errNotFound(msg string) error { return &Error{Code: 404, Msg: msg} }
// StatusOf 取业务错误的 HTTP 状态码,非业务错误回退 fallback。
func StatusOf(err error, fallback int) int {
var e *Error
if errors.As(err, &e) {
return e.Code
}
return fallback
}
// UserContext 当前操作人在日报模块内的身份与可见范围。
//
// UID 是 yz_system_tenant_user.uid(老 uid),不是 JWT 的 identity_id。
// TeamOrgIDs 是「当前用户作为负责人的部门」及其全部下级部门;
// 为空且非管理员时,只能查看/操作自己的日报。
type UserContext struct {
Tid uint64
UID uint64
Name string
OrgID uint64
OrgName string
IsAdmin bool
TeamOrgIDs []uint64
CanViewTeam bool
}
// InTeamOrg 判断部门是否落在当前用户的团队范围内。
func (u *UserContext) InTeamOrg(orgID uint64) bool {
for _, id := range u.TeamOrgIDs {
if id == orgID {
return true
}
}
return false
}
// Today 本地日期 "YYYY-MM-DD"。
func Today() string {
return time.Now().Format("2006-01-02")
}
// WindowStart 补写窗口起点(今天往前 BackfillDays-1 天)。
func WindowStart() string {
return time.Now().AddDate(0, 0, -(BackfillDays - 1)).Format("2006-01-02")
}
// InWindow 判断日期是否在补写窗口内。
func InWindow(date string) bool {
return date >= WindowStart() && date <= Today()
}
// ParseDate 校验 "YYYY-MM-DD" 并返回规范化的字符串。
func ParseDate(date string) (string, error) {
date = strings.TrimSpace(date)
if date == "" {
return Today(), nil
}
t, err := time.ParseInLocation("2006-01-02", date, time.Local)
if err != nil {
return "", errBad("日报日期格式应为 YYYY-MM-DD")
}
return t.Format("2006-01-02"), nil
}
// dateOnly 把驱动返回的 DATE 值统一成 "YYYY-MM-DD"。
// go-sql-driver 在 parseTime=true 时把 DATE 读成 time.Time,
// Beego ORM 赋给 string 字段后会得到 "2026-10-21 00:00:00 +0800 CST",
// 直接参与 InWindow 的字符串比较会误判为“不在窗口内”。
func dateOnly(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 10 {
return s[:10]
}
return s
}
// BuildContext 组装当前用户的日报身份与团队范围。
// identityUserID 为 JWT 中的 UserID,platformAdmin 表示平台端管理员。
func BuildContext(tid uint64, identityUserID uint64, fallbackName string, platformAdmin bool) (*UserContext, error) {
if models.Orm == nil || tid == 0 {
return nil, errBad("缺少租户信息")
}
uid := services.ResolveTenantUID(tid, identityUserID)
if uid == 0 {
return nil, errBad("无法识别当前用户")
}
u := &UserContext{Tid: tid, UID: uid, Name: strings.TrimSpace(fallbackName), IsAdmin: platformAdmin}
var tu models.SystemTenantUser
err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
Filter("uid", uid).
Filter("delete_time__isnull", true).
One(&tu)
if err == nil {
u.OrgID = tu.OrgID
if n := strings.TrimSpace(ptrString(tu.Name)); n != "" {
u.Name = n
} else if a := strings.TrimSpace(ptrString(tu.Account)); a != "" {
u.Name = a
}
if !u.IsAdmin {
u.IsAdmin = tenantAdmin(tid, &tu)
}
} else if err != orm.ErrNoRows {
return nil, err
}
if u.Name == "" {
u.Name = fmt.Sprintf("用户%d", uid)
}
orgs, err := loadOrgs(tid)
if err != nil {
return nil, err
}
orgByID := make(map[uint64]*models.BackendOrganization, len(orgs))
for i := range orgs {
orgByID[orgs[i].ID] = &orgs[i]
}
if o := orgByID[u.OrgID]; o != nil {
u.OrgName = o.OrgName
}
if !u.IsAdmin {
leaderKeys := leaderKeys(tid, &tu, identityUserID, uid)
roots := make([]uint64, 0, 2)
for i := range orgs {
if orgs[i].LeaderID != nil && containsID(leaderKeys, *orgs[i].LeaderID) {
roots = append(roots, orgs[i].ID)
}
}
u.TeamOrgIDs = collectSubtree(roots, orgs)
}
u.CanViewTeam = u.IsAdmin || len(u.TeamOrgIDs) > 0
return u, nil
}
// tenantAdmin 与 controllers.isTenantAdmin 判定口径一致:
// 未分配角色 / 租户管理员角色 / 角色未配置权限,均视为全权限。
//
// 角色查询额外兼容全局预置角色:「租户管理员」「租户用户」在
// yz_system_admin_role 中是 cid=2 且 tenant_id=0 的全局记录,
// 只有租户自建角色才带自己的 tenant_id,因此这里同时接受 0 与本租户。
func tenantAdmin(tid uint64, tu *models.SystemTenantUser) bool {
if tu.GroupID == 0 {
return true
}
cond := orm.NewCondition().
And("id", tu.GroupID).
And("cid", 2).
AndCond(orm.NewCondition().Or("tenant_id", 0).Or("tenant_id", tid))
var role models.AdminRole
if err := models.Orm.QueryTable(new(models.AdminRole)).SetCond(cond).One(&role); err != nil {
return false
}
if role.Name == "租户管理员" {
return true
}
rights := ""
if role.Rights != nil {
rights = strings.TrimSpace(*role.Rights)
}
return rights == "" || rights == "[]" || rights == "null"
}
// leaderKeys 负责人在库里可能存员工ID,也可能存租户用户 uid / 认证中心 uid,
// 这里把三种可能都收进来,避免因历史数据差异漏判主管身份。
func leaderKeys(tid uint64, tu *models.SystemTenantUser, identityUserID, uid uint64) []uint64 {
keys := []uint64{uid}
if identityUserID > 0 && identityUserID != uid {
keys = append(keys, identityUserID)
}
account := strings.TrimSpace(ptrString(tu.Account))
if account == "" {
return keys
}
var emps []models.BackendEmployee
if _, err := models.Orm.QueryTable(new(models.BackendEmployee)).
Filter("tid", tid).
Filter("account", account).
Filter("delete_time__isnull", true).
All(&emps); err != nil {
return keys
}
for _, e := range emps {
keys = append(keys, uint64(e.ID))
}
return keys
}
// loadOrgs 读取租户内未删除的部门。
func loadOrgs(tid uint64) ([]models.BackendOrganization, error) {
var orgs []models.BackendOrganization
_, err := models.Orm.QueryTable(new(models.BackendOrganization)).
Filter("tid", tid).
Filter("delete_time__isnull", true).
OrderBy("sort", "id").
All(&orgs)
if err != nil {
return nil, err
}
return orgs, nil
}
// collectSubtree 返回 roots 及其全部下级部门 ID。
func collectSubtree(roots []uint64, orgs []models.BackendOrganization) []uint64 {
if len(roots) == 0 {
return nil
}
children := make(map[uint64][]uint64, len(orgs))
for _, o := range orgs {
if o.ParentID > 0 {
children[o.ParentID] = append(children[o.ParentID], o.ID)
}
}
seen := make(map[uint64]bool, len(roots))
queue := make([]uint64, 0, len(roots))
for _, id := range roots {
if id > 0 && !seen[id] {
seen[id] = true
queue = append(queue, id)
}
}
out := make([]uint64, 0, len(queue))
for len(queue) > 0 {
id := queue[0]
queue = queue[1:]
out = append(out, id)
for _, child := range children[id] {
if !seen[child] {
seen[child] = true
queue = append(queue, child)
}
}
}
return out
}
func containsID(ids []uint64, id uint64) bool {
for _, v := range ids {
if v == id {
return true
}
}
return false
}
func ptrString(p *string) string {
if p == nil {
return ""
}
return *p
}
+568
View File
@@ -0,0 +1,568 @@
package dailyreport
import (
"encoding/json"
"fmt"
"strings"
"time"
"unicode/utf8"
"server/models"
"github.com/beego/beego/v2/client/orm"
)
// Attachment 日报附件,与 PC / 小程序上传接口返回的文件信息对齐。
type Attachment struct {
ID uint64 `json:"id"`
URL string `json:"url"`
Name string `json:"name"`
Size int64 `json:"size"`
}
// ReportItem 日报列表/详情中的主体。
type ReportItem struct {
ID uint64 `json:"id"`
UserID uint64 `json:"user_id"`
UserName string `json:"user_name"`
OrgID uint64 `json:"org_id"`
OrgName string `json:"org_name"`
ReportDate string `json:"report_date"`
TodayDone string `json:"today_done"`
TomorrowPlan string `json:"tomorrow_plan"`
Issues string `json:"issues"`
Attachments []Attachment `json:"attachments"`
Status int8 `json:"status"`
SubmitTime string `json:"submit_time"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
CommentCount int `json:"comment_count"`
}
// Comment 日报评论(两级回复)。
type Comment struct {
ID uint64 `json:"id"`
UserID uint64 `json:"user_id"`
UserName string `json:"user_name"`
Content string `json:"content"`
ReplyToID uint64 `json:"reply_to_id"`
ReplyToName string `json:"reply_to_name"`
CreateTime string `json:"create_time"`
IsMine bool `json:"is_mine"`
CanDelete bool `json:"can_delete"`
}
// Detail 日报详情:主体 + 评论 + 当前用户可执行的操作。
type Detail struct {
Report ReportItem `json:"report"`
Comments []Comment `json:"comments"`
CanEdit bool `json:"can_edit"`
CanDelete bool `json:"can_delete"`
CanComment bool `json:"can_comment"`
}
// SaveInput 保存日报入参;Submit=true 表示提交,否则保存草稿。
type SaveInput struct {
Date string `json:"date"`
TodayDone string `json:"today_done"`
TomorrowPlan string `json:"tomorrow_plan"`
Issues string `json:"issues"`
Attachments string `json:"attachments"`
Submit bool `json:"submit"`
}
// MineQuery 我的日报列表查询。
type MineQuery struct {
StartDate string
EndDate string
Page int
PageSize int
}
// MineSummary 我的日报页头统计。
type MineSummary struct {
MonthTotal int `json:"month_total"`
MonthSubmitted int `json:"month_submitted"`
TodayStatus int8 `json:"today_status"` // -1=今天未填写
TodayReportID uint64 `json:"today_report_id"`
WindowStart string `json:"window_start"`
WindowEnd string `json:"window_end"`
}
// MineResult 我的日报列表结果。
type MineResult struct {
List []ReportItem `json:"list"`
Total int64 `json:"total"`
Summary MineSummary `json:"summary"`
}
// Save 新建或更新一条日报(同一人同一天唯一)。
func Save(u *UserContext, in SaveInput) (*ReportItem, error) {
date, err := ParseDate(in.Date)
if err != nil {
return nil, err
}
if !InWindow(date) {
return nil, errBad(fmt.Sprintf("只能补写最近%d天(%s 至 %s)的日报", BackfillDays, WindowStart(), Today()))
}
in.TodayDone = strings.TrimSpace(in.TodayDone)
in.TomorrowPlan = strings.TrimSpace(in.TomorrowPlan)
in.Issues = strings.TrimSpace(in.Issues)
for name, text := range map[string]string{
"今日完成": in.TodayDone,
"明日计划": in.TomorrowPlan,
"问题与建议": in.Issues,
} {
if utf8.RuneCountInString(text) > 5000 {
return nil, errBad(name + "不能超过5000字")
}
}
if in.TodayDone == "" && in.TomorrowPlan == "" && in.Issues == "" {
return nil, errBad("请至少填写今日完成、明日计划、问题与建议中的一项")
}
attachmentJSON, _, err := normalizeAttachments(in.Attachments)
if err != nil {
return nil, err
}
var existing models.OaDailyReport
findErr := models.Orm.QueryTable(new(models.OaDailyReport)).
Filter("tid", u.Tid).
Filter("user_id", u.UID).
Filter("report_date", date).
Filter("is_deleted", 0).
One(&existing)
status := int8(0)
if in.Submit {
status = 1
}
now := time.Now()
if findErr == orm.ErrNoRows {
report := models.OaDailyReport{
Tid: u.Tid,
UserID: u.UID,
UserName: u.Name,
OrgID: u.OrgID,
OrgName: u.OrgName,
ReportDate: date,
TodayDone: in.TodayDone,
TomorrowPlan: in.TomorrowPlan,
Issues: in.Issues,
Attachments: attachmentJSON,
Status: status,
}
if status == 1 {
report.SubmitTime = &now
}
if _, err := models.Orm.Insert(&report); err != nil {
return nil, errBad("保存失败,该日期可能已存在日报,请刷新后重试")
}
return buildItem(&report, 0), nil
}
if findErr != nil {
return nil, findErr
}
// 已提交的日报在窗口内允许继续修改,状态保持已提交。
if existing.Status == 1 {
status = 1
}
if status == 1 && existing.SubmitTime == nil {
existing.SubmitTime = &now
}
existing.TodayDone = in.TodayDone
existing.TomorrowPlan = in.TomorrowPlan
existing.Issues = in.Issues
existing.Attachments = attachmentJSON
existing.Status = status
existing.UserName = u.Name
existing.OrgID = u.OrgID
existing.OrgName = u.OrgName
if _, err := models.Orm.Update(&existing, "today_done", "tomorrow_plan", "issues",
"attachments", "status", "submit_time", "user_name", "org_id", "org_name"); err != nil {
return nil, err
}
count := commentCount(u.Tid, existing.ID)
return buildItem(&existing, count), nil
}
// ListMine 我的日报分页列表 + 页头统计。
func ListMine(u *UserContext, q MineQuery) (*MineResult, error) {
if q.Page < 1 {
q.Page = 1
}
if q.PageSize < 1 || q.PageSize > 100 {
q.PageSize = 20
}
base := models.Orm.QueryTable(new(models.OaDailyReport)).
Filter("tid", u.Tid).
Filter("user_id", u.UID).
Filter("is_deleted", 0)
start, err := optionalDate(q.StartDate)
if err != nil {
return nil, err
}
end, err := optionalDate(q.EndDate)
if err != nil {
return nil, err
}
if start != "" {
base = base.Filter("report_date__gte", start)
}
if end != "" {
base = base.Filter("report_date__lte", end)
}
total, _ := base.Count()
var rows []models.OaDailyReport
if _, err := base.OrderBy("-report_date", "-id").
Limit(q.PageSize).Offset((q.Page - 1) * q.PageSize).
All(&rows); err != nil {
return nil, err
}
ids := make([]uint64, 0, len(rows))
for i := range rows {
ids = append(ids, rows[i].ID)
}
counts := commentCounts(u.Tid, ids)
list := make([]ReportItem, 0, len(rows))
for i := range rows {
list = append(list, *buildItem(&rows[i], counts[rows[i].ID]))
}
summary, err := mineSummary(u)
if err != nil {
return nil, err
}
return &MineResult{List: list, Total: total, Summary: *summary}, nil
}
// mineSummary 本月提交统计与今天的状态。
func mineSummary(u *UserContext) (*MineSummary, error) {
now := time.Now()
monthStart := now.Format("2006-01") + "-01"
monthEnd := now.AddDate(0, 1, -1).Format("2006-01-02")
out := &MineSummary{
TodayStatus: -1,
WindowStart: WindowStart(),
WindowEnd: Today(),
}
base := models.Orm.QueryTable(new(models.OaDailyReport)).
Filter("tid", u.Tid).
Filter("user_id", u.UID).
Filter("is_deleted", 0)
out.MonthTotal = int(countWhere(base, "report_date__gte", monthStart, "report_date__lte", monthEnd))
out.MonthSubmitted = int(countWhere(base.Filter("status", 1), "report_date__gte", monthStart, "report_date__lte", monthEnd))
var today models.OaDailyReport
if err := models.Orm.QueryTable(new(models.OaDailyReport)).
Filter("tid", u.Tid).Filter("user_id", u.UID).
Filter("report_date", Today()).
Filter("is_deleted", 0).One(&today); err == nil {
out.TodayStatus = today.Status
out.TodayReportID = today.ID
}
return out, nil
}
// GetDetail 读取日报详情;无权限时返回 403。
func GetDetail(u *UserContext, id uint64) (*Detail, error) {
report, err := loadReport(u.Tid, id)
if err != nil {
return nil, err
}
if !canViewReport(u, report) {
return nil, errForbidden("无权查看该日报")
}
comments := make([]Comment, 0)
if report.Status == 1 {
comments, err = loadComments(u, report.ID)
if err != nil {
return nil, err
}
}
author := report.UserID == u.UID
return &Detail{
Report: *buildItem(report, len(comments)),
Comments: comments,
CanEdit: author && InWindow(report.ReportDate),
CanDelete: (author && InWindow(report.ReportDate)) || u.IsAdmin,
CanComment: report.Status == 1 && (author || u.IsAdmin || u.InTeamOrg(report.OrgID)),
}, nil
}
// Delete 软删除日报及其评论:作者限补写窗口内,管理员不受限。
//
// 日报的 is_deleted 写入记录 ID(而非 1),既标记删除,又让唯一键
// (tid, user_id, report_date, is_deleted) 在「删除后同一天重新填写」时不冲突。
func Delete(u *UserContext, id uint64) error {
report, err := loadReport(u.Tid, id)
if err != nil {
return err
}
author := report.UserID == u.UID
if !author && !u.IsAdmin {
return errForbidden("无权删除该日报")
}
if author && !u.IsAdmin && !InWindow(report.ReportDate) {
return errBad(fmt.Sprintf("只能删除最近%d天(%s 至 %s)的日报", BackfillDays, WindowStart(), Today()))
}
now := time.Now()
tx, err := models.Orm.Begin()
if err != nil {
return err
}
if _, err := tx.QueryTable(new(models.OaDailyReportComment)).
Filter("tid", u.Tid).Filter("report_id", report.ID).Filter("is_deleted", 0).
Update(map[string]interface{}{"is_deleted": 1, "delete_time": now}); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.QueryTable(new(models.OaDailyReport)).
Filter("tid", u.Tid).Filter("id", report.ID).
Update(map[string]interface{}{"is_deleted": report.ID, "delete_time": now}); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
// AddComment 对已提交日报发表评论或回复;replyToID=0 表示直接评论。
func AddComment(u *UserContext, reportID uint64, content string, replyToID uint64) (*Comment, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, errBad("请输入评论内容")
}
if utf8.RuneCountInString(content) > 1000 {
return nil, errBad("评论不能超过1000字")
}
report, err := loadReport(u.Tid, reportID)
if err != nil {
return nil, err
}
if !canViewReport(u, report) {
return nil, errForbidden("无权评论该日报")
}
if report.Status != 1 {
return nil, errBad("日报提交后才能评论")
}
replyName := ""
if replyToID > 0 {
var parent models.OaDailyReportComment
if err := models.Orm.QueryTable(new(models.OaDailyReportComment)).
Filter("tid", u.Tid).Filter("id", replyToID).Filter("report_id", reportID).
Filter("is_deleted", 0).
One(&parent); err != nil {
return nil, errBad("回复的评论不存在")
}
replyName = parent.UserName
}
row := models.OaDailyReportComment{
Tid: u.Tid,
ReportID: reportID,
UserID: u.UID,
UserName: u.Name,
Content: content,
ReplyToID: replyToID,
ReplyToName: replyName,
}
if _, err := models.Orm.Insert(&row); err != nil {
return nil, err
}
c := buildComment(u, &row)
return &c, nil
}
// DeleteComment 软删除评论:评论作者本人或管理员。
func DeleteComment(u *UserContext, commentID uint64) error {
var row models.OaDailyReportComment
if err := models.Orm.QueryTable(new(models.OaDailyReportComment)).
Filter("tid", u.Tid).Filter("id", commentID).Filter("is_deleted", 0).One(&row); err != nil {
return errNotFound("评论不存在或已删除")
}
if row.UserID != u.UID && !u.IsAdmin {
return errForbidden("只能删除自己的评论")
}
_, err := models.Orm.QueryTable(new(models.OaDailyReportComment)).
Filter("tid", u.Tid).Filter("id", commentID).Filter("is_deleted", 0).
Update(map[string]interface{}{"is_deleted": 1, "delete_time": time.Now()})
return err
}
// ========================== 内部工具 ==========================
func loadReport(tid, id uint64) (*models.OaDailyReport, error) {
var report models.OaDailyReport
if err := models.Orm.QueryTable(new(models.OaDailyReport)).
Filter("tid", tid).Filter("id", id).Filter("is_deleted", 0).One(&report); err != nil {
if err == orm.ErrNoRows {
return nil, errNotFound("日报不存在或已删除")
}
return nil, err
}
// 统一成 YYYY-MM-DD,保证 InWindow 与前端日期比较正确。
report.ReportDate = dateOnly(report.ReportDate)
return &report, nil
}
// canViewReport 本人 / 管理员 / 作者所在部门的主管;草稿仅本人与管理员可见。
func canViewReport(u *UserContext, r *models.OaDailyReport) bool {
if r.UserID == u.UID || u.IsAdmin {
return true
}
if r.Status != 1 {
return false
}
return u.InTeamOrg(r.OrgID)
}
func loadComments(u *UserContext, reportID uint64) ([]Comment, error) {
var rows []models.OaDailyReportComment
if _, err := models.Orm.QueryTable(new(models.OaDailyReportComment)).
Filter("tid", u.Tid).Filter("report_id", reportID).
Filter("is_deleted", 0).
OrderBy("id").All(&rows); err != nil {
return nil, err
}
out := make([]Comment, 0, len(rows))
for i := range rows {
out = append(out, buildComment(u, &rows[i]))
}
if out == nil {
out = []Comment{}
}
return out, nil
}
func buildComment(u *UserContext, r *models.OaDailyReportComment) Comment {
return Comment{
ID: r.ID,
UserID: r.UserID,
UserName: r.UserName,
Content: r.Content,
ReplyToID: r.ReplyToID,
ReplyToName: r.ReplyToName,
CreateTime: r.CreateTime.Format("2006-01-02 15:04"),
IsMine: r.UserID == u.UID,
CanDelete: r.UserID == u.UID || u.IsAdmin,
}
}
func buildItem(r *models.OaDailyReport, commentCount int) *ReportItem {
attachments := make([]Attachment, 0)
if strings.TrimSpace(r.Attachments) != "" {
_ = json.Unmarshal([]byte(r.Attachments), &attachments)
}
return &ReportItem{
ID: r.ID,
UserID: r.UserID,
UserName: r.UserName,
OrgID: r.OrgID,
OrgName: r.OrgName,
ReportDate: dateOnly(r.ReportDate),
TodayDone: r.TodayDone,
TomorrowPlan: r.TomorrowPlan,
Issues: r.Issues,
Attachments: attachments,
Status: r.Status,
SubmitTime: formatTime(r.SubmitTime),
CreateTime: r.CreateTime.Format("2006-01-02 15:04"),
UpdateTime: formatTime(r.UpdateTime),
CommentCount: commentCount,
}
}
func formatTime(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.Format("2006-01-02 15:04")
}
// normalizeAttachments 校验并规范化附件 JSON,最多 20 个。
func normalizeAttachments(raw string) (string, []Attachment, error) {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "null" {
return "[]", []Attachment{}, nil
}
var items []Attachment
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return "", nil, errBad("附件数据格式错误")
}
out := make([]Attachment, 0, len(items))
for _, it := range items {
if strings.TrimSpace(it.URL) == "" {
continue
}
if len(out) >= 20 {
return "", nil, errBad("附件最多20个")
}
out = append(out, it)
}
data, _ := json.Marshal(out)
return string(data), out, nil
}
func optionalDate(v string) (string, error) {
v = strings.TrimSpace(v)
if v == "" {
return "", nil
}
return ParseDate(v)
}
// countWhere 依次应用列值条件后计数。
func countWhere(qs orm.QuerySeter, kv ...string) int64 {
for i := 0; i+1 < len(kv); i += 2 {
qs = qs.Filter(kv[i], kv[i+1])
}
n, _ := qs.Count()
return n
}
func commentCount(tid, reportID uint64) int {
counts := commentCounts(tid, []uint64{reportID})
return counts[reportID]
}
// commentCounts 批量统计评论数,避免列表逐条查询。
func commentCounts(tid uint64, ids []uint64) map[uint64]int {
out := make(map[uint64]int, len(ids))
if len(ids) == 0 {
return out
}
holders := make([]string, 0, len(ids))
args := make([]interface{}, 0, len(ids)+1)
args = append(args, tid)
for _, id := range ids {
holders = append(holders, "?")
args = append(args, id)
}
var rows []struct {
ReportID uint64 `orm:"column(report_id)"`
Cnt int `orm:"column(cnt)"`
}
sql := "SELECT report_id, COUNT(*) AS cnt FROM yz_backend_oa_daily_report_comment" +
" WHERE tid = ? AND report_id IN (" + strings.Join(holders, ",") + ") AND is_deleted = 0 GROUP BY report_id"
if _, err := models.Orm.Raw(sql, args...).QueryRows(&rows); err != nil {
return out
}
for _, r := range rows {
out[r.ReportID] = r.Cnt
}
return out
}
+250
View File
@@ -0,0 +1,250 @@
package dailyreport
import (
"math"
"strings"
"server/models"
"github.com/beego/beego/v2/client/orm"
)
// TeamQuery 团队日报汇总查询。
type TeamQuery struct {
Date string
OrgID uint64
Keyword string
Page int
PageSize int
}
// TeamStats 团队提交统计。
type TeamStats struct {
Total int `json:"total"`
Submitted int `json:"submitted"`
Missing int `json:"missing"`
Rate float64 `json:"rate"` // 提交率百分比,保留一位小数
}
// TeamRow 团队汇总中的一名成员。
type TeamRow struct {
UserID uint64 `json:"user_id"`
UserName string `json:"user_name"`
Account string `json:"account"`
OrgID uint64 `json:"org_id"`
OrgName string `json:"org_name"`
Submitted bool `json:"submitted"`
ReportID uint64 `json:"report_id"`
Status int8 `json:"status"` // -1未填写 0草稿 1已提交
SubmitTime string `json:"submit_time"`
HasIssue bool `json:"has_issue"`
}
// TeamResult 团队汇总结果。
type TeamResult struct {
Date string `json:"date"`
List []TeamRow `json:"list"`
Total int64 `json:"total"`
Stats TeamStats `json:"stats"`
}
// OrgOption 团队筛选用的部门选项(前端按 parent_id 组装树)。
type OrgOption struct {
ID uint64 `json:"id"`
ParentID uint64 `json:"parent_id"`
Name string `json:"name"`
MemberCount int `json:"member_count"`
}
// TeamStatus 团队日报汇总:按日期返回管辖范围内每位成员的提交情况。
// 管理员不选部门时统计全租户(含未分配部门的成员)。
func TeamStatus(u *UserContext, q TeamQuery) (*TeamResult, error) {
if !u.CanViewTeam {
return nil, errForbidden("无权查看团队日报")
}
date, err := ParseDate(q.Date)
if err != nil {
return nil, err
}
if date > Today() {
return nil, errBad("不能查看未来日期的团队日报")
}
if q.Page < 1 {
q.Page = 1
}
if q.PageSize < 1 || q.PageSize > 100 {
q.PageSize = 20
}
orgs, err := loadOrgs(u.Tid)
if err != nil {
return nil, err
}
orgByID := make(map[uint64]*models.BackendOrganization, len(orgs))
for i := range orgs {
orgByID[orgs[i].ID] = &orgs[i]
}
var scopeIDs []uint64
if q.OrgID > 0 {
if !u.IsAdmin && !u.InTeamOrg(q.OrgID) {
return nil, errForbidden("无权查看该部门")
}
if _, ok := orgByID[q.OrgID]; !ok {
return nil, errBad("部门不存在")
}
scopeIDs = collectSubtree([]uint64{q.OrgID}, orgs)
} else if !u.IsAdmin {
scopeIDs = u.TeamOrgIDs
}
if scopeIDs != nil && len(scopeIDs) == 0 {
return nil, errForbidden("你还没有管辖的部门")
}
cond := orm.NewCondition().
And("tid", u.Tid).
And("delete_time__isnull", true).
And("status", 1).
AndNot("uid", u.UID)
if scopeIDs != nil {
cond = cond.And("org_id__in", scopeIDs)
}
if kw := strings.TrimSpace(q.Keyword); kw != "" {
cond = cond.AndCond(orm.NewCondition().
Or("name__icontains", kw).
Or("account__icontains", kw))
}
base := models.Orm.QueryTable(new(models.SystemTenantUser)).SetCond(cond)
total, _ := base.Count()
var members []models.SystemTenantUser
if _, err := base.OrderBy("org_id", "uid").
Limit(q.PageSize).Offset((q.Page - 1) * q.PageSize).
All(&members); err != nil {
return nil, err
}
// 统计口径覆盖全部成员,不受分页影响。
var allMembers []models.SystemTenantUser
if _, err := base.OrderBy("uid").All(&allMembers, "uid"); err != nil {
return nil, err
}
reports, err := loadReportsByDate(u.Tid, date)
if err != nil {
return nil, err
}
stats := TeamStats{Total: len(allMembers)}
for _, m := range allMembers {
if r := reports[m.Uid]; r != nil && r.Status == 1 {
stats.Submitted++
}
}
stats.Missing = stats.Total - stats.Submitted
if stats.Total > 0 {
stats.Rate = math.Round(float64(stats.Submitted)*1000/float64(stats.Total)) / 10
}
list := make([]TeamRow, 0, len(members))
for i := range members {
m := &members[i]
row := TeamRow{
UserID: m.Uid,
UserName: userNameOf(m),
Account: ptrString(m.Account),
OrgID: m.OrgID,
Status: -1,
}
if o := orgByID[m.OrgID]; o != nil {
row.OrgName = o.OrgName
}
if r := reports[m.Uid]; r != nil {
row.ReportID = r.ID
row.Status = r.Status
row.Submitted = r.Status == 1
row.SubmitTime = formatTime(r.SubmitTime)
row.HasIssue = strings.TrimSpace(r.Issues) != ""
}
list = append(list, row)
}
return &TeamResult{Date: date, List: list, Total: total, Stats: stats}, nil
}
// TeamOrgs 返回团队筛选可选部门;管理员为全租户,主管仅自己管辖的部门子树。
func TeamOrgs(u *UserContext) ([]OrgOption, error) {
if !u.CanViewTeam {
return nil, errForbidden("无权查看团队日报")
}
orgs, err := loadOrgs(u.Tid)
if err != nil {
return nil, err
}
allow := make(map[uint64]bool, len(orgs))
if u.IsAdmin {
for _, o := range orgs {
allow[o.ID] = true
}
} else {
for _, id := range u.TeamOrgIDs {
allow[id] = true
}
}
var users []models.SystemTenantUser
if _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", u.Tid).
Filter("delete_time__isnull", true).
Filter("status", 1).
All(&users, "org_id"); err != nil {
return nil, err
}
counts := make(map[uint64]int, len(orgs))
for _, m := range users {
if m.OrgID > 0 {
counts[m.OrgID]++
}
}
out := make([]OrgOption, 0, len(allow))
for i := range orgs {
o := &orgs[i]
if !allow[o.ID] {
continue
}
out = append(out, OrgOption{
ID: o.ID,
ParentID: o.ParentID,
Name: o.OrgName,
MemberCount: counts[o.ID],
})
}
return out, nil
}
// loadReportsByDate 一次取回某日全部已提交/草稿日报,按作者 uid 建索引。
func loadReportsByDate(tid uint64, date string) (map[uint64]*models.OaDailyReport, error) {
var rows []models.OaDailyReport
if _, err := models.Orm.QueryTable(new(models.OaDailyReport)).
Filter("tid", tid).
Filter("report_date", date).
Filter("is_deleted", 0).
All(&rows); err != nil {
return nil, err
}
out := make(map[uint64]*models.OaDailyReport, len(rows))
for i := range rows {
out[rows[i].UserID] = &rows[i]
}
return out, nil
}
func userNameOf(m *models.SystemTenantUser) string {
if n := strings.TrimSpace(ptrString(m.Name)); n != "" {
return n
}
if a := strings.TrimSpace(ptrString(m.Account)); a != "" {
return a
}
return "未命名成员"
}
+42 -6
View File
@@ -24,6 +24,7 @@ type Config struct {
Name string
AppID string
AppSecret string // 明文
QrcodeURL string // 小程序码图片地址(对外展示)
Enabled bool
AutoBind bool
Remark string
@@ -35,12 +36,13 @@ var ErrNotConfigured = errors.New("微信小程序未配置或未启用")
// appFromRow 解密行数据为运行配置
func appFromRow(row models.WechatMiniApp) (*Config, error) {
cfg := &Config{
ID: row.ID,
Name: row.Name,
AppID: strings.TrimSpace(row.AppID),
Enabled: row.Enabled == 1,
AutoBind: row.AutoBind != 0,
Remark: row.Remark,
ID: row.ID,
Name: row.Name,
AppID: strings.TrimSpace(row.AppID),
QrcodeURL: strings.TrimSpace(row.QrcodeURL),
Enabled: row.Enabled == 1,
AutoBind: row.AutoBind != 0,
Remark: row.Remark,
}
if row.AppSecret != "" {
plain, derr := wechatmp.DecryptSecret(row.AppSecret)
@@ -140,12 +142,44 @@ func ListApps() ([]models.WechatMiniApp, error) {
return rows, err
}
// PublicApp 对外公布的小程序信息(供官网 / H5 等其他端展示二维码)
type PublicApp struct {
ID uint64 `json:"id"`
Name string `json:"name"`
AppID string `json:"app_id"`
QrcodeURL string `json:"qrcode_url"`
}
// ListEnabledApps 已启用的小程序(含二维码地址),供其他端公开读取
func ListEnabledApps() ([]PublicApp, error) {
var rows []models.WechatMiniApp
_, err := models.Orm.QueryTable(new(models.WechatMiniApp)).
Filter("delete_time__isnull", true).
Filter("enabled", 1).
OrderBy("id").
All(&rows)
if err != nil {
return nil, err
}
list := make([]PublicApp, 0, len(rows))
for _, r := range rows {
list = append(list, PublicApp{
ID: r.ID,
Name: strings.TrimSpace(r.Name),
AppID: strings.TrimSpace(r.AppID),
QrcodeURL: strings.TrimSpace(r.QrcodeURL),
})
}
return list, nil
}
// SaveAppInput 保存应用入参(AppSecret 支持掩码表示不修改)
type SaveAppInput struct {
ID uint64
Name string
AppID string
AppSecret string // 掩码 / 空串 = 保持不变
QrcodeURL string // 空串 = 清空二维码
Enabled bool
AutoBind bool
Remark string
@@ -202,6 +236,7 @@ func SaveApp(in SaveAppInput) (uint64, error) {
if in.Enabled {
row.Enabled = 1
}
row.QrcodeURL = strings.TrimSpace(in.QrcodeURL)
row.AutoBind = 0
if in.AutoBind {
row.AutoBind = 1
@@ -273,6 +308,7 @@ func SaveConfig(in SaveInput) error {
Name: row.Name,
AppID: in.AppID,
AppSecret: in.AppSecret,
QrcodeURL: row.QrcodeURL, // 旧端点不改动二维码
Enabled: in.Enabled,
AutoBind: in.AutoBind,
Remark: in.Remark,
+2 -1
View File
@@ -4,7 +4,7 @@ import request from "@/utils/request";
* 小程序多应用管理
*/
/** 获取小程序应用列表(AppSecret 不回显,返回 configured 标记) */
/** 获取小程序应用列表(AppSecret 不回显,返回 configured 标记;含 qrcode_url) */
export function listMiniApps() {
return request({
url: "/platform/wechatMini/apps",
@@ -19,6 +19,7 @@ export function listMiniApps() {
* @param {string} data.name 应用名称
* @param {string} data.app_id 小程序 AppID
* @param {string} [data.app_secret] 掩码/空串 = 不修改原值
* @param {string} [data.qrcode_url] 小程序二维码图片地址(空串 = 清空)
* @param {number} data.enabled 1=启用 0=停用
* @param {number} data.auto_bind 1=手机号命中身份后自动绑定
* @param {string} [data.remark] 备注
@@ -48,6 +48,19 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="二维码" width="90" align="center">
<template #default="scope">
<el-image
v-if="scope.row.qrcode_url"
:src="previewUrl(scope.row.qrcode_url)"
:preview-src-list="[previewUrl(scope.row.qrcode_url)]"
preview-teleported
fit="contain"
style="width: 48px; height: 48px"
/>
<span v-else class="empty-tip">未上传</span>
</template>
</el-table-column>
<el-table-column label="启用" width="80" align="center">
<template #default="scope">
<el-switch
@@ -127,6 +140,46 @@
"
/>
</el-form-item>
<el-form-item label="小程序二维码">
<div class="qrcode-field">
<div v-if="form.qrcode_url" class="qrcode-preview">
<el-image
:src="previewUrl(form.qrcode_url)"
:preview-src-list="[previewUrl(form.qrcode_url)]"
preview-teleported
fit="contain"
style="width: 120px; height: 120px"
/>
<div class="qrcode-actions">
<el-upload
:show-file-list="false"
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
:http-request="handleQrcodeUpload"
>
<el-button size="small" :loading="qrcodeUploading">重新上传</el-button>
</el-upload>
<el-button size="small" type="danger" plain @click="removeQrcode">
删除
</el-button>
</div>
</div>
<el-upload
v-else
:show-file-list="false"
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
:http-request="handleQrcodeUpload"
>
<div class="qrcode-upload">
<el-icon><Plus /></el-icon>
<span>{{ qrcodeUploading ? "上传中..." : "上传二维码" }}</span>
</div>
</el-upload>
<div class="form-tip block">
建议上传小程序码原图(≤2MB)。保存后官网 / H5 等其他端可调用
<code>GET /site/miniapp</code> 获取该图片地址(qrcode_url)。
</div>
</div>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" :active-value="1" :inactive-value="0" />
<span class="form-tip">启用后小程序端才可使用微信一键登录</span>
@@ -161,9 +214,20 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { listMiniApps, saveMiniApp, deleteMiniApp } from "@/api/miniApp";
import { formatDateTime } from "@/utils/datetime";
import { smartUpload } from "@/utils/qiniuUpload";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
// 相对地址(/uploads/...)拼接接口域名,绝对地址原样使用
const previewUrl = (url) => {
if (!url) return "";
if (/^https?:\/\//i.test(url)) return url;
return API_BASE_URL + url;
};
const loading = ref(false);
const saving = ref(false);
const qrcodeUploading = ref(false);
const apps = ref([]);
const dialogVisible = ref(false);
const formRef = ref(null);
@@ -174,6 +238,7 @@ const emptyForm = () => ({
name: "",
app_id: "",
app_secret: "",
qrcode_url: "",
enabled: 0,
auto_bind: 1,
remark: "",
@@ -217,6 +282,7 @@ const openEdit = (row) => {
name: row.name || "",
app_id: row.app_id || "",
app_secret: "",
qrcode_url: row.qrcode_url || "",
enabled: row.enabled,
auto_bind: row.auto_bind,
remark: row.remark || "",
@@ -225,6 +291,38 @@ const openEdit = (row) => {
dialogVisible.value = true;
};
// 二维码上传:上传成功后先写入表单,保存时随应用一起落库
const handleQrcodeUpload = async ({ file, onSuccess, onError }) => {
const raw = file?.raw ?? file;
if (!raw) return;
if (!/^image\//.test(raw.type || "")) {
ElMessage.error("请选择图片文件");
onError?.(new Error("请选择图片文件"));
return;
}
if (raw.size > 2 * 1024 * 1024) {
ElMessage.error("二维码图片不能超过 2MB");
onError?.(new Error("二维码图片不能超过 2MB"));
return;
}
qrcodeUploading.value = true;
try {
const result = await smartUpload(raw);
form.value.qrcode_url = result.url || "";
ElMessage.success("二维码上传成功");
onSuccess?.(result);
} catch (error) {
ElMessage.error(error?.message || "二维码上传失败");
onError?.(error);
} finally {
qrcodeUploading.value = false;
}
};
const removeQrcode = () => {
form.value.qrcode_url = "";
};
const handleSave = () => {
formRef.value?.validate(async (valid) => {
if (!valid) return;
@@ -262,6 +360,7 @@ const quickToggle = async (row, field, val) => {
name: row.name,
app_id: row.app_id,
app_secret: "",
qrcode_url: row.qrcode_url || "",
enabled: field === "enabled" ? (val ? 1 : 0) : row.enabled,
auto_bind: field === "auto_bind" ? (val ? 1 : 0) : row.auto_bind,
remark: row.remark || "",
@@ -327,6 +426,69 @@ onMounted(() => {
margin-left: 10px;
color: #8a94ad;
font-size: 12px;
&.block {
display: block;
margin-left: 0;
margin-top: 8px;
line-height: 1.6;
}
code {
padding: 0 4px;
background: #f2f4f8;
border-radius: 3px;
color: #5a6478;
}
}
.empty-tip {
color: #b3bac9;
font-size: 12px;
}
.qrcode-field {
width: 100%;
}
.qrcode-preview {
display: flex;
align-items: flex-end;
gap: 12px;
.qrcode-actions {
display: flex;
flex-direction: column;
gap: 8px;
:deep(.el-upload) {
width: 100%;
}
:deep(.el-button) {
width: 100%;
margin-left: 0;
}
}
}
.qrcode-upload {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: 120px;
height: 120px;
border: 1px dashed #c8cfdd;
border-radius: 6px;
color: #8a94ad;
font-size: 12px;
&:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
}
}
</style>
+140
View File
@@ -0,0 +1,140 @@
import { request } from '@/api/request.js'
import { getBaseURL } from '@/api/config.js'
import { getToken } from '@/utils/auth.js'
/**
* OA 工作日报接口封装,对应后端 /api/oa/dailyreport/*。
* 与后台 PC 端 /backend/oa/dailyreport/* 共用同一套数据与权限:
* 本人 / 部门主管 / 管理员三级可见,已提交日报支持评论与回复。
*/
/** 当前用户身份与可见范围 */
export function fetchDailyReportProfile() {
return request({ url: '/api/oa/dailyreport/profile' })
}
/**
* 我的日报列表
* @param {{page?:number,pageSize?:number,startDate?:string,endDate?:string}} params
* @returns {{list:Array,total:number,summary:Object}}
*/
export function fetchMyDailyReports(params = {}) {
return request({
url: '/api/oa/dailyreport/mine/list',
data: {
page: params.page || 1,
pageSize: params.pageSize || 20,
start_date: params.startDate || '',
end_date: params.endDate || ''
}
})
}
/** 日报详情:{ report, comments, can_edit, can_delete, can_comment } */
export function fetchDailyReportDetail(id) {
return request({ url: `/api/oa/dailyreport/detail/${id}` })
}
/**
* 保存日报
* @param {{date:string,todayDone:string,tomorrowPlan:string,issues:string,attachments:Array,submit:boolean}} payload
*/
export function saveDailyReport(payload) {
return request({
url: '/api/oa/dailyreport/save',
method: 'POST',
data: {
date: payload.date,
today_done: payload.todayDone || '',
tomorrow_plan: payload.tomorrowPlan || '',
issues: payload.issues || '',
attachments: JSON.stringify(payload.attachments || []),
submit: Boolean(payload.submit)
}
})
}
export function deleteDailyReport(id) {
return request({ url: `/api/oa/dailyreport/delete/${id}`, method: 'DELETE' })
}
/**
* 团队日报汇总
* @param {{date?:string,orgId?:number,keyword?:string,page?:number,pageSize?:number}} params
* @returns {{date:string,list:Array,total:number,stats:Object}}
*/
export function fetchTeamDailyReports(params = {}) {
return request({
url: '/api/oa/dailyreport/team/status',
data: {
date: params.date || '',
org_id: params.orgId || 0,
keyword: params.keyword || '',
page: params.page || 1,
pageSize: params.pageSize || 20
}
})
}
/** 团队筛选部门:{ list: [{id,parent_id,name,member_count}] } */
export function fetchTeamDailyReportOrgs() {
return request({ url: '/api/oa/dailyreport/team/orgs' })
}
/** 评论 / 回复:replyToId=0 表示直接评论 */
export function addDailyReportComment(reportId, content, replyToId = 0) {
return request({
url: '/api/oa/dailyreport/comment',
method: 'POST',
data: {
report_id: Number(reportId),
content,
reply_to_id: Number(replyToId) || 0
}
})
}
export function deleteDailyReportComment(id) {
return request({ url: `/api/oa/dailyreport/comment/${id}`, method: 'DELETE' })
}
/**
* 上传日报附件(multipart,字段名 file,单文件 ≤10MB)
* 返回 { id, url, name },与 Web 端上传结果结构一致。
*/
export function uploadDailyReportFile(filePath) {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${getBaseURL().replace(/\/$/, '')}/api/oa/dailyreport/upload`,
filePath,
name: 'file',
header: { Authorization: `Bearer ${getToken()}` },
success(res) {
let body = {}
try {
body = JSON.parse(res.data || '{}')
} catch (e) {
// 非 JSON 响应按失败处理
}
if (res.statusCode >= 200 && res.statusCode < 300 && body.code === 200 && body.data) {
resolve(body.data)
} else {
const msg = body.msg || `上传失败(${res.statusCode})`
uni.showToast({ title: msg, icon: 'none' })
reject(new Error(msg))
}
},
fail(err) {
uni.showToast({ title: '网络异常,上传失败', icon: 'none' })
reject(err)
}
})
})
}
/** 今日 "YYYY-MM-DD"(与后端本地日期口径一致) */
export function todayString() {
const now = new Date()
const pad = (n) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
+21
View File
@@ -257,6 +257,27 @@
"navigationBarBackgroundColor": "#F8FAFC"
}
},
{
"path": "pages/oa/dailyreport/index",
"style": {
"navigationBarTitleText": "工作日报",
"navigationBarBackgroundColor": "#F8FAFC"
}
},
{
"path": "pages/oa/dailyreport/edit",
"style": {
"navigationBarTitleText": "填写日报",
"navigationBarBackgroundColor": "#F8FAFC"
}
},
{
"path": "pages/oa/dailyreport/detail",
"style": {
"navigationBarTitleText": "日报详情",
"navigationBarBackgroundColor": "#F8FAFC"
}
},
{
"path": "pages/oa/notice/detail",
"style": {
+573
View File
@@ -0,0 +1,573 @@
<template>
<view class="page">
<scroll-view class="body" scroll-y :show-scrollbar="false">
<view v-if="loading && !detail" class="state-box">
<text class="state-text">加载中...</text>
</view>
<template v-else-if="detail">
<view class="head-card">
<view class="author-row">
<view class="avatar">{{ (report.user_name || '?').slice(0, 1) }}</view>
<view class="author-info">
<view class="author-line">
<text class="author-name">{{ report.user_name }}</text>
<u-tag
:text="report.status === 1 ? '已提交' : '草稿'"
:type="report.status === 1 ? 'success' : 'info'"
size="mini"
plain
/>
</view>
<text class="author-sub">
{{ report.org_name || '未分配部门' }} · {{ report.report_date }}
</text>
</view>
<view class="head-actions">
<text v-if="detail.can_edit" class="action-link" @tap="openEdit">编辑</text>
<text v-if="detail.can_delete" class="action-link is-danger" @tap="handleDelete">
删除
</text>
</view>
</view>
<text v-if="report.submit_time" class="submit-time">
提交于 {{ report.submit_time }}
</text>
</view>
<view class="section-card">
<text class="section-title">今日完成</text>
<text class="section-text">{{ report.today_done || '—' }}</text>
</view>
<view class="section-card">
<text class="section-title">明日计划</text>
<text class="section-text">{{ report.tomorrow_plan || '—' }}</text>
</view>
<view class="section-card">
<text class="section-title">问题与建议</text>
<text class="section-text">{{ report.issues || '—' }}</text>
</view>
<view v-if="report.attachments && report.attachments.length" class="section-card">
<text class="section-title">附件</text>
<view class="attachment-list">
<view
v-for="file in report.attachments"
:key="file.url"
class="attachment-item"
@tap="openAttachment(file)"
>
<text class="attachment-name">{{ file.name || '附件' }}</text>
<text class="attachment-size">{{ formatSize(file.size) }}</text>
</view>
</view>
</view>
<view class="section-card comment-card">
<view class="comment-head-row">
<text class="section-title">评论</text>
<text class="comment-count">{{ comments.length }}</text>
</view>
<view v-if="!comments.length" class="empty-comments">
<text class="empty-text">还没有评论</text>
</view>
<view v-else class="comment-list">
<view
v-for="item in comments"
:key="item.id"
class="comment-item"
:class="{ 'is-reply': item.reply_to_id > 0 }"
>
<view class="comment-top">
<text class="comment-user">{{ item.user_name }}</text>
<text v-if="item.reply_to_name" class="reply-to">
回复 {{ item.reply_to_name }}
</text>
<text class="comment-time">{{ item.create_time }}</text>
</view>
<text class="comment-content">{{ item.content }}</text>
<view class="comment-actions">
<text class="action-link" @tap="startReply(item)">回复</text>
<text
v-if="item.can_delete"
class="action-link is-danger"
@tap="handleDeleteComment(item)"
>
删除
</text>
</view>
</view>
</view>
<view v-if="!detail.can_comment" class="comment-disabled">
<text class="empty-text">
{{ report.status === 1 ? '当前账号无权评论' : '日报提交后才能评论' }}
</text>
</view>
</view>
<view class="bottom-space" />
</template>
</scroll-view>
<view v-if="detail && detail.can_comment" class="comment-editor">
<view v-if="replyTo" class="reply-banner">
<text class="reply-text">回复 {{ replyTo.user_name }}</text>
<text class="reply-cancel" @tap="replyTo = null">取消</text>
</view>
<view class="editor-row">
<input
v-model="commentText"
class="comment-input"
maxlength="1000"
:placeholder="replyTo ? `回复 ${replyTo.user_name}` : '写下你的评论或建议'"
confirm-type="send"
@confirm="submitComment"
/>
<view class="send-btn" :class="{ disabled: submitting }" @tap="submitComment">
发送
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import {
fetchDailyReportDetail,
addDailyReportComment,
deleteDailyReportComment,
deleteDailyReport
} from '@/api/oaDailyReport.js'
const reportId = ref(0)
const detail = ref(null)
const loading = ref(false)
const commentText = ref('')
const replyTo = ref(null)
const submitting = ref(false)
const report = computed(() => detail.value?.report || {})
const comments = computed(() => detail.value?.comments || [])
onLoad((options = {}) => {
reportId.value = Number(options.id) || 0
if (!reportId.value) {
uni.showToast({ title: '缺少日报ID', icon: 'none' })
}
})
onShow(() => {
if (!reportId.value) return
loadDetail()
})
async function loadDetail() {
loading.value = true
try {
const data = await fetchDailyReportDetail(reportId.value)
detail.value = data || null
} catch (e) {
uni.showToast({ title: e?.message || '日报加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function openEdit() {
uni.navigateTo({
url: `/pages/oa/dailyreport/edit?id=${reportId.value}&date=${report.value.report_date}`
})
}
function startReply(item) {
replyTo.value = { id: item.id, user_name: item.user_name }
}
async function submitComment() {
const content = commentText.value.trim()
if (!content) {
uni.showToast({ title: '请输入评论内容', icon: 'none' })
return
}
if (submitting.value) return
submitting.value = true
try {
await addDailyReportComment(reportId.value, content, replyTo.value?.id || 0)
commentText.value = ''
replyTo.value = null
uni.showToast({ title: '评论成功', icon: 'success' })
await loadDetail()
} catch (e) {
uni.showToast({ title: e?.message || '评论失败', icon: 'none' })
} finally {
submitting.value = false
}
}
function handleDeleteComment(item) {
uni.showModal({
title: '删除评论',
content: '确定删除这条评论吗?',
success: async (res) => {
if (!res.confirm) return
try {
await deleteDailyReportComment(item.id)
uni.showToast({ title: '已删除', icon: 'success' })
await loadDetail()
} catch (e) {
uni.showToast({ title: e?.message || '删除失败', icon: 'none' })
}
}
})
}
function handleDelete() {
uni.showModal({
title: '删除日报',
content: '删除后不可恢复,评论会一并删除。确定删除吗?',
success: async (res) => {
if (!res.confirm) return
try {
await deleteDailyReport(reportId.value)
uni.showToast({ title: '日报已删除', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} catch (e) {
uni.showToast({ title: e?.message || '删除失败', icon: 'none' })
}
}
})
}
function openAttachment(file) {
if (!file?.url) return
if (/\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(file.url)) {
const images = (report.value.attachments || [])
.filter((f) => /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(f.url))
.map((f) => f.url)
uni.previewImage({ urls: images, current: file.url })
return
}
uni.setClipboardData({
data: file.url,
success: () => uni.showToast({ title: '附件链接已复制', icon: 'none' })
})
}
function formatSize(size) {
const n = Number(size) || 0
if (!n) return ''
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="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
height: 100vh;
background: $color-bg-page;
display: flex;
flex-direction: column;
}
.body {
flex: 1;
min-height: 0;
padding: 24rpx $page-padding-x 0;
box-sizing: border-box;
}
.head-card {
@include card;
padding: 26rpx 24rpx;
}
.author-row {
display: flex;
align-items: center;
gap: 18rpx;
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: $radius-md;
background: linear-gradient(135deg, #60a5fa, #2563eb);
color: #ffffff;
font-size: 30rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.author-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.author-line {
display: flex;
align-items: center;
gap: 12rpx;
}
.author-name {
font-size: 30rpx;
font-weight: 600;
color: $color-text;
}
.author-sub {
font-size: 22rpx;
color: $color-text-muted;
}
.head-actions {
display: flex;
align-items: center;
gap: 20rpx;
flex-shrink: 0;
}
.action-link {
font-size: 24rpx;
color: $color-primary;
&.is-danger {
color: $color-danger;
}
}
.submit-time {
display: block;
margin-top: 16rpx;
font-size: 22rpx;
color: $color-text-muted;
}
.section-card {
@include card;
padding: 26rpx 24rpx;
margin-top: 20rpx;
}
.section-title {
font-size: 26rpx;
font-weight: 600;
color: $color-text-secondary;
}
.section-text {
display: block;
margin-top: 14rpx;
font-size: 28rpx;
line-height: 1.75;
color: $color-text;
white-space: pre-wrap;
word-break: break-all;
}
.attachment-list {
margin-top: 14rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.attachment-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
padding: 16rpx 20rpx;
border-radius: $radius-md;
background: $color-bg-page;
&:active {
background: $color-divider;
}
}
.attachment-name {
flex: 1;
min-width: 0;
font-size: 25rpx;
color: $color-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-size {
flex-shrink: 0;
font-size: 22rpx;
color: $color-text-muted;
}
.comment-card {
padding-bottom: 20rpx;
}
.comment-head-row {
display: flex;
align-items: center;
gap: 10rpx;
}
.comment-count {
font-size: 22rpx;
color: $color-text-muted;
}
.empty-comments {
padding: 30rpx 0;
display: flex;
justify-content: center;
}
.empty-text {
font-size: 24rpx;
color: $color-text-muted;
}
.comment-list {
margin-top: 18rpx;
display: flex;
flex-direction: column;
gap: 18rpx;
}
.comment-item {
padding: 18rpx 20rpx;
border-radius: $radius-md;
background: $color-bg-page;
&.is-reply {
margin-left: 32rpx;
background: $color-primary-bg;
}
}
.comment-top {
display: flex;
align-items: center;
gap: 10rpx;
flex-wrap: wrap;
}
.comment-user {
font-size: 24rpx;
font-weight: 600;
color: $color-text;
}
.reply-to {
font-size: 22rpx;
color: $color-text-muted;
}
.comment-time {
margin-left: auto;
font-size: 20rpx;
color: $color-text-muted;
}
.comment-content {
display: block;
margin-top: 10rpx;
font-size: 26rpx;
line-height: 1.65;
color: $color-text;
word-break: break-all;
}
.comment-actions {
display: flex;
align-items: center;
gap: 24rpx;
margin-top: 10rpx;
}
.comment-disabled {
padding-top: 20rpx;
}
.bottom-space {
height: 40rpx;
}
.comment-editor {
flex-shrink: 0;
background: $color-card;
padding: 16rpx $page-padding-x calc(16rpx + env(safe-area-inset-bottom));
box-shadow: 0 -2rpx 12rpx rgba(15, 23, 42, 0.04);
}
.reply-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 10rpx;
}
.reply-text {
font-size: 22rpx;
color: $color-text-muted;
}
.reply-cancel {
font-size: 22rpx;
color: $color-primary;
}
.editor-row {
display: flex;
align-items: center;
gap: 16rpx;
}
.comment-input {
flex: 1;
height: 72rpx;
padding: 0 24rpx;
border-radius: $radius-full;
background: $color-bg-page;
font-size: 26rpx;
color: $color-text;
}
.send-btn {
flex-shrink: 0;
padding: 16rpx 32rpx;
border-radius: $radius-full;
background: $color-primary;
color: #ffffff;
font-size: 26rpx;
&.disabled {
opacity: 0.6;
}
}
.state-box {
padding: 160rpx 0;
display: flex;
justify-content: center;
}
.state-text {
font-size: 26rpx;
color: $color-text-muted;
}
</style>
+458
View File
@@ -0,0 +1,458 @@
<template>
<view class="page">
<scroll-view class="body" scroll-y :show-scrollbar="false">
<view class="form-card">
<view class="date-row">
<text class="label">日报日期</text>
<picker
mode="date"
:value="reportDate"
:start="profile.window_start || ''"
:end="profile.window_end || today"
@change="onDateChange"
>
<view class="date-chip">{{ reportDate }}</view>
</picker>
<text class="date-tip">可补写最近 7 天</text>
</view>
<view class="field">
<text class="label">今日完成</text>
<textarea
v-model="form.todayDone"
class="textarea"
maxlength="5000"
placeholder="今天完成了哪些工作?可按条列出"
placeholder-class="placeholder"
/>
</view>
<view class="field">
<text class="label">明日计划</text>
<textarea
v-model="form.tomorrowPlan"
class="textarea"
maxlength="5000"
placeholder="明天计划推进哪些事项?"
placeholder-class="placeholder"
/>
</view>
<view class="field">
<text class="label">问题与建议</text>
<textarea
v-model="form.issues"
class="textarea is-short"
maxlength="5000"
placeholder="遇到的问题、需要的支持或改进建议(选填)"
placeholder-class="placeholder"
/>
</view>
<view class="field">
<text class="label">附件</text>
<view class="attachment-list">
<view
v-for="(file, index) in form.attachments"
:key="file.url || index"
class="attachment-item"
>
<text class="attachment-name">{{ file.name || '附件' }}</text>
<text class="remove" @tap="removeAttachment(index)">移除</text>
</view>
<view
v-if="form.attachments.length < 9"
class="upload-btn"
@tap="chooseImages"
>
{{ uploading ? '上传中...' : '+ 添加附件' }}
</view>
<text class="upload-tip">支持图片,单个不超过 10MB</text>
</view>
</view>
</view>
<view v-if="loading" class="state-box">
<text class="state-text">加载中...</text>
</view>
</scroll-view>
<view class="footer">
<button class="btn ghost" :disabled="saving" @tap="save(false)">保存草稿</button>
<button class="btn primary" :disabled="saving" @tap="save(true)">提交日报</button>
</view>
</view>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import {
fetchDailyReportProfile,
fetchDailyReportDetail,
fetchMyDailyReports,
saveDailyReport,
uploadDailyReportFile,
todayString
} from '@/api/oaDailyReport.js'
const today = todayString()
const loading = ref(false)
const saving = ref(false)
const uploading = ref(false)
const reportId = ref(0)
const reportDate = ref(today)
const profile = ref({ window_start: '', window_end: '' })
const form = reactive({
todayDone: '',
tomorrowPlan: '',
issues: '',
attachments: []
})
onLoad(async (options = {}) => {
reportDate.value = options.date || today
if (options.id) reportId.value = Number(options.id)
await loadProfile()
if (reportId.value) {
await loadDetail(reportId.value)
} else {
await loadByDate(reportDate.value, false)
}
})
async function loadProfile() {
try {
const data = await fetchDailyReportProfile()
if (data) profile.value = { ...profile.value, ...data }
} catch (e) {
// 窗口信息失败时仍允许按今日编辑
}
}
async function loadDetail(id) {
loading.value = true
try {
const data = await fetchDailyReportDetail(id)
applyDetail(data)
} catch (e) {
uni.showToast({ title: e?.message || '日报加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function applyDetail(data) {
const report = data?.report
if (!report) return
reportId.value = report.id
reportDate.value = report.report_date
form.todayDone = report.today_done || ''
form.tomorrowPlan = report.tomorrow_plan || ''
form.issues = report.issues || ''
form.attachments = Array.isArray(report.attachments)
? report.attachments.map((item) => ({ ...item }))
: []
}
/** 按日期查找已有日报;载入后可直接编辑 */
async function loadByDate(date, notify = true) {
const data = await fetchMyDailyReports({ page: 1, pageSize: 1, startDate: date, endDate: date })
const item = data?.list?.[0]
if (!item) {
reportId.value = 0
return false
}
if (notify) {
const confirmed = await confirmModal('该日期已有日报', '是否载入已有内容?')
if (!confirmed) return false
}
await loadDetail(item.id)
return true
}
async function onDateChange(e) {
const next = e.detail.value
if (!next || next === reportDate.value) return
const existing = await fetchMyDailyReports({
page: 1,
pageSize: 1,
startDate: next,
endDate: next
})
const item = existing?.list?.[0]
if (item) {
const confirmed = await confirmModal(
'该日期已有日报',
'是否载入该日期已有内容?当前未保存的内容将被覆盖。'
)
if (!confirmed) return
reportDate.value = next
await loadDetail(item.id)
return
}
if (hasContent()) {
const confirmed = await confirmModal(
'该日期暂无日报',
'切换日期会清空当前内容,是否继续?'
)
if (!confirmed) return
}
reportDate.value = next
reportId.value = 0
form.todayDone = ''
form.tomorrowPlan = ''
form.issues = ''
form.attachments = []
}
function hasContent() {
return Boolean(
form.todayDone.trim() ||
form.tomorrowPlan.trim() ||
form.issues.trim() ||
form.attachments.length
)
}
function removeAttachment(index) {
form.attachments.splice(index, 1)
}
async function chooseImages() {
const remain = Math.max(1, 9 - form.attachments.length)
uni.chooseImage({
count: remain,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
uploading.value = true
const paths = res.tempFilePaths || []
const files = res.tempFiles || []
for (let i = 0; i < paths.length; i += 1) {
try {
const data = await uploadDailyReportFile(paths[i])
form.attachments.push({
id: data.id || 0,
url: data.url,
name: data.name || '附件',
size: files[i]?.size || 0
})
} catch (e) {
// 上传失败已提示,继续处理其余文件
}
}
uploading.value = false
}
})
}
async function save(submit) {
if (!hasContent()) {
uni.showToast({ title: '请至少填写一项内容', icon: 'none' })
return
}
saving.value = true
try {
await saveDailyReport({
date: reportDate.value,
todayDone: form.todayDone,
tomorrowPlan: form.tomorrowPlan,
issues: form.issues,
attachments: form.attachments,
submit
})
uni.showToast({ title: submit ? '日报已提交' : '草稿已保存', icon: 'success' })
setTimeout(() => uni.navigateBack(), 500)
} catch (e) {
uni.showToast({ title: e?.message || '保存失败', icon: 'none' })
} finally {
saving.value = false
}
}
function confirmModal(title, content) {
return new Promise((resolve) => {
uni.showModal({
title,
content,
confirmText: '确定',
cancelText: '取消',
success: (res) => resolve(Boolean(res.confirm)),
fail: () => resolve(false)
})
})
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
height: 100vh;
background: $color-bg-page;
display: flex;
flex-direction: column;
}
.body {
flex: 1;
min-height: 0;
padding: 24rpx $page-padding-x 24rpx;
box-sizing: border-box;
}
.form-card {
@include card;
padding: 28rpx 24rpx;
}
.date-row {
display: flex;
align-items: center;
gap: 16rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid $color-divider;
}
.label {
font-size: 28rpx;
font-weight: 500;
color: $color-text;
}
.date-chip {
padding: 10rpx 20rpx;
border-radius: $radius-sm;
background: $color-primary-bg;
color: $color-primary;
font-size: 26rpx;
}
.date-tip {
margin-left: auto;
font-size: 22rpx;
color: $color-text-muted;
}
.field {
margin-top: 24rpx;
}
.textarea {
width: 100%;
box-sizing: border-box;
margin-top: 14rpx;
padding: 20rpx;
min-height: 220rpx;
border-radius: $radius-md;
background: $color-bg-page;
font-size: 27rpx;
line-height: 1.6;
color: $color-text;
&.is-short {
min-height: 150rpx;
}
}
.placeholder {
color: $color-text-muted;
font-size: 26rpx;
}
.attachment-list {
margin-top: 14rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.attachment-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 20rpx;
border-radius: $radius-md;
background: $color-bg-page;
}
.attachment-name {
flex: 1;
min-width: 0;
font-size: 25rpx;
color: $color-text-secondary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove {
margin-left: 16rpx;
font-size: 24rpx;
color: $color-danger;
}
.upload-btn {
align-self: flex-start;
padding: 14rpx 26rpx;
border-radius: $radius-full;
border: 1rpx dashed $color-primary-light;
color: $color-primary;
font-size: 25rpx;
background: $color-primary-bg;
}
.upload-tip {
font-size: 22rpx;
color: $color-text-muted;
}
.state-box {
padding: 60rpx 0;
display: flex;
justify-content: center;
}
.state-text {
font-size: 26rpx;
color: $color-text-muted;
}
.footer {
flex-shrink: 0;
display: flex;
gap: 20rpx;
padding: 20rpx $page-padding-x calc(20rpx + env(safe-area-inset-bottom));
background: $color-card;
box-shadow: 0 -2rpx 12rpx rgba(15, 23, 42, 0.04);
}
.btn {
flex: 1;
height: 84rpx;
line-height: 84rpx;
border-radius: $radius-full;
font-size: 28rpx;
font-weight: 500;
border: none;
&.ghost {
background: $color-bg-page;
color: $color-text-secondary;
}
&.primary {
background: $color-primary;
color: #ffffff;
}
&[disabled] {
opacity: 0.6;
}
}
</style>
+729
View File
@@ -0,0 +1,729 @@
<template>
<view class="page">
<view class="header">
<view class="header-top">
<view class="title-wrap">
<text class="title">工作日报</text>
<text class="subtitle">
补写窗口 {{ profile.window_start || '-' }} 至 {{ profile.window_end || '-' }}
</text>
</view>
<view class="write-btn" @tap="writeToday">写今日日报</view>
</view>
<view class="tabs">
<view
class="tab"
:class="{ active: activeTab === 'mine' }"
@tap="switchTab('mine')"
>
我的日报
</view>
<view
v-if="profile.can_view_team"
class="tab"
:class="{ active: activeTab === 'team' }"
@tap="switchTab('team')"
>
团队日报
</view>
</view>
</view>
<!-- ==================== 我的日报 ==================== -->
<view v-if="activeTab === 'mine'" class="content">
<view class="summary-row">
<view class="summary-item">
<text class="summary-num">{{ summary.month_total || 0 }}</text>
<text class="summary-label">本月日报</text>
</view>
<view class="summary-item">
<text class="summary-num is-green">{{ summary.month_submitted || 0 }}</text>
<text class="summary-label">本月已提交</text>
</view>
<view class="summary-item">
<text class="summary-num" :class="todayStatusClass">{{ todayStatusText }}</text>
<text class="summary-label">今日状态</text>
</view>
</view>
<view class="backfill-bar">
<text class="backfill-label">补写往日</text>
<picker
mode="date"
:value="backfillDate"
:start="profile.window_start || ''"
:end="profile.window_end || today"
@change="onBackfillDateChange"
>
<view class="date-chip">{{ backfillDate }}</view>
</picker>
<view class="backfill-btn" @tap="openBackfill">填写该日</view>
</view>
<view v-if="loading && !mineList.length" class="state-box">
<text class="state-text">加载中...</text>
</view>
<u-empty
v-else-if="!mineList.length"
mode="list"
:text="'还没有日报,点击右上角开始记录'"
:icon-size="80"
/>
<view v-else class="card-list">
<view
v-for="item in mineList"
:key="item.id"
class="report-card"
@tap="openDetail(item.id)"
>
<view class="card-head">
<view class="date-wrap">
<text class="date-text">{{ item.report_date }}</text>
<text v-if="item.report_date === today" class="today-chip">今天</text>
</view>
<u-tag
:text="item.status === 1 ? '已提交' : '草稿'"
:type="item.status === 1 ? 'success' : 'info'"
size="mini"
plain
/>
</view>
<text class="preview">{{ previewText(item) }}</text>
<view class="card-foot">
<text class="meta">评论 {{ item.comment_count || 0 }}</text>
<text class="meta">{{ item.submit_time || '未提交' }}</text>
</view>
</view>
<view class="load-more">
<text v-if="hasMore" class="load-link" @tap="loadMore">加载更多</text>
<text v-else class="load-done">没有更多了</text>
</view>
</view>
</view>
<!-- ==================== 团队日报 ==================== -->
<view v-else class="content">
<view class="team-filter">
<picker mode="date" :value="teamDate" :end="today" @change="onTeamDateChange">
<view class="date-chip">{{ teamDate }}</view>
</picker>
<picker
:range="orgNames"
:value="orgIndex"
@change="onOrgChange"
>
<view class="date-chip">{{ orgNames[orgIndex] || '全部部门' }}</view>
</picker>
</view>
<view class="team-stats">
<view class="team-stat">
<text class="summary-num">{{ teamStats.total || 0 }}</text>
<text class="summary-label">应提交</text>
</view>
<view class="team-stat">
<text class="summary-num is-green">{{ teamStats.submitted || 0 }}</text>
<text class="summary-label">已提交</text>
</view>
<view class="team-stat">
<text class="summary-num is-red">{{ teamStats.missing || 0 }}</text>
<text class="summary-label">未提交</text>
</view>
<view class="team-stat">
<text class="summary-num is-orange">{{ teamStats.rate || 0 }}%</text>
<text class="summary-label">提交率</text>
</view>
</view>
<view v-if="teamLoading && !teamList.length" class="state-box">
<text class="state-text">加载中...</text>
</view>
<u-empty
v-else-if="!teamList.length"
mode="list"
text="暂无团队成员"
:icon-size="80"
/>
<view v-else class="card-list">
<view
v-for="item in teamList"
:key="item.user_id"
class="member-card"
:class="{ clickable: item.submitted }"
@tap="openTeamReport(item)"
>
<view class="member-left">
<view class="member-avatar">{{ (item.user_name || '?').slice(0, 1) }}</view>
<view class="member-info">
<text class="member-name">{{ item.user_name }}</text>
<text class="member-org">{{ item.org_name || '未分配部门' }}</text>
</view>
</view>
<view class="member-right">
<u-tag
v-if="item.submitted"
text="已提交"
type="success"
size="mini"
plain
/>
<u-tag
v-else-if="item.status === 0"
text="草稿"
type="info"
size="mini"
plain
/>
<u-tag v-else text="未提交" type="error" size="mini" plain />
<text v-if="item.submit_time" class="member-time">{{ item.submit_time }}</text>
</view>
</view>
<view class="load-more">
<text v-if="teamHasMore" class="load-link" @tap="loadMoreTeam">加载更多</text>
<text v-else class="load-done">没有更多了</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import {
fetchDailyReportProfile,
fetchMyDailyReports,
fetchTeamDailyReports,
fetchTeamDailyReportOrgs,
todayString
} from '@/api/oaDailyReport.js'
import { isLoggedIn } from '@/utils/auth.js'
const today = todayString()
const activeTab = ref('mine')
const profile = ref({ is_admin: false, can_view_team: false, window_start: '', window_end: '' })
const summary = ref({})
const loading = ref(false)
const mineList = ref([])
const minePage = ref(1)
const hasMore = ref(false)
const backfillDate = ref(today)
const teamDate = ref(today)
const teamList = ref([])
const teamStats = ref({ total: 0, submitted: 0, missing: 0, rate: 0 })
const teamLoading = ref(false)
const teamPage = ref(1)
const teamHasMore = ref(false)
const orgOptions = ref([])
const orgIndex = ref(0)
const orgNames = computed(() => ['全部部门', ...orgOptions.value.map((o) => o.name)])
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'
})
onShow(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
reload()
})
onReachBottom(() => {
if (activeTab.value === 'mine') {
if (hasMore.value && !loading.value) loadMine(false)
} else if (teamHasMore.value && !teamLoading.value) {
loadTeam(false)
}
})
async function reload() {
await loadProfile()
if (activeTab.value === 'mine') {
await loadMine(true)
} else {
if (!orgOptions.value.length) await loadTeamOrgs()
await loadTeam(true)
}
}
async function loadProfile() {
try {
const data = await fetchDailyReportProfile()
if (data) profile.value = { ...profile.value, ...data }
if (profile.value.window_end) backfillDate.value = profile.value.window_end
if (!profile.value.can_view_team && activeTab.value === 'team') activeTab.value = 'mine'
} catch (e) {
uni.showToast({ title: e?.message || '日报身份加载失败', icon: 'none' })
}
}
async function loadMine(reset = false) {
if (reset) {
minePage.value = 1
loading.value = true
}
try {
const data = await fetchMyDailyReports({ page: minePage.value, pageSize: 20 })
const rows = data?.list || []
mineList.value = reset ? rows : mineList.value.concat(rows)
summary.value = data?.summary || {}
hasMore.value = mineList.value.length < (data?.total || 0)
} catch (e) {
uni.showToast({ title: e?.message || '日报加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function loadMore() {
if (!hasMore.value || loading.value) return
minePage.value += 1
loadMine(false)
}
function switchTab(name) {
if (activeTab.value === name) return
activeTab.value = name
if (name === 'mine') {
loadMine(true)
} else {
if (!orgOptions.value.length) loadTeamOrgs()
loadTeam(true)
}
}
function onBackfillDateChange(e) {
backfillDate.value = e.detail.value
}
function openBackfill() {
uni.navigateTo({ url: `/pages/oa/dailyreport/edit?date=${backfillDate.value}` })
}
function writeToday() {
const id = summary.value.today_report_id
if (id) {
uni.navigateTo({ url: `/pages/oa/dailyreport/edit?id=${id}&date=${today}` })
} else {
uni.navigateTo({ url: `/pages/oa/dailyreport/edit?date=${today}` })
}
}
function openDetail(id) {
uni.navigateTo({ url: `/pages/oa/dailyreport/detail?id=${id}` })
}
function previewText(item) {
const text = item.today_done || item.tomorrow_plan || item.issues || ''
return text || '暂无内容'
}
// ---------------- 团队 ----------------
async function loadTeamOrgs() {
try {
const data = await fetchTeamDailyReportOrgs()
orgOptions.value = data?.list || []
} catch (e) {
// 部门列表失败不阻断团队汇总主流程
}
}
async function loadTeam(reset = false) {
if (reset) {
teamPage.value = 1
teamLoading.value = true
}
try {
const orgId = orgIndex.value > 0 ? orgOptions.value[orgIndex.value - 1]?.id || 0 : 0
const data = await fetchTeamDailyReports({
date: teamDate.value,
orgId,
page: teamPage.value,
pageSize: 20
})
const rows = data?.list || []
teamList.value = reset ? rows : teamList.value.concat(rows)
teamStats.value = data?.stats || { total: 0, submitted: 0, missing: 0, rate: 0 }
teamHasMore.value = teamList.value.length < (data?.total || 0)
} catch (e) {
uni.showToast({ title: e?.message || '团队日报加载失败', icon: 'none' })
} finally {
teamLoading.value = false
}
}
function loadMoreTeam() {
if (!teamHasMore.value || teamLoading.value) return
teamPage.value += 1
loadTeam(false)
}
function onTeamDateChange(e) {
teamDate.value = e.detail.value
loadTeam(true)
}
function onOrgChange(e) {
orgIndex.value = Number(e.detail.value) || 0
loadTeam(true)
}
function openTeamReport(item) {
if (!item.submitted) {
uni.showToast({ title: '该成员当日未提交日报', icon: 'none' })
return
}
openDetail(item.report_id)
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
min-height: 100vh;
background: $color-bg-page;
padding-bottom: 40rpx;
}
.header {
background: $color-card;
padding: 28rpx $page-padding-x 0;
box-shadow: $shadow-header;
}
.header-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.title-wrap {
flex: 1;
min-width: 0;
}
.title {
display: block;
font-size: 38rpx;
font-weight: 700;
color: $color-text;
}
.subtitle {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: $color-text-muted;
}
.write-btn {
flex-shrink: 0;
padding: 14rpx 28rpx;
border-radius: $radius-full;
background: $color-primary;
color: #ffffff;
font-size: 26rpx;
font-weight: 500;
&:active {
background: $color-primary-dark;
}
}
.tabs {
display: flex;
gap: 48rpx;
margin-top: 24rpx;
}
.tab {
position: relative;
padding: 16rpx 0;
font-size: 28rpx;
color: $color-text-secondary;
&.active {
color: $color-primary;
font-weight: 600;
&::after {
content: '';
position: absolute;
left: 50%;
bottom: 0;
width: 40rpx;
height: 6rpx;
border-radius: 3rpx;
background: $color-primary;
transform: translateX(-50%);
}
}
}
.content {
padding: 24rpx $page-padding-x 0;
}
.summary-row {
display: flex;
@include card;
padding: 24rpx 0;
}
.summary-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
}
.summary-num {
font-size: 34rpx;
font-weight: 700;
color: $color-text;
}
.summary-label {
font-size: 22rpx;
color: $color-text-muted;
}
.is-green {
color: $color-success;
}
.is-red {
color: $color-danger;
}
.is-orange {
color: $color-warning;
}
.backfill-bar {
display: flex;
align-items: center;
gap: 16rpx;
margin-top: 20rpx;
padding: 18rpx 24rpx;
@include card;
}
.backfill-label {
font-size: 24rpx;
color: $color-text-secondary;
}
.date-chip {
padding: 10rpx 20rpx;
border-radius: $radius-sm;
background: $color-primary-bg;
color: $color-primary;
font-size: 24rpx;
}
.backfill-btn {
margin-left: auto;
font-size: 24rpx;
color: $color-primary;
font-weight: 500;
}
.card-list {
margin-top: 20rpx;
}
.report-card {
@include card;
padding: 24rpx;
margin-bottom: 20rpx;
&:active {
background: $color-bg-page;
}
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.date-wrap {
display: flex;
align-items: center;
gap: 12rpx;
}
.date-text {
font-size: 30rpx;
font-weight: 600;
color: $color-text;
}
.today-chip {
@include chip;
font-size: 20rpx;
}
.preview {
display: block;
margin-top: 14rpx;
font-size: 26rpx;
line-height: 1.6;
color: $color-text-secondary;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
word-break: break-all;
}
.card-foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16rpx;
}
.meta {
font-size: 22rpx;
color: $color-text-muted;
}
.team-filter {
display: flex;
align-items: center;
gap: 16rpx;
}
.team-stats {
display: flex;
margin-top: 20rpx;
padding: 24rpx 0;
@include card;
}
.team-stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
}
.member-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
@include card;
padding: 22rpx 24rpx;
margin-bottom: 18rpx;
&.clickable:active {
background: $color-bg-page;
}
}
.member-left {
display: flex;
align-items: center;
gap: 18rpx;
flex: 1;
min-width: 0;
}
.member-avatar {
width: 72rpx;
height: 72rpx;
border-radius: $radius-md;
background: linear-gradient(135deg, #60a5fa, #2563eb);
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.member-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.member-name {
font-size: 28rpx;
font-weight: 500;
color: $color-text;
}
.member-org {
margin-top: 4rpx;
font-size: 22rpx;
color: $color-text-muted;
}
.member-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6rpx;
flex-shrink: 0;
}
.member-time {
font-size: 20rpx;
color: $color-text-muted;
}
.state-box {
display: flex;
align-items: center;
justify-content: center;
padding: 140rpx 0;
}
.state-text {
font-size: 26rpx;
color: $color-text-muted;
}
.load-more {
display: flex;
align-items: center;
justify-content: center;
padding: 20rpx 0 8rpx;
}
.load-link {
font-size: 24rpx;
color: $color-primary;
}
.load-done {
font-size: 22rpx;
color: $color-text-muted;
}
</style>
+1
View File
@@ -45,6 +45,7 @@ const NATIVE_RULES = [
// 报销管理(注意放在日程规则之后即可,与日程关键词无冲突)
{ pattern: /报销|reimburse|expense/i, route: '/pages/tools/expense/index' },
// OA 协同办公
{ pattern: /工作日报|日报|dailyreport/i, route: '/pages/oa/dailyreport/index' },
{ pattern: /通知公告|公告|notice/i, route: '/pages/oa/notice/index' },
{ pattern: /文档库|文档管理|文档图谱|document/i, route: '/pages/oa/document/index' },
{ pattern: /薪酬|工资|薪资|compensation|payroll/i, route: '/pages/oa/compensation/index' },