This commit is contained in:
18631081161 2026-01-14 20:52:59 +08:00
parent 8cada25804
commit d7f39c31bc
26 changed files with 866 additions and 174 deletions

View File

@ -28,7 +28,9 @@ public class AllocationsController : BaseApiController
/// 获取当前用户可见的所有物资配额
/// </summary>
[HttpGet]
public async Task<IActionResult> GetAll()
public async Task<IActionResult> GetAll(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 10)
{
var unitId = GetCurrentUnitId();
var unitLevel = GetCurrentUnitLevel();
@ -37,18 +39,31 @@ public class AllocationsController : BaseApiController
return Unauthorized(new { message = "无法获取用户组织信息" });
// 师团级可以看到所有配额,其他级别只能看到分配给自己及下级的配额
IEnumerable<Models.Entities.MaterialAllocation> allocations;
IEnumerable<Models.Entities.MaterialAllocation> allAllocations;
if (unitLevel == OrganizationalLevel.Division)
{
allocations = await _allocationService.GetAllAsync();
allAllocations = await _allocationService.GetAllAsync();
}
else
{
allocations = await _allocationService.GetVisibleToUnitAsync(unitId.Value);
allAllocations = await _allocationService.GetVisibleToUnitAsync(unitId.Value);
}
var response = allocations.Select(MapToResponse);
return Ok(response);
var totalCount = allAllocations.Count();
var items = allAllocations
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.Select(MapToResponse)
.ToList();
return Ok(new
{
items,
totalCount,
pageNumber,
pageSize,
totalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
});
}
/// <summary>
@ -132,7 +147,7 @@ public class AllocationsController : BaseApiController
request.Unit,
request.TotalQuota,
unitId.Value,
request.Distributions);
request.GetDistributionsDictionary());
return CreatedAtAction(nameof(GetById), new { id = allocation.Id }, MapToResponse(allocation));
}
@ -251,12 +266,14 @@ public class AllocationsController : BaseApiController
[HttpPost("validate")]
public async Task<IActionResult> ValidateDistributions([FromBody] CreateAllocationRequest request)
{
var distributions = request.GetDistributionsDictionary();
var isValid = await _allocationService.ValidateDistributionQuotasAsync(
request.TotalQuota,
request.Distributions);
distributions);
var targetUnitsExist = await _allocationService.ValidateTargetUnitsExistAsync(
request.Distributions.Keys);
distributions.Keys);
return Ok(new
{

View File

@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
namespace MilitaryTrainingManagement.Controllers;
/// <summary>
/// 文件服务控制器
/// </summary>
[ApiController]
[Route("api/files")]
public class FilesController : ControllerBase
{
private readonly IWebHostEnvironment _environment;
private readonly FileExtensionContentTypeProvider _contentTypeProvider;
public FilesController(IWebHostEnvironment environment)
{
_environment = environment;
_contentTypeProvider = new FileExtensionContentTypeProvider();
}
/// <summary>
/// 获取上传的文件
/// </summary>
[HttpGet("{*filePath}")]
[AllowAnonymous]
public IActionResult GetFile(string filePath)
{
if (string.IsNullOrEmpty(filePath))
return BadRequest(new { message = "文件路径不能为空" });
// 安全检查:防止路径遍历攻击
if (filePath.Contains("..") || filePath.Contains("~"))
return BadRequest(new { message = "无效的文件路径" });
var uploadsPath = Path.Combine(_environment.ContentRootPath, "uploads");
var fullPath = Path.Combine(uploadsPath, filePath);
// 确保文件在uploads目录内
if (!fullPath.StartsWith(uploadsPath))
return BadRequest(new { message = "无效的文件路径" });
if (!System.IO.File.Exists(fullPath))
return NotFound(new { message = "文件不存在" });
// 获取文件的MIME类型
if (!_contentTypeProvider.TryGetContentType(fullPath, out var contentType))
{
contentType = "application/octet-stream";
}
var fileStream = System.IO.File.OpenRead(fullPath);
return File(fileStream, contentType);
}
}

View File

@ -1,7 +1,10 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MilitaryTrainingManagement.Data;
using MilitaryTrainingManagement.Models.DTOs;
using MilitaryTrainingManagement.Models.Entities;
using MilitaryTrainingManagement.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace MilitaryTrainingManagement.Controllers;
@ -12,10 +15,17 @@ namespace MilitaryTrainingManagement.Controllers;
public class OrganizationsController : BaseApiController
{
private readonly IOrganizationService _organizationService;
private readonly IAuthenticationService _authService;
private readonly ApplicationDbContext _context;
public OrganizationsController(IOrganizationService organizationService)
public OrganizationsController(
IOrganizationService organizationService,
IAuthenticationService authService,
ApplicationDbContext context)
{
_organizationService = organizationService;
_authService = authService;
_context = context;
}
[HttpGet]
@ -63,4 +73,39 @@ public class OrganizationsController : BaseApiController
await _organizationService.DeleteAsync(id);
return NoContent();
}
/// <summary>
/// 为组织创建账户
/// </summary>
[HttpPost("{id}/accounts")]
public async Task<IActionResult> CreateAccount(int id, [FromBody] CreateAccountRequest request)
{
var unit = await _organizationService.GetByIdAsync(id);
if (unit == null)
{
return NotFound(new { message = "组织不存在" });
}
// 检查用户名是否已存在
var existingUser = await _context.UserAccounts
.FirstOrDefaultAsync(u => u.Username == request.Username);
if (existingUser != null)
{
return BadRequest(new { message = "用户名已存在" });
}
var account = new UserAccount
{
Username = request.Username,
PasswordHash = _authService.HashPassword(request.Password),
DisplayName = unit.Name + "管理员",
OrganizationalUnitId = id,
CreatedAt = DateTime.UtcNow
};
_context.UserAccounts.Add(account);
await _context.SaveChangesAsync();
return Ok(new { message = "账户创建成功", username = account.Username });
}
}

View File

@ -32,7 +32,10 @@ public class PersonnelController : BaseApiController
/// 获取人员列表(基于层级权限控制)
/// </summary>
[HttpGet]
public async Task<IActionResult> GetPersonnel([FromQuery] bool includeSubordinates = false)
public async Task<IActionResult> GetPersonnel(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 10,
[FromQuery] string? status = null)
{
var unitId = GetCurrentUnitId();
var userLevel = GetCurrentUnitLevel();
@ -42,8 +45,28 @@ public class PersonnelController : BaseApiController
return Unauthorized();
}
var personnel = await _personnelService.GetVisiblePersonnelAsync(unitId.Value, userLevel.Value);
return Ok(personnel);
var allPersonnel = await _personnelService.GetVisiblePersonnelAsync(unitId.Value, userLevel.Value);
// 状态筛选
if (!string.IsNullOrEmpty(status) && Enum.TryParse<PersonnelStatus>(status, out var statusFilter))
{
allPersonnel = allPersonnel.Where(p => p.Status == statusFilter);
}
var totalCount = allPersonnel.Count();
var items = allPersonnel
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToList();
return Ok(new
{
items,
totalCount,
pageNumber,
pageSize,
totalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
});
}
/// <summary>
@ -136,8 +159,21 @@ public class PersonnelController : BaseApiController
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreatePersonnelRequest request)
public async Task<IActionResult> Create([FromForm] CreatePersonnelRequest request,
IFormFile? photo, IFormFile? supportingDocuments)
{
// 返回详细的验证错误
if (!ModelState.IsValid)
{
var errors = ModelState
.Where(x => x.Value?.Errors.Count > 0)
.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value?.Errors.Select(e => e.ErrorMessage).ToArray()
);
return BadRequest(new { message = "验证失败", errors });
}
var unitId = GetCurrentUnitId();
if (unitId == null)
{
@ -162,15 +198,27 @@ public class PersonnelController : BaseApiController
SubmittedByUnitId = unitId.Value
};
var created = await _personnelService.CreateAsync(personnel);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
try
{
var created = await _personnelService.SubmitPersonnelAsync(personnel, photo, supportingDocuments);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
}
catch (ArgumentException ex)
{
return BadRequest(new { message = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
}
/// <summary>
/// 更新人员信息(仅限未审批的人员)
/// </summary>
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdatePersonnelRequest request)
public async Task<IActionResult> Update(int id, [FromForm] UpdatePersonnelRequest request,
IFormFile? photo, IFormFile? supportingDocuments)
{
var personnel = await _personnelService.GetByIdAsync(id);
if (personnel == null)
@ -206,6 +254,30 @@ public class PersonnelController : BaseApiController
personnel.TrainingParticipation = request.TrainingParticipation;
personnel.Achievements = request.Achievements;
// 处理照片上传
if (photo != null)
{
if (!string.IsNullOrEmpty(personnel.PhotoPath))
{
await _fileUploadService.DeleteFileAsync(personnel.PhotoPath);
}
personnel.PhotoPath = await _fileUploadService.UploadPhotoAsync(photo);
}
// 处理文档上传
if (supportingDocuments != null)
{
var documentPath = await _fileUploadService.UploadDocumentAsync(supportingDocuments);
if (string.IsNullOrEmpty(personnel.SupportingDocuments))
{
personnel.SupportingDocuments = documentPath;
}
else
{
personnel.SupportingDocuments += ";" + documentPath;
}
}
var updated = await _personnelService.UpdateAsync(personnel);
return Ok(updated);
}
@ -356,7 +428,7 @@ public class PersonnelController : BaseApiController
var canApprove = await _personnelService.CanApprovePersonnelAsync(userId.Value, id);
if (!canApprove)
{
return Forbid("您没有权限审批此人员");
return StatusCode(403, new { message = "您没有权限审批此人员" });
}
try
@ -366,7 +438,7 @@ public class PersonnelController : BaseApiController
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
return BadRequest(new { message = ex.Message });
}
}
@ -387,7 +459,7 @@ public class PersonnelController : BaseApiController
var canApprove = await _personnelService.CanApprovePersonnelAsync(userId.Value, id);
if (!canApprove)
{
return Forbid("您没有权限拒绝此人员");
return StatusCode(403, new { message = "您没有权限拒绝此人员" });
}
var personnel = await _personnelService.RejectAsync(id, userId.Value);

View File

@ -2,6 +2,15 @@ using System.ComponentModel.DataAnnotations;
namespace MilitaryTrainingManagement.Models.DTOs;
/// <summary>
/// 分配请求项
/// </summary>
public class DistributionRequestItem
{
public int TargetUnitId { get; set; }
public decimal UnitQuota { get; set; }
}
/// <summary>
/// 创建物资配额请求
/// </summary>
@ -35,10 +44,19 @@ public class CreateAllocationRequest
public decimal TotalQuota { get; set; }
/// <summary>
/// 分配记录 (目标单位ID -> 配额)
/// 分配记录
/// </summary>
[Required(ErrorMessage = "分配记录为必填项")]
public Dictionary<int, decimal> Distributions { get; set; } = new();
public List<DistributionRequestItem> Distributions { get; set; } = new();
/// <summary>
/// 转换为字典格式
/// </summary>
public Dictionary<int, decimal> GetDistributionsDictionary()
{
return Distributions
.Where(d => d.TargetUnitId > 0)
.ToDictionary(d => d.TargetUnitId, d => d.UnitQuota);
}
}
/// <summary>

View File

@ -13,3 +13,9 @@ public class UpdateOrganizationRequest
{
public string Name { get; set; } = string.Empty;
}
public class CreateAccountRequest
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}

View File

@ -88,6 +88,8 @@ builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles;
// 将枚举序列化为字符串
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
builder.Services.AddEndpointsApiExplorer();

View File

@ -11,13 +11,12 @@ public class FileUploadService : IFileUploadService
private readonly IWebHostEnvironment _environment;
private readonly ILogger<FileUploadService> _logger;
// 2寸照片标准尺寸像素35mm x 49mm300dpi约为413x579像素
// 允许一定的误差范围
private const int MinPhotoWidth = 300;
private const int MaxPhotoWidth = 600;
private const int MinPhotoHeight = 400;
private const int MaxPhotoHeight = 800;
private const long MaxPhotoSize = 5 * 1024 * 1024; // 5MB
// 照片尺寸限制(放宽限制以适应各种照片)
private const int MinPhotoWidth = 100;
private const int MaxPhotoWidth = 4000;
private const int MinPhotoHeight = 100;
private const int MaxPhotoHeight = 4000;
private const long MaxPhotoSize = 10 * 1024 * 1024; // 10MB
private const long MaxDocumentSize = 20 * 1024 * 1024; // 20MB
private static readonly string[] AllowedPhotoExtensions = { ".jpg", ".jpeg", ".png" };
@ -73,7 +72,7 @@ public class FileUploadService : IFileUploadService
var width = image.Width;
var height = image.Height;
// 验证2寸照片尺寸要求
// 验证照片尺寸(放宽限制)
if (width < MinPhotoWidth || width > MaxPhotoWidth)
{
return PhotoValidationResult.Failure($"照片宽度应在{MinPhotoWidth}-{MaxPhotoWidth}像素之间,当前为{width}像素");
@ -84,13 +83,7 @@ public class FileUploadService : IFileUploadService
return PhotoValidationResult.Failure($"照片高度应在{MinPhotoHeight}-{MaxPhotoHeight}像素之间,当前为{height}像素");
}
// 验证宽高比2寸照片约为35:49 ≈ 0.71
var aspectRatio = (double)width / height;
if (aspectRatio < 0.5 || aspectRatio > 0.9)
{
return PhotoValidationResult.Failure("照片宽高比不符合2寸照片要求约35:49");
}
// 不再严格验证宽高比,允许各种照片
return PhotoValidationResult.Success(width, height);
}
catch (Exception ex)

View File

@ -91,10 +91,11 @@ public class PersonnelService : IPersonnelService
if (approvedByUnit == null)
throw new ArgumentException("审批单位不存在");
// 验证人员等级与审批单位层级一致
var expectedLevel = (PersonnelLevel)(int)approvedByUnit.Level;
if (level != expectedLevel)
throw new ArgumentException("人员等级必须与审批单位层级一致");
// 验证审批单位层级必须高于或等于人员等级(数值越小层级越高)
var unitLevelValue = (int)approvedByUnit.Level;
var personnelLevelValue = (int)level;
if (unitLevelValue > personnelLevelValue)
throw new ArgumentException("审批单位层级不足以审批该等级人才");
var previousStatus = personnel.Status;
var previousLevel = personnel.ApprovedLevel;
@ -528,6 +529,10 @@ public class PersonnelService : IPersonnelService
if (personnel == null || personnel.Status != PersonnelStatus.Pending)
return false;
// 同一单位可以审批自己提交的人员
if (user.OrganizationalUnitId == personnel.SubmittedByUnitId)
return true;
// 检查用户的组织单位是否是提交单位的上级
var isParent = await _organizationService.IsParentUnitAsync(user.OrganizationalUnitId, personnel.SubmittedByUnitId);

View File

@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("MilitaryTrainingManagement")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+dea416c1206a35a2f77e33f37406891d5377f1a6")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+8cada25804844f7e625d0f213b568324580ab12f")]
[assembly: System.Reflection.AssemblyProductAttribute("MilitaryTrainingManagement")]
[assembly: System.Reflection.AssemblyTitleAttribute("MilitaryTrainingManagement")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@ -1 +1 @@
71761eb666e1de962287ab2d3efffb4d59f9a1ceb181540c49222eadb77fb760
947693409f11b6d9a783dd5578eff75f0295de8dfc4524de39ae86b037f3a76a

View File

@ -1 +1 @@
9aa7a804a7b716c6d51cb391fd2292638fb67f08685ae7d440982f042b40fd5e
33d3e5abc8ec2790adb522e907e856fe53e06ac3a2e49a9f8cb326340b3b3177

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

View File

@ -41,8 +41,7 @@ export const personnelApi = {
async approve(data: PersonnelApprovalRequest): Promise<Personnel> {
const response = await apiClient.post<Personnel>(`/personnel/${data.personnelId}/approve`, {
approved: data.approved,
comments: data.comments
level: data.level
})
return response.data
},

View File

@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi } from '@/api/auth'
import type { User, LoginRequest } from '@/types'
import { OrganizationalLevel, OrganizationalLevelValue } from '@/types'
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
@ -11,13 +12,17 @@ export const useAuthStore = defineStore('auth', () => {
// 只检查token是否存在
const isAuthenticated = computed(() => !!token.value)
const organizationalLevel = computed(() => user.value?.organizationalLevel ?? 4)
// 获取组织级别的数值(用于权限比较)
const organizationalLevelNum = computed(() => {
if (!user.value?.organizationalLevel) return 4
return OrganizationalLevelValue[user.value.organizationalLevel] ?? 4
})
const canManageSubordinates = computed(() => organizationalLevel.value < 4)
const canManageSubordinates = computed(() => organizationalLevelNum.value < 4)
const canCreateAllocations = computed(() => organizationalLevel.value === 1)
const canCreateAllocations = computed(() => organizationalLevelNum.value === 1)
const canApprove = computed(() => organizationalLevel.value < 4)
const canApprove = computed(() => organizationalLevelNum.value < 4)
async function login(credentials: LoginRequest): Promise<boolean> {
try {
@ -80,7 +85,7 @@ export const useAuthStore = defineStore('auth', () => {
}
function hasPermission(requiredLevel: number): boolean {
return organizationalLevel.value <= requiredLevel
return organizationalLevelNum.value <= requiredLevel
}
return {
@ -88,7 +93,8 @@ export const useAuthStore = defineStore('auth', () => {
token,
initialized,
isAuthenticated,
organizationalLevel,
organizationalLevel: computed(() => user.value?.organizationalLevel),
organizationalLevelNum,
canManageSubordinates,
canCreateAllocations,
canApprove,

View File

@ -1,16 +1,24 @@
// Enums
export enum OrganizationalLevel {
Division = 1, // 师团
Regiment = 2, // 团
Battalion = 3, // 营
Company = 4 // 连
Division = 'Division', // 师团
Regiment = 'Regiment', // 团
Battalion = 'Battalion', // 营
Company = 'Company' // 连
}
// 用于权限判断的级别数值映射
export const OrganizationalLevelValue: Record<OrganizationalLevel, number> = {
[OrganizationalLevel.Division]: 1,
[OrganizationalLevel.Regiment]: 2,
[OrganizationalLevel.Battalion]: 3,
[OrganizationalLevel.Company]: 4
}
export enum PersonnelLevel {
Division = 1, // 师级人才
Regiment = 2, // 团级人才
Battalion = 3, // 营级人才
Company = 4 // 连级人才
Division = 'Division', // 师级人才
Regiment = 'Regiment', // 团级人才
Battalion = 'Battalion', // 营级人才
Company = 'Company' // 连级人才
}
export enum PersonnelStatus {
@ -192,6 +200,7 @@ export interface PersonnelApprovalRequest {
personnelId: number
approved: boolean
comments?: string
level?: string
}
// Approval types

View File

@ -1,66 +1,202 @@
<template>
<div class="allocation-list">
<el-card>
<el-card class="list-card" shadow="hover">
<template #header>
<div class="card-header">
<span>物资配额列表</span>
<el-button v-if="authStore.canCreateAllocations" type="primary" @click="$router.push('/allocations/create')">
<el-icon><Plus /></el-icon>
创建配额
</el-button>
<div class="header-title">
<el-icon class="title-icon" :size="22"><Box /></el-icon>
<span>物资配额管理</span>
</div>
<div class="header-actions">
<el-input
v-model="searchKeyword"
placeholder="搜索物资名称"
clearable
style="width: 180px; margin-right: 12px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select v-model="categoryFilter" placeholder="类别筛选" clearable style="width: 120px; margin-right: 12px">
<el-option label="弹药" value="弹药" />
<el-option label="装备" value="装备" />
<el-option label="物资" value="物资" />
<el-option label="器材" value="器材" />
</el-select>
<el-button v-if="authStore.canCreateAllocations" type="primary" @click="$router.push('/allocations/create')">
<el-icon><Plus /></el-icon>
创建配额
</el-button>
</div>
</div>
</template>
<el-table :data="allocations" 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="totalQuota" label="总配额" width="120" />
<el-table-column prop="createdByUnitName" label="创建单位" width="150" />
<el-table-column prop="createdAt" label="创建时间" width="180">
<el-table
:data="filteredAllocations"
style="width: 100%"
v-loading="loading"
stripe
highlight-current-row
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: 'bold' }"
>
<el-table-column prop="category" label="类别" width="100" align="center">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
<el-tag :type="getCategoryTagType(row.category)" effect="plain" size="small">
{{ row.category }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<el-table-column prop="materialName" label="物资名称" min-width="150">
<template #default="{ row }">
<el-button type="primary" text size="small" @click="handleViewDistribution(row)">查看分配</el-button>
<el-button v-if="authStore.canCreateAllocations" type="warning" text size="small" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="authStore.canCreateAllocations" type="danger" text size="small" @click="handleDelete(row)">删除</el-button>
<span class="material-name">{{ row.materialName }}</span>
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="80" align="center">
<template #default="{ row }">
<span class="unit-text">{{ row.unit }}</span>
</template>
</el-table-column>
<el-table-column prop="totalQuota" label="总配额" width="120" align="right">
<template #default="{ row }">
<span class="quota-value">{{ formatNumber(row.totalQuota) }}</span>
</template>
</el-table-column>
<el-table-column label="分配情况" width="150" align="center">
<template #default="{ row }">
<div class="distribution-info">
<span class="dist-count">{{ row.distributions?.length || 0 }} 个单位</span>
<el-progress
:percentage="getDistributionPercentage(row)"
:stroke-width="6"
:show-text="false"
style="width: 60px; margin-left: 8px"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="createdByUnitName" label="创建单位" width="120" show-overflow-tooltip />
<el-table-column prop="createdAt" label="创建时间" width="160" align="center">
<template #default="{ row }">
<span class="time-cell">{{ formatDate(row.createdAt) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right" align="center">
<template #default="{ row }">
<div class="action-buttons">
<el-tooltip content="查看分配" placement="top">
<el-button type="primary" link size="small" @click="handleViewDistribution(row)">
<el-icon><View /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip v-if="authStore.canCreateAllocations" content="编辑" placement="top">
<el-button type="warning" link size="small" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip v-if="authStore.canCreateAllocations" content="删除" placement="top">
<el-button type="danger" link size="small" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
</el-button>
</el-tooltip>
</div>
</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>
<!-- Distribution Dialog -->
<el-dialog v-model="showDistributionDialog" title="配额分配详情" width="800px">
<el-table :data="distributions" style="width: 100%">
<el-table-column prop="targetUnitName" label="目标单位" />
<el-table-column prop="unitQuota" label="分配配额" width="120" />
<el-table-column prop="actualCompletion" label="实际完成" width="120">
<el-dialog
v-model="showDistributionDialog"
:title="`配额分配详情 - ${selectedAllocation?.materialName || ''}`"
width="900px"
:close-on-click-modal="false"
>
<div class="distribution-summary">
<el-row :gutter="20">
<el-col :span="6">
<div class="summary-item">
<div class="summary-label">物资类别</div>
<div class="summary-value">
<el-tag :type="getCategoryTagType(selectedAllocation?.category)" size="small">
{{ selectedAllocation?.category }}
</el-tag>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="summary-item">
<div class="summary-label">总配额</div>
<div class="summary-value highlight">{{ formatNumber(selectedAllocation?.totalQuota || 0) }} {{ selectedAllocation?.unit }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="summary-item">
<div class="summary-label">已分配</div>
<div class="summary-value">{{ formatNumber(getTotalDistributed()) }} {{ selectedAllocation?.unit }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="summary-item">
<div class="summary-label">分配单位数</div>
<div class="summary-value">{{ distributions.length }} </div>
</div>
</el-col>
</el-row>
</div>
<el-table
:data="distributions"
style="width: 100%"
stripe
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: 'bold' }"
>
<el-table-column prop="targetUnitName" label="目标单位" min-width="150">
<template #default="{ row }">
{{ row.actualCompletion ?? '-' }}
<span class="unit-name">{{ row.targetUnitName }}</span>
</template>
</el-table-column>
<el-table-column prop="completionRate" label="完成率" width="120">
<el-table-column prop="unitQuota" label="分配配额" width="120" align="right">
<template #default="{ row }">
<el-progress :percentage="Math.round(row.completionRate * 100)" :status="getProgressStatus(row.completionRate)" />
<span class="quota-value">{{ formatNumber(row.unitQuota) }}</span>
</template>
</el-table-column>
<el-table-column prop="reportedAt" label="上报时间" width="180">
<el-table-column prop="actualCompletion" label="实际完成" width="120" align="right">
<template #default="{ row }">
{{ row.reportedAt ? formatDate(row.reportedAt) : '-' }}
<span :class="['completion-value', row.actualCompletion ? '' : 'no-data']">
{{ row.actualCompletion ? formatNumber(row.actualCompletion) : '未上报' }}
</span>
</template>
</el-table-column>
<el-table-column prop="completionRate" label="完成率" width="150" 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="reportedAt" label="上报时间" width="160" align="center">
<template #default="{ row }">
<span class="time-cell">{{ row.reportedAt ? formatDate(row.reportedAt) : '-' }}</span>
</template>
</el-table-column>
</el-table>
@ -69,10 +205,10 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { Plus, Search, View, Edit, Delete, Box } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { allocationsApi } from '@/api'
import type { MaterialAllocation, AllocationDistribution } from '@/types'
@ -84,6 +220,9 @@ const allocations = ref<MaterialAllocation[]>([])
const distributions = ref<AllocationDistribution[]>([])
const loading = ref(false)
const showDistributionDialog = ref(false)
const searchKeyword = ref('')
const categoryFilter = ref('')
const selectedAllocation = ref<MaterialAllocation | null>(null)
const pagination = reactive({
pageNumber: 1,
@ -91,16 +230,56 @@ const pagination = reactive({
total: 0
})
const filteredAllocations = computed(() => {
let result = allocations.value
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(a => a.materialName.toLowerCase().includes(keyword))
}
if (categoryFilter.value) {
result = result.filter(a => a.category === categoryFilter.value)
}
return result
})
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString('zh-CN')
}
function formatNumber(num: number): string {
return num.toLocaleString('zh-CN')
}
function getCategoryTagType(category: string): string {
switch (category) {
case '弹药': return 'danger'
case '装备': return 'warning'
case '物资': return 'success'
case '器材': return 'info'
default: return ''
}
}
function getProgressStatus(rate: number): string {
if (rate >= 1) return 'success'
if (rate >= 0.6) return ''
return 'warning'
}
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)
return Math.round((distributed / allocation.totalQuota) * 100)
}
function getTotalDistributed(): number {
return distributions.value.reduce((sum, d) => sum + (d.unitQuota || 0), 0)
}
function handleSearch() {
pagination.pageNumber = 1
}
function handlePageChange(page: number) {
pagination.pageNumber = page
loadAllocations()
@ -129,12 +308,9 @@ async function loadAllocations() {
}
async function handleViewDistribution(allocation: MaterialAllocation) {
try {
distributions.value = await allocationsApi.getDistributions(allocation.id)
showDistributionDialog.value = true
} catch {
ElMessage.error('加载分配详情失败')
}
selectedAllocation.value = allocation
distributions.value = allocation.distributions || []
showDistributionDialog.value = true
}
function handleEdit(allocation: MaterialAllocation) {
@ -143,9 +319,16 @@ function handleEdit(allocation: MaterialAllocation) {
async function handleDelete(allocation: MaterialAllocation) {
try {
await ElMessageBox.confirm('确定要删除该配额吗?', '确认删除', {
type: 'warning'
})
await ElMessageBox.confirm(
`确定要删除物资配额 "${allocation.materialName}" 吗?此操作不可恢复。`,
'确认删除',
{
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger'
}
)
await allocationsApi.delete(allocation.id)
ElMessage.success('删除成功')
await loadAllocations()
@ -160,14 +343,148 @@ onMounted(() => {
</script>
<style scoped>
.allocation-list {
padding: 0;
}
.list-card {
border-radius: 8px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.pagination {
.header-title {
display: flex;
align-items: center;
font-size: 18px;
font-weight: 600;
color: #303133;
}
.title-icon {
margin-right: 8px;
color: #409EFF;
}
.header-actions {
display: flex;
align-items: center;
}
.material-name {
font-weight: 500;
color: #303133;
}
.unit-text {
color: #909399;
font-size: 13px;
}
.quota-value {
font-weight: 600;
color: #409EFF;
font-size: 14px;
}
.distribution-info {
display: flex;
align-items: center;
justify-content: center;
}
.dist-count {
font-size: 12px;
color: #606266;
}
.time-cell {
font-size: 13px;
color: #909399;
}
.action-buttons {
display: flex;
justify-content: center;
gap: 4px;
}
.action-buttons .el-button {
padding: 4px 8px;
}
.pagination-wrapper {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
/* Distribution Dialog Styles */
.distribution-summary {
background: #f5f7fa;
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
}
.summary-item {
text-align: center;
}
.summary-label {
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.summary-value {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.summary-value.highlight {
color: #409EFF;
font-size: 18px;
}
.unit-name {
font-weight: 500;
}
.completion-value {
font-weight: 500;
color: #67C23A;
}
.completion-value.no-data {
color: #C0C4CC;
font-weight: normal;
}
.progress-cell {
padding: 0 8px;
}
:deep(.el-table .el-table__row) {
transition: background-color 0.2s;
}
:deep(.el-table .el-table__row:hover) {
background-color: #ecf5ff !important;
}
:deep(.el-card__header) {
padding: 16px 20px;
border-bottom: 1px solid #ebeef5;
}
:deep(.el-dialog__header) {
border-bottom: 1px solid #ebeef5;
padding-bottom: 16px;
}
</style>

View File

@ -1,14 +1,35 @@
<template>
<div class="personnel-list">
<el-card>
<el-card class="list-card" shadow="hover">
<template #header>
<div class="card-header">
<span>人员管理</span>
<div class="header-title">
<el-icon class="title-icon" :size="22"><User /></el-icon>
<span>人员管理</span>
</div>
<div class="header-actions">
<el-input
v-model="searchKeyword"
placeholder="搜索姓名/职位"
clearable
style="width: 180px; margin-right: 12px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select v-model="statusFilter" placeholder="状态筛选" clearable style="width: 120px; margin-right: 12px">
<el-option label="待审批" value="Pending" />
<el-option label="已批准" value="Approved" />
<el-option label="已拒绝" value="Rejected" />
<el-option label="待审批" value="Pending">
<el-tag type="warning" size="small">待审批</el-tag>
</el-option>
<el-option label="已批准" value="Approved">
<el-tag type="success" size="small">已批准</el-tag>
</el-option>
<el-option label="已拒绝" value="Rejected">
<el-tag type="danger" size="small">已拒绝</el-tag>
</el-option>
</el-select>
<el-button type="primary" @click="$router.push('/personnel/create')">
<el-icon><Plus /></el-icon>
@ -18,74 +39,108 @@
</div>
</template>
<el-table :data="personnel" style="width: 100%" v-loading="loading">
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="position" label="职位" width="120" />
<el-table-column prop="rank" label="军衔" width="100" />
<el-table-column prop="gender" label="性别" width="60" />
<el-table-column prop="age" label="年龄" width="60" />
<el-table-column prop="submittedByUnitName" label="提交单位" width="120" />
<el-table-column prop="status" label="状态" width="100">
<el-table
:data="personnel"
style="width: 100%"
v-loading="loading"
stripe
highlight-current-row
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: 'bold' }"
>
<el-table-column prop="name" label="姓名" width="100">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)">{{ getStatusName(row.status) }}</el-tag>
<span class="name-cell">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="approvedLevel" label="人员等级" width="100">
<el-table-column prop="gender" label="性别" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.approvedLevel" :type="getLevelTagType(row.approvedLevel)">
<el-tag :type="row.gender === '男' ? '' : 'danger'" size="small" effect="plain">
{{ row.gender }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="age" label="年龄" width="70" align="center" />
<el-table-column prop="position" label="职位" width="120" show-overflow-tooltip />
<el-table-column prop="rank" label="军衔" width="100" show-overflow-tooltip />
<el-table-column prop="submittedByUnitName" label="所属单位" min-width="120" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)" effect="dark" size="small">
{{ getStatusName(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="approvedLevel" label="人员等级" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.approvedLevel" :type="getLevelTagType(row.approvedLevel)" size="small">
{{ getLevelName(row.approvedLevel) }}
</el-tag>
<span v-else>-</span>
<span v-else class="no-data">-</span>
</template>
</el-table-column>
<el-table-column prop="submittedAt" label="提交时间" width="160">
<el-table-column prop="submittedAt" label="提交时间" width="170" align="center">
<template #default="{ row }">
{{ formatDate(row.submittedAt) }}
<span class="time-cell">{{ formatDate(row.submittedAt) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" text size="small" @click="handleView(row)">查看</el-button>
<el-button
v-if="row.status === 'Pending' && authStore.canApprove"
type="success"
text
size="small"
@click="handleApprove(row)"
>
审批
</el-button>
<el-button
v-if="row.status === 'Pending'"
type="warning"
text
size="small"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button type="danger" text size="small" @click="handleDelete(row)">删除</el-button>
<div class="action-buttons">
<el-tooltip content="查看详情" placement="top">
<el-button type="primary" link size="small" @click="handleView(row)">
<el-icon><View /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip v-if="row.status === 'Pending' && authStore.canApprove" content="审批" placement="top">
<el-button type="success" link size="small" @click="handleApprove(row)">
<el-icon><Check /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip v-if="row.status === 'Pending'" content="编辑" placement="top">
<el-button type="warning" link size="small" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button type="danger" link size="small" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
</el-button>
</el-tooltip>
</div>
</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>
<!-- Approval Dialog -->
<el-dialog v-model="showApprovalDialog" title="审批人员" width="500px">
<el-dialog v-model="showApprovalDialog" title="审批人员" width="500px" :close-on-click-modal="false">
<el-form :model="approvalForm" label-width="100px">
<el-form-item label="人员姓名">
<el-input :value="selectedPerson?.name" disabled />
<el-descriptions :column="2" border class="approval-info">
<el-descriptions-item label="姓名">{{ selectedPerson?.name }}</el-descriptions-item>
<el-descriptions-item label="职位">{{ selectedPerson?.position }}</el-descriptions-item>
<el-descriptions-item label="军衔">{{ selectedPerson?.rank }}</el-descriptions-item>
<el-descriptions-item label="所属单位">{{ selectedPerson?.submittedByUnitName }}</el-descriptions-item>
</el-descriptions>
<el-form-item label="人员等级" style="margin-top: 20px">
<el-select v-model="approvalForm.level" placeholder="请选择人员等级" style="width: 100%">
<el-option label="师级人才" value="Division" />
<el-option label="团级人才" value="Regiment" />
<el-option label="营级人才" value="Battalion" />
<el-option label="连级人才" value="Company" />
</el-select>
</el-form-item>
<el-form-item label="审批意见">
<el-input v-model="approvalForm.comments" type="textarea" rows="3" placeholder="请输入审批意见(可选)" />
@ -93,8 +148,12 @@
</el-form>
<template #footer>
<el-button @click="showApprovalDialog = false">取消</el-button>
<el-button type="danger" :loading="approving" @click="submitApproval(false)">拒绝</el-button>
<el-button type="success" :loading="approving" @click="submitApproval(true)">批准</el-button>
<el-button type="danger" :loading="approving" @click="submitApproval(false)">
<el-icon><Close /></el-icon>
</el-button>
<el-button type="success" :loading="approving" @click="submitApproval(true)">
<el-icon><Check /></el-icon>
</el-button>
</template>
</el-dialog>
</div>
@ -104,7 +163,7 @@
import { ref, reactive, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { Plus, Search, View, Edit, Delete, Check, Close, User } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { personnelApi } from '@/api'
import type { Personnel } from '@/types'
@ -116,6 +175,7 @@ const authStore = useAuthStore()
const personnel = ref<Personnel[]>([])
const loading = ref(false)
const statusFilter = ref('')
const searchKeyword = ref('')
const showApprovalDialog = ref(false)
const approving = ref(false)
const selectedPerson = ref<Personnel | null>(null)
@ -127,7 +187,8 @@ const pagination = reactive({
})
const approvalForm = reactive({
comments: ''
comments: '',
level: 'Company'
})
function getStatusName(status: PersonnelStatus): string {
@ -172,6 +233,11 @@ function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString('zh-CN')
}
function handleSearch() {
pagination.pageNumber = 1
loadPersonnel()
}
async function loadPersonnel() {
loading.value = true
try {
@ -180,7 +246,16 @@ async function loadPersonnel() {
pageSize: pagination.pageSize,
status: statusFilter.value || undefined
})
personnel.value = response.items
//
let items = response.items
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
items = items.filter(p =>
p.name.toLowerCase().includes(keyword) ||
p.position.toLowerCase().includes(keyword)
)
}
personnel.value = items
pagination.total = response.totalCount
} catch {
ElMessage.error('加载人员列表失败')
@ -200,6 +275,7 @@ function handleEdit(person: Personnel) {
function handleApprove(person: Personnel) {
selectedPerson.value = person
approvalForm.comments = ''
approvalForm.level = 'Company'
showApprovalDialog.value = true
}
@ -212,7 +288,8 @@ async function submitApproval(approved: boolean) {
await personnelApi.approve({
personnelId: selectedPerson.value.id,
approved: true,
comments: approvalForm.comments
comments: approvalForm.comments,
level: approvalForm.level
})
ElMessage.success('审批通过')
} else {
@ -234,9 +311,16 @@ async function submitApproval(approved: boolean) {
async function handleDelete(person: Personnel) {
try {
await ElMessageBox.confirm('确定要删除该人员记录吗?', '确认删除', {
type: 'warning'
})
await ElMessageBox.confirm(
`确定要删除人员 "${person.name}" 的记录吗?此操作不可恢复。`,
'确认删除',
{
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger'
}
)
await personnelApi.delete(person.id)
ElMessage.success('删除成功')
await loadPersonnel()
@ -267,19 +351,82 @@ onMounted(() => {
</script>
<style scoped>
.personnel-list {
padding: 0;
}
.list-card {
border-radius: 8px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-title {
display: flex;
align-items: center;
font-size: 18px;
font-weight: 600;
color: #303133;
}
.title-icon {
margin-right: 8px;
color: #409EFF;
}
.header-actions {
display: flex;
align-items: center;
}
.pagination {
.name-cell {
font-weight: 500;
color: #303133;
}
.time-cell {
font-size: 13px;
color: #909399;
}
.no-data {
color: #c0c4cc;
}
.action-buttons {
display: flex;
justify-content: center;
gap: 4px;
}
.action-buttons .el-button {
padding: 4px 8px;
}
.pagination-wrapper {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.approval-info {
margin-bottom: 16px;
}
:deep(.el-table .el-table__row) {
transition: background-color 0.2s;
}
:deep(.el-table .el-table__row:hover) {
background-color: #ecf5ff !important;
}
:deep(.el-card__header) {
padding: 16px 20px;
border-bottom: 1px solid #ebeef5;
}
</style>