连级修改

This commit is contained in:
18631081161 2026-01-16 22:02:51 +08:00
parent 1f793aa004
commit f639840803
20 changed files with 690 additions and 117 deletions

View File

@ -318,7 +318,8 @@ public class AllocationsController : BaseApiController
distributionId,
unitId.Value,
userId.Value,
request.ActualCompletion);
request.ActualCompletion,
request.Remarks);
return Ok(MapDistributionToResponse(distribution));
}
@ -433,6 +434,29 @@ public class AllocationsController : BaseApiController
return Ok(response);
}
/// <summary>
/// 获取按单位汇总的上报数据
/// </summary>
[HttpGet("distributions/{distributionId}/summary")]
public async Task<IActionResult> GetReportSummaryByUnit(int distributionId)
{
var unitId = GetCurrentUnitId();
var unitLevel = GetCurrentUnitLevel();
if (unitId == null || unitLevel == null)
return Unauthorized(new { message = "无法获取用户组织信息" });
// 检查分配记录是否存在
var distribution = await _allocationService.GetDistributionByIdAsync(distributionId);
if (distribution == null)
return NotFound(new { message = "配额分配记录不存在" });
// 获取按单位汇总的上报数据
var summaries = await _allocationService.GetReportSummaryByUnitAsync(distributionId, unitId.Value, unitLevel.Value);
return Ok(summaries);
}
/// <summary>
/// 映射实体到响应DTO
/// </summary>

View File

@ -33,7 +33,7 @@ public class ReportsController : BaseApiController
/// 需求3.1:显示类别、物资名称、单位、配额、实际完成和完成率
/// </summary>
[HttpGet]
public async Task<IActionResult> GetByUnit()
public async Task<IActionResult> GetByUnit([FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 10)
{
var unitId = GetCurrentUnitId();
if (unitId == null)
@ -41,9 +41,23 @@ public class ReportsController : BaseApiController
return Unauthorized(new { message = "无法获取用户组织信息" });
}
var distributions = await _reportingService.GetDistributionsByUnitAsync(unitId.Value);
var response = distributions.Select(MapToReportResponse);
return Ok(response);
var allDistributions = await _reportingService.GetDistributionsByUnitAsync(unitId.Value);
var distributionsList = allDistributions.ToList();
var totalCount = distributionsList.Count;
var pagedDistributions = distributionsList
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.Select(MapToReportResponse);
return Ok(new
{
items = pagedDistributions,
totalCount,
pageNumber,
pageSize,
totalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
});
}
/// <summary>

View File

@ -103,6 +103,12 @@ public class UpdateDistributionRequest
[Required(ErrorMessage = "实际完成数量为必填项")]
[Range(0, double.MaxValue, ErrorMessage = "实际完成数量不能为负数")]
public decimal ActualCompletion { get; set; }
/// <summary>
/// 备注
/// </summary>
[MaxLength(500)]
public string? Remarks { get; set; }
}
/// <summary>

View File

@ -69,6 +69,11 @@ public class UnitReportSummary
public decimal CompletionRate { get; set; }
public DateTime? ReportedAt { get; set; }
public string? ReportedByUserName { get; set; }
// 新增字段:用于按单位汇总上报数据
public decimal TotalReported { get; set; }
public int ReportCount { get; set; }
public DateTime? LastReportedAt { get; set; }
}
/// <summary>

View File

@ -97,6 +97,10 @@ builder.Services.AddControllers()
options.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles;
// 将枚举序列化为字符串
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
// 使用 camelCase 命名策略(输出)
options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
// 允许不区分大小写的属性名匹配(输入)
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
});
builder.Services.AddEndpointsApiExplorer();

View File

@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using MilitaryTrainingManagement.Data;
using MilitaryTrainingManagement.Models.Entities;
using MilitaryTrainingManagement.Models.DTOs;
using MilitaryTrainingManagement.Services.Interfaces;
namespace MilitaryTrainingManagement.Services.Implementations;
@ -270,7 +271,8 @@ public class AllocationService : IAllocationService
int distributionId,
int unitId,
int userId,
decimal actualCompletion)
decimal actualCompletion,
string? remarks = null)
{
var distribution = await _context.AllocationDistributions
.Include(d => d.Allocation)
@ -312,6 +314,7 @@ public class AllocationService : IAllocationService
AllocationDistributionId = distributionId,
ReportedAmount = reportedAmount,
CumulativeAmount = actualCompletion,
Remarks = remarks,
ReportedByUserId = userId,
ReportedAt = DateTime.UtcNow
};
@ -448,4 +451,38 @@ public class AllocationService : IAllocationService
.Where(r => visibleUnitIds.Contains(r.ReportedByUser.OrganizationalUnitId))
.Sum(r => r.ReportedAmount);
}
/// <summary>
/// 获取按单位汇总的上报数据(带可见性过滤)
/// </summary>
public async Task<IEnumerable<UnitReportSummary>> GetReportSummaryByUnitAsync(int distributionId, int userUnitId, Models.Enums.OrganizationalLevel userLevel)
{
// 获取所有上报记录(不做可见性过滤,因为师团级需要看到所有下级的汇总)
var allReports = await _context.ConsumptionReports
.Include(r => r.ReportedByUser)
.ThenInclude(u => u.OrganizationalUnit)
.Where(r => r.AllocationDistributionId == distributionId)
.ToListAsync();
// 按单位分组汇总
var summaries = allReports
.GroupBy(r => new {
UnitId = r.ReportedByUser.OrganizationalUnitId,
UnitName = r.ReportedByUser.OrganizationalUnit.Name,
UnitLevel = r.ReportedByUser.OrganizationalUnit.Level.ToString()
})
.Select(g => new UnitReportSummary
{
UnitId = g.Key.UnitId,
UnitName = g.Key.UnitName,
UnitLevel = g.Key.UnitLevel,
TotalReported = g.Sum(r => r.ReportedAmount),
ReportCount = g.Count(),
LastReportedAt = g.Max(r => r.ReportedAt)
})
.OrderByDescending(s => s.TotalReported)
.ToList();
return summaries;
}
}

View File

@ -56,7 +56,7 @@ public interface IAllocationService
/// <summary>
/// 更新单个分配记录的实际完成数量(上报消耗)
/// </summary>
Task<AllocationDistribution> UpdateDistributionCompletionAsync(int distributionId, int unitId, int userId, decimal actualCompletion);
Task<AllocationDistribution> UpdateDistributionCompletionAsync(int distributionId, int unitId, int userId, decimal actualCompletion, string? remarks = null);
/// <summary>
/// 删除物资配额
@ -95,4 +95,9 @@ public interface IAllocationService
/// 营部及以下级别:只计算本单位及直接下级的上报
/// </summary>
Task<decimal> GetVisibleActualCompletionAsync(int allocationId, int userUnitId, OrganizationalLevel userLevel);
/// <summary>
/// 获取按单位汇总的上报数据(带可见性过滤)
/// </summary>
Task<IEnumerable<Models.DTOs.UnitReportSummary>> GetReportSummaryByUnitAsync(int distributionId, int userUnitId, OrganizationalLevel userLevel);
}

View File

@ -51,6 +51,11 @@ export const allocationsApi = {
async getConsumptionReports(distributionId: number): Promise<ConsumptionReport[]> {
const response = await apiClient.get<ConsumptionReport[]>(`/allocations/distributions/${distributionId}/reports`)
return response.data
},
async getReportSummaryByUnit(distributionId: number): Promise<UnitReportSummary[]> {
const response = await apiClient.get<UnitReportSummary[]>(`/allocations/distributions/${distributionId}/summary`)
return response.data
}
}
@ -62,3 +67,12 @@ export interface ConsumptionReport {
reportedByUserName?: string
reportedAt: string
}
export interface UnitReportSummary {
unitId: number
unitName: string
unitLevel: string
totalReported: number
reportCount: number
lastReportedAt?: string
}

View File

@ -1,6 +1,6 @@
export { authApi } from './auth'
export { organizationsApi } from './organizations'
export { allocationsApi } from './allocations'
export { allocationsApi, type UnitReportSummary } from './allocations'
export { materialCategoriesApi } from './materialCategories'
export { reportsApi } from './reports'
export { personnelApi } from './personnel'

View File

@ -30,8 +30,9 @@
<el-menu-item index="/allocations">配额列表</el-menu-item>
<el-menu-item v-if="authStore.canCreateAllocations" index="/allocations/create">创建配额</el-menu-item>
</el-sub-menu>
<el-sub-menu index="reports">
<!-- 上报管理只对团级账号可见师团创建配额营部及以下用配额列表上报 -->
<el-sub-menu v-if="authStore.organizationalLevelNum === 2" index="reports">
<template #title>
<el-icon><DataAnalysis /></el-icon>
<span>上报管理</span>
@ -39,7 +40,13 @@
<el-menu-item index="/reports">上报列表</el-menu-item>
<el-menu-item index="/reports/summary">数据汇总</el-menu-item>
</el-sub-menu>
<!-- 数据汇总师团级单独显示查看下级汇总数据 -->
<el-menu-item v-if="authStore.organizationalLevelNum === 1" index="/reports/summary">
<el-icon><DataAnalysis /></el-icon>
<span>数据汇总</span>
</el-menu-item>
<el-sub-menu index="personnel">
<template #title>
<el-icon><User /></el-icon>

View File

@ -120,6 +120,7 @@ export interface DistributionRequest {
export interface UpdateDistributionRequest {
actualCompletion: number
remarks?: string
}
// Report types

View File

@ -169,7 +169,7 @@
<div class="summary-item">
<div class="summary-label">物资类别</div>
<div class="summary-value">
<el-tag :type="getCategoryTagType(selectedAllocation?.category)" size="large" effect="plain">
<el-tag :type="getCategoryTagType(selectedAllocation?.category || '')" size="large" effect="plain">
{{ selectedAllocation?.category }}
</el-tag>
</div>
@ -325,29 +325,35 @@
</el-table-column>
</el-table>
</el-dialog>
<!-- Consumption Reports Dialog -->
<el-dialog
v-model="showReportsDialog"
:title="`上报记录 - ${selectedDistribution?.targetUnitName || ''}`"
width="700px"
<el-dialog
v-model="showReportsDialog"
:title="`上报记录 - ${selectedDistribution?.targetUnitName || ''}`"
width="900px"
:close-on-click-modal="false"
>
<div class="reports-summary">
<el-row :gutter="16">
<el-col :span="8">
<el-col :span="6">
<div class="report-stat">
<div class="stat-label">分配配额</div>
<div class="stat-value highlight">{{ formatNumber(selectedDistribution?.unitQuota || 0) }}</div>
</div>
</el-col>
<el-col :span="8">
<el-col :span="6">
<div class="report-stat">
<div class="stat-label">累计消耗</div>
<div class="stat-value consumed">{{ formatNumber(selectedDistribution?.actualCompletion || 0) }}</div>
</div>
</el-col>
<el-col :span="8">
<el-col :span="6">
<div class="report-stat">
<div class="stat-label">上报单位数</div>
<div class="stat-value">{{ unitSummaries.length }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="report-stat">
<div class="stat-label">上报次数</div>
<div class="stat-value">{{ consumptionReports.length }}</div>
@ -355,40 +361,87 @@
</el-col>
</el-row>
</div>
<el-table
:data="consumptionReports"
style="width: 100%"
v-loading="loadingReports"
stripe
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: 'bold' }"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="reportedAmount" label="本次上报" width="120" align="center">
<template #default="{ row }">
<span class="report-amount">+{{ formatNumber(row.reportedAmount) }}</span>
<el-tabs v-model="reportsTabActive" class="reports-tabs">
<el-tab-pane label="按单位汇总" name="summary">
<el-table
:data="unitSummaries"
style="width: 100%"
v-loading="loadingReports"
stripe
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: 'bold' }"
>
<el-table-column prop="unitName" label="单位名称" min-width="150">
<template #default="{ row }">
<div class="unit-cell">
<el-icon class="unit-icon"><OfficeBuilding /></el-icon>
<span class="unit-name">{{ row.unitName }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="unitLevel" label="单位级别" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getUnitLevelTagType(row.unitLevel)" size="small">
{{ getUnitLevelText(row.unitLevel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="totalReported" label="上报总量" width="120" align="right">
<template #default="{ row }">
<span class="total-reported">{{ formatNumber(row.totalReported) }}</span>
</template>
</el-table-column>
<el-table-column prop="reportCount" label="上报次数" width="100" align="center">
<template #default="{ row }">
<el-tag type="info" size="small">{{ row.reportCount }} </el-tag>
</template>
</el-table-column>
<el-table-column prop="lastReportedAt" label="最后上报时间" width="170" align="center">
<template #default="{ row }">
<span class="time-cell">{{ row.lastReportedAt ? formatDate(row.lastReportedAt) : '-' }}</span>
</template>
</el-table-column>
</el-table>
<template v-if="unitSummaries.length === 0 && !loadingReports">
<el-empty description="暂无上报数据" />
</template>
</el-table-column>
<el-table-column prop="cumulativeAmount" label="累计数量" width="120" align="center">
<template #default="{ row }">
<span class="cumulative-amount">{{ formatNumber(row.cumulativeAmount) }}</span>
</el-tab-pane>
<el-tab-pane label="上报明细" name="detail">
<el-table
:data="consumptionReports"
style="width: 100%"
v-loading="loadingReports"
stripe
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: 'bold' }"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="reportedAmount" label="本次上报" width="120" align="center">
<template #default="{ row }">
<span class="report-amount">+{{ formatNumber(row.reportedAmount) }}</span>
</template>
</el-table-column>
<el-table-column prop="cumulativeAmount" label="累计数量" width="120" align="center">
<template #default="{ row }">
<span class="cumulative-amount">{{ formatNumber(row.cumulativeAmount) }}</span>
</template>
</el-table-column>
<el-table-column prop="reportedByUserName" label="上报人" min-width="120">
<template #default="{ row }">
<span>{{ row.reportedByUserName || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="reportedAt" label="上报时间" width="160" align="center">
<template #default="{ row }">
<span class="time-cell">{{ formatDate(row.reportedAt) }}</span>
</template>
</el-table-column>
</el-table>
<template v-if="consumptionReports.length === 0 && !loadingReports">
<el-empty description="暂无上报记录" />
</template>
</el-table-column>
<el-table-column prop="reportedByUserName" label="上报人" min-width="120">
<template #default="{ row }">
<span>{{ row.reportedByUserName || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="reportedAt" label="上报时间" width="160" align="center">
<template #default="{ row }">
<span class="time-cell">{{ formatDate(row.reportedAt) }}</span>
</template>
</el-table-column>
</el-table>
<template v-if="consumptionReports.length === 0 && !loadingReports">
<el-empty description="暂无上报记录" />
</template>
</el-tab-pane>
</el-tabs>
</el-dialog>
</div>
</template>
@ -399,7 +452,7 @@ import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, View, Edit, Delete, Box, Setting, OfficeBuilding, Clock } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { allocationsApi } from '@/api'
import { allocationsApi, type UnitReportSummary } from '@/api'
import type { MaterialAllocation, AllocationDistribution } from '@/types'
const router = useRouter()
@ -408,6 +461,8 @@ const authStore = useAuthStore()
const allocations = ref<MaterialAllocation[]>([])
const distributions = ref<AllocationDistribution[]>([])
const consumptionReports = ref<any[]>([])
const unitSummaries = ref<UnitReportSummary[]>([])
const reportsTabActive = ref('summary')
const loading = ref(false)
const loadingReports = ref(false)
const showDistributionDialog = ref(false)
@ -458,7 +513,7 @@ function getCategoryTagType(category: string): string {
case '装备': return 'warning'
case '物资': return 'success'
case '器材': return 'info'
default: return ''
default: return 'info'
}
}
@ -468,6 +523,26 @@ function getProgressStatus(rate: number): string {
return 'warning'
}
function getUnitLevelTagType(level: string): string {
switch (level) {
case 'Division': return 'danger'
case 'Regiment': return 'warning'
case 'Battalion': return 'success'
case 'Company': return 'info'
default: return 'info'
}
}
function getUnitLevelText(level: string): string {
switch (level) {
case 'Division': return '师团'
case 'Regiment': return '团'
case 'Battalion': return '营'
case 'Company': return '连'
default: return level
}
}
function getDistributionPercentage(allocation: MaterialAllocation): number {
if (!allocation.distributions || allocation.distributions.length === 0) return 0
const distributed = allocation.distributions.reduce((sum, d) => sum + (d.unitQuota || 0), 0)
@ -556,13 +631,21 @@ function handleReportFromSummary() {
async function handleViewReports(distribution: AllocationDistribution) {
selectedDistribution.value = distribution
showReportsDialog.value = true
reportsTabActive.value = 'summary'
loadingReports.value = true
try {
const reports = await allocationsApi.getConsumptionReports(distribution.id)
//
const [reports, summaries] = await Promise.all([
allocationsApi.getConsumptionReports(distribution.id),
allocationsApi.getReportSummaryByUnit(distribution.id)
])
consumptionReports.value = reports
} catch {
unitSummaries.value = summaries
} catch (error) {
console.error('加载上报记录失败', error)
ElMessage.error('加载上报记录失败')
consumptionReports.value = []
unitSummaries.value = []
} finally {
loadingReports.value = false
}

View File

@ -75,6 +75,20 @@
label-width="120px"
@submit.prevent="handleSubmit"
>
<!-- 连部显示上报目标选择 -->
<el-form-item v-if="isCompanyLevel" label="上报到" prop="reportToLevel">
<el-radio-group v-model="form.reportToLevel">
<el-radio value="Battalion">
<el-tag type="success" size="small">营部</el-tag>
<span class="radio-desc">上报到所属营部</span>
</el-radio>
<el-radio value="Regiment">
<el-tag type="warning" size="small">团部</el-tag>
<span class="radio-desc">直接上报到团部</span>
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="isBattalionOrBelow ? '本次消耗数量' : '本次上报数量'" prop="actualCompletion">
<el-input-number
v-model="form.actualCompletion"
@ -141,7 +155,7 @@
<h3 class="section-title">上报历史</h3>
<el-table
v-if="consumptionReports.length > 0"
:data="consumptionReports"
:data="reportsWithRecalculatedCumulative"
stripe
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: 'bold' }"
>
@ -160,7 +174,7 @@
</el-table-column>
<el-table-column label="累计数量" width="120" align="center">
<template #default="{ row }">
<span class="cumulative-amount">{{ formatNumber(row.cumulativeAmount) }}</span>
<span class="cumulative-amount">{{ formatNumber(row.calculatedCumulativeAmount) }}</span>
</template>
</el-table-column>
<el-table-column label="上报人" width="120" align="center">
@ -205,7 +219,13 @@ const formRef = ref<FormInstance>()
const form = reactive({
actualCompletion: 0,
remarks: ''
remarks: '',
reportToLevel: '' as string // Battalion Regiment
})
// Company = 4
const isCompanyLevel = computed(() => {
return authStore.organizationalLevelNum === 4
})
// Battalion = 3, Company = 4
@ -220,6 +240,24 @@ const totalReportedAmount = computed(() => {
return consumptionReports.value.reduce((sum, report) => sum + report.reportedAmount, 0)
})
//
// cumulativeAmount
const reportsWithRecalculatedCumulative = computed(() => {
//
const sortedByTimeAsc = [...consumptionReports.value].sort(
(a, b) => new Date(a.reportedAt).getTime() - new Date(b.reportedAt).getTime()
)
let cumulative = 0
const reportsWithCumulative = sortedByTimeAsc.map(report => {
cumulative += report.reportedAmount
return { ...report, calculatedCumulativeAmount: cumulative }
})
//
return reportsWithCumulative.reverse()
})
//
const formRules = computed<FormRules>(() => {
const baseRules: FormRules = {
@ -234,6 +272,13 @@ const formRules = computed<FormRules>(() => {
]
}
//
if (isCompanyLevel.value) {
baseRules.reportToLevel = [
{ required: true, message: '请选择上报目标', trigger: 'change' }
]
}
// /
if (!isBattalionOrBelow.value) {
baseRules.actualCompletion.push({
@ -266,7 +311,7 @@ function getCategoryTagType(category?: string): string {
case '装备': return 'warning'
case '物资': return 'success'
case '器材': return 'info'
default: return ''
default: return 'info'
}
}
@ -295,10 +340,16 @@ async function handleSubmit() {
const newTotal = (distribution.value?.actualCompletion || 0) + form.actualCompletion
// 使""
const confirmMessage = isBattalionOrBelow.value
? `本次消耗数量:${form.actualCompletion} ${allocation.value?.unit}\n\n确认提交吗`
: `本次上报数量:${form.actualCompletion} ${allocation.value?.unit}\n上报后总数${newTotal} ${allocation.value?.unit}\n\n确认提交吗`
//
let confirmMessage = ''
if (isCompanyLevel.value) {
const targetName = form.reportToLevel === 'Battalion' ? '营部' : '团部'
confirmMessage = `本次消耗数量:${form.actualCompletion} ${allocation.value?.unit}\n上报目标${targetName}\n\n确认提交吗`
} else if (isBattalionOrBelow.value) {
confirmMessage = `本次消耗数量:${form.actualCompletion} ${allocation.value?.unit}\n\n确认提交吗`
} else {
confirmMessage = `本次上报数量:${form.actualCompletion} ${allocation.value?.unit}\n上报后总数${newTotal} ${allocation.value?.unit}\n\n确认提交吗`
}
await ElMessageBox.confirm(
confirmMessage,
@ -314,9 +365,17 @@ async function handleSubmit() {
if (!distribution.value) return
//
let remarks = form.remarks || ''
if (isCompanyLevel.value && form.reportToLevel) {
const targetName = form.reportToLevel === 'Battalion' ? '营部' : '团部'
remarks = remarks ? `[上报到${targetName}] ${remarks}` : `[上报到${targetName}]`
}
//
await allocationsApi.updateDistribution(distribution.value.id, {
actualCompletion: newTotal
actualCompletion: newTotal,
remarks: remarks
})
ElMessage.success(isBattalionOrBelow.value ? '消耗提交成功' : '上报成功')
@ -560,4 +619,23 @@ onMounted(() => {
.history-section :deep(.el-table) {
margin-top: 16px;
}
.radio-desc {
margin-left: 8px;
color: #909399;
font-size: 13px;
}
:deep(.el-radio-group) {
display: flex;
flex-direction: column;
gap: 12px;
}
:deep(.el-radio) {
display: flex;
align-items: center;
height: auto;
padding: 8px 0;
}
</style>

View File

@ -95,7 +95,7 @@ function getTypeTagType(type: ApprovalRequestType): string {
case ApprovalRequestType.AllocationModification: return 'primary'
case ApprovalRequestType.ReportModification: return 'success'
case ApprovalRequestType.PersonnelModification: return 'warning'
default: return ''
default: return 'info'
}
}
@ -113,7 +113,7 @@ function getStatusTagType(status: ApprovalStatus): string {
case ApprovalStatus.Pending: return 'warning'
case ApprovalStatus.Approved: return 'success'
case ApprovalStatus.Rejected: return 'danger'
default: return ''
default: return 'info'
}
}

View File

@ -138,7 +138,7 @@ function getTypeTagType(type: ApprovalRequestType): string {
case ApprovalRequestType.AllocationModification: return 'primary'
case ApprovalRequestType.ReportModification: return 'success'
case ApprovalRequestType.PersonnelModification: return 'warning'
default: return ''
default: return 'info'
}
}
@ -156,7 +156,7 @@ function getStatusTagType(status: ApprovalStatus): string {
case ApprovalStatus.Pending: return 'warning'
case ApprovalStatus.Approved: return 'success'
case ApprovalStatus.Rejected: return 'danger'
default: return ''
default: return 'info'
}
}

View File

@ -201,7 +201,7 @@ function getLevelTagType(level: OrganizationalLevel): string {
case OrganizationalLevel.Regiment: return 'warning'
case OrganizationalLevel.Battalion: return 'success'
case OrganizationalLevel.Company: return 'info'
default: return ''
default: return 'info'
}
}

View File

@ -104,7 +104,7 @@ function getStatusTagType(status: PersonnelStatus): string {
case PersonnelStatus.Pending: return 'warning'
case PersonnelStatus.Approved: return 'success'
case PersonnelStatus.Rejected: return 'danger'
default: return ''
default: return 'info'
}
}
@ -124,7 +124,7 @@ function getLevelTagType(level: PersonnelLevel): string {
case PersonnelLevel.Regiment: return 'warning'
case PersonnelLevel.Battalion: return 'success'
case PersonnelLevel.Company: return 'info'
default: return ''
default: return 'info'
}
}

View File

@ -54,7 +54,7 @@
</el-table-column>
<el-table-column prop="gender" label="性别" width="70" align="center">
<template #default="{ row }">
<el-tag :type="row.gender === '男' ? '' : 'danger'" size="small" effect="plain">
<el-tag :type="row.gender === '男' ? 'primary' : 'danger'" size="small" effect="plain">
{{ row.gender }}
</el-tag>
</template>
@ -202,7 +202,7 @@ function getStatusTagType(status: PersonnelStatus): string {
case PersonnelStatus.Pending: return 'warning'
case PersonnelStatus.Approved: return 'success'
case PersonnelStatus.Rejected: return 'danger'
default: return ''
default: return 'info'
}
}
@ -222,7 +222,7 @@ function getLevelTagType(level: PersonnelLevel): string {
case PersonnelLevel.Regiment: return 'warning'
case PersonnelLevel.Battalion: return 'success'
case PersonnelLevel.Company: return 'info'
default: return ''
default: return 'info'
}
}

View File

@ -1,77 +1,196 @@
<template>
<div class="report-list">
<el-card>
<el-card class="report-card" shadow="hover">
<template #header>
<div class="card-header">
<span>上报管理</span>
<div class="header-title">
<el-icon class="title-icon" :size="22"><DataAnalysis /></el-icon>
<span>上报管理</span>
</div>
<div class="header-stats">
<el-tag type="info" effect="plain">
{{ pagination.total }} 条记录
</el-tag>
</div>
</div>
</template>
<el-table :data="reports" style="width: 100%" v-loading="loading">
<el-table-column prop="category" label="类别" width="120" />
<el-table-column prop="materialName" label="物资名称" />
<el-table-column prop="unit" label="单位" width="80" />
<el-table-column prop="unitQuota" label="配额" width="100" />
<el-table-column prop="actualCompletion" label="实际完成" width="120">
<!-- 统计卡片 -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-icon total">
<el-icon :size="24"><Box /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ pagination.total }}</div>
<div class="stat-label">总配额数</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon reported">
<el-icon :size="24"><CircleCheck /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ reportedCount }}</div>
<div class="stat-label">已上报</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon pending">
<el-icon :size="24"><Clock /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ pendingCount }}</div>
<div class="stat-label">待上报</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon rate">
<el-icon :size="24"><TrendCharts /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ averageCompletionRate }}%</div>
<div class="stat-label">平均完成率</div>
</div>
</div>
</div>
<el-table
:data="reports"
style="width: 100%"
v-loading="loading"
stripe
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: 'bold' }"
>
<el-table-column prop="category" label="类别" width="100" align="center">
<template #default="{ row }">
<span v-if="row.actualCompletion !== null">{{ row.actualCompletion }}</span>
<el-tag v-else type="warning" size="small">未上报</el-tag>
<el-tag :type="getCategoryTagType(row.category)" size="small">
{{ row.category }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="completionRate" label="完成率" width="150">
<el-table-column prop="materialName" label="物资名称" min-width="150">
<template #default="{ row }">
<el-progress :percentage="Math.round(row.completionRate * 100)" :status="getProgressStatus(row.completionRate)" />
<span class="material-name">{{ row.materialName }}</span>
</template>
</el-table-column>
<el-table-column prop="targetUnitName" label="所属单位" width="150" />
<el-table-column prop="reportedAt" label="上报时间" width="180">
<el-table-column prop="unit" label="单位" width="80" align="center" />
<el-table-column prop="unitQuota" label="配额" width="100" align="right">
<template #default="{ row }">
{{ row.reportedAt ? formatDate(row.reportedAt) : '-' }}
<span class="quota-value">{{ formatNumber(row.unitQuota) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150">
<el-table-column prop="actualCompletion" label="实际完成" width="120" align="right">
<template #default="{ row }">
<span v-if="row.actualCompletion !== null" class="completion-value">
{{ formatNumber(row.actualCompletion) }}
</span>
<el-tag v-else type="warning" size="small" effect="light">未上报</el-tag>
</template>
</el-table-column>
<el-table-column prop="completionRate" label="完成率" width="160" align="center">
<template #default="{ row }">
<div class="progress-cell">
<el-progress
:percentage="Math.round(row.completionRate * 100)"
:status="getProgressStatus(row.completionRate)"
:stroke-width="8"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="targetUnitName" label="所属单位" width="140" align="center">
<template #default="{ row }">
<span class="unit-name">{{ row.targetUnitName }}</span>
</template>
</el-table-column>
<el-table-column prop="reportedAt" label="上报时间" width="170" align="center">
<template #default="{ row }">
<div v-if="row.reportedAt" class="time-cell">
<el-icon class="time-icon"><Clock /></el-icon>
<span>{{ formatDate(row.reportedAt) }}</span>
</div>
<span v-else class="no-data">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="canReport"
type="primary"
text
size="small"
:type="row.actualCompletion !== null ? 'warning' : 'primary'"
size="small"
@click="handleReport(row)"
>
<el-icon class="btn-icon"><Edit /></el-icon>
{{ row.actualCompletion !== null ? '修改' : '上报' }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
:current-page="pagination.pageNumber"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
class="pagination"
@update:current-page="handlePageChange"
@update:page-size="handleSizeChange"
/>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="pagination.pageNumber"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</el-card>
<!-- Report Dialog -->
<el-dialog v-model="showReportDialog" title="上报数据" width="500px">
<el-dialog
v-model="showReportDialog"
:title="selectedReport?.actualCompletion !== null ? '修改上报数据' : '上报数据'"
width="500px"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="reportForm" :rules="rules" label-width="100px">
<el-form-item label="物资类别">
<el-tag :type="getCategoryTagType(selectedReport?.category)">
{{ selectedReport?.category }}
</el-tag>
</el-form-item>
<el-form-item label="物资名称">
<el-input :value="selectedReport?.materialName" disabled />
<span class="dialog-value">{{ selectedReport?.materialName }}</span>
</el-form-item>
<el-form-item label="配额">
<el-input :value="selectedReport?.unitQuota" disabled />
<span class="dialog-value">{{ formatNumber(selectedReport?.unitQuota || 0) }} {{ selectedReport?.unit }}</span>
</el-form-item>
<el-form-item label="当前完成" v-if="selectedReport?.actualCompletion !== null">
<span class="dialog-value highlight">{{ formatNumber(selectedReport?.actualCompletion || 0) }} {{ selectedReport?.unit }}</span>
</el-form-item>
<el-divider />
<el-form-item label="实际完成" prop="actualCompletion">
<el-input-number v-model="reportForm.actualCompletion" :min="0" :precision="2" style="width: 100%" />
<el-input-number
v-model="reportForm.actualCompletion"
:min="0"
:max="selectedReport?.unitQuota"
:precision="2"
:step="1"
style="width: 200px"
/>
<span class="unit-hint">{{ selectedReport?.unit }}</span>
</el-form-item>
<el-form-item label="完成进度">
<el-progress
:percentage="getFormCompletionRate()"
:status="getProgressStatus(getFormCompletionRate() / 100)"
:stroke-width="12"
style="width: 200px"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showReportDialog = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSubmitReport">提交</el-button>
<el-button type="primary" :loading="saving" @click="handleSubmitReport">
<el-icon class="btn-icon"><Check /></el-icon>
确认提交
</el-button>
</template>
</el-dialog>
</div>
@ -80,6 +199,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, FormInstance, FormRules } from 'element-plus'
import { DataAnalysis, Box, CircleCheck, Clock, TrendCharts, Edit, Check } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { reportsApi } from '@/api'
import type { ReportData } from '@/types'
@ -105,24 +225,55 @@ const reportForm = reactive({
})
const rules: FormRules = {
actualCompletion: [{ required: true, message: '请输入实际完成数量', trigger: 'blur' }]
actualCompletion: [
{ required: true, message: '请输入实际完成数量', trigger: 'blur' },
{ type: 'number', min: 0, message: '数量不能为负数', trigger: 'blur' }
]
}
//
const reportedCount = computed(() => reports.value.filter(r => r.actualCompletion !== null).length)
const pendingCount = computed(() => reports.value.filter(r => r.actualCompletion === null).length)
const averageCompletionRate = computed(() => {
if (reports.value.length === 0) return 0
const total = reports.value.reduce((sum, r) => sum + (r.completionRate || 0), 0)
return Math.round(total / reports.value.length * 100)
})
// Company level users can only view, not report
const canReport = computed(() => {
return authStore.user?.organizationalLevel !== OrganizationalLevel.Company
})
function formatNumber(num: number): string {
return num?.toLocaleString('zh-CN') ?? '0'
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString('zh-CN')
}
function getCategoryTagType(category?: string): string {
switch (category) {
case '弹药': return 'danger'
case '装备': return 'warning'
case '物资': return 'success'
case '器材': return 'info'
default: return 'info'
}
}
function getProgressStatus(rate: number): string {
if (rate >= 1) return 'success'
if (rate >= 0.6) return ''
return 'warning'
}
function getFormCompletionRate(): number {
if (!selectedReport.value?.unitQuota) return 0
return Math.round((reportForm.actualCompletion / selectedReport.value.unitQuota) * 100)
}
function handlePageChange(page: number) {
pagination.pageNumber = page
loadReports()
@ -158,10 +309,10 @@ function handleReport(report: ReportData) {
async function handleSubmitReport() {
if (!formRef.value || !selectedReport.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
saving.value = true
try {
await reportsApi.submit({
@ -185,14 +336,158 @@ onMounted(() => {
</script>
<style scoped>
.report-list {
padding: 0;
}
.report-card {
border-radius: 8px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.pagination {
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
color: #303133;
}
.title-icon {
color: #409eff;
}
/* 统计卡片 */
.stats-row {
display: flex;
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: linear-gradient(135deg, #f5f7fa 0%, #fff 100%);
border-radius: 8px;
border: 1px solid #ebeef5;
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.stat-icon.total {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
}
.stat-icon.reported {
background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
}
.stat-icon.pending {
background: linear-gradient(135deg, #e6a23c 0%, #ebb563 100%);
}
.stat-icon.rate {
background: linear-gradient(135deg, #909399 0%, #a6a9ad 100%);
}
.stat-info {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: #303133;
}
.stat-label {
font-size: 13px;
color: #909399;
margin-top: 2px;
}
/* 表格样式 */
.material-name {
font-weight: 500;
color: #303133;
}
.quota-value {
font-weight: 500;
color: #606266;
}
.completion-value {
font-weight: 600;
color: #67c23a;
}
.unit-name {
color: #606266;
}
.progress-cell {
padding: 0 8px;
}
.time-cell {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: #909399;
font-size: 13px;
}
.time-icon {
font-size: 14px;
}
.no-data {
color: #c0c4cc;
}
.btn-icon {
margin-right: 4px;
}
/* 分页 */
.pagination-wrapper {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
/* 弹窗样式 */
.dialog-value {
font-weight: 500;
color: #303133;
}
.dialog-value.highlight {
color: #409eff;
}
.unit-hint {
margin-left: 8px;
color: #909399;
}
</style>

View File

@ -120,7 +120,7 @@ function getLevelTagType(level: OrganizationalLevel): string {
case OrganizationalLevel.Regiment: return 'warning'
case OrganizationalLevel.Battalion: return 'success'
case OrganizationalLevel.Company: return 'info'
default: return ''
default: return 'info'
}
}