Files
yunzerwebsiteallinone/uniapp/pages/oa/dailyreport/index.vue
T
2026-10-21 17:45:50 +08:00

730 lines
15 KiB
Vue

<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>