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> /// </summary>
[HttpGet] [HttpGet]
public async Task<IActionResult> GetAll() public async Task<IActionResult> GetAll(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 10)
{ {
var unitId = GetCurrentUnitId(); var unitId = GetCurrentUnitId();
var unitLevel = GetCurrentUnitLevel(); var unitLevel = GetCurrentUnitLevel();
@ -37,18 +39,31 @@ public class AllocationsController : BaseApiController
return Unauthorized(new { message = "无法获取用户组织信息" }); return Unauthorized(new { message = "无法获取用户组织信息" });
// 师团级可以看到所有配额,其他级别只能看到分配给自己及下级的配额 // 师团级可以看到所有配额,其他级别只能看到分配给自己及下级的配额
IEnumerable<Models.Entities.MaterialAllocation> allocations; IEnumerable<Models.Entities.MaterialAllocation> allAllocations;
if (unitLevel == OrganizationalLevel.Division) if (unitLevel == OrganizationalLevel.Division)
{ {
allocations = await _allocationService.GetAllAsync(); allAllocations = await _allocationService.GetAllAsync();
} }
else else
{ {
allocations = await _allocationService.GetVisibleToUnitAsync(unitId.Value); allAllocations = await _allocationService.GetVisibleToUnitAsync(unitId.Value);
} }
var response = allocations.Select(MapToResponse); var totalCount = allAllocations.Count();
return Ok(response); 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> /// <summary>
@ -132,7 +147,7 @@ public class AllocationsController : BaseApiController
request.Unit, request.Unit,
request.TotalQuota, request.TotalQuota,
unitId.Value, unitId.Value,
request.Distributions); request.GetDistributionsDictionary());
return CreatedAtAction(nameof(GetById), new { id = allocation.Id }, MapToResponse(allocation)); return CreatedAtAction(nameof(GetById), new { id = allocation.Id }, MapToResponse(allocation));
} }
@ -251,12 +266,14 @@ public class AllocationsController : BaseApiController
[HttpPost("validate")] [HttpPost("validate")]
public async Task<IActionResult> ValidateDistributions([FromBody] CreateAllocationRequest request) public async Task<IActionResult> ValidateDistributions([FromBody] CreateAllocationRequest request)
{ {
var distributions = request.GetDistributionsDictionary();
var isValid = await _allocationService.ValidateDistributionQuotasAsync( var isValid = await _allocationService.ValidateDistributionQuotasAsync(
request.TotalQuota, request.TotalQuota,
request.Distributions); distributions);
var targetUnitsExist = await _allocationService.ValidateTargetUnitsExistAsync( var targetUnitsExist = await _allocationService.ValidateTargetUnitsExistAsync(
request.Distributions.Keys); distributions.Keys);
return Ok(new 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.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using MilitaryTrainingManagement.Data;
using MilitaryTrainingManagement.Models.DTOs; using MilitaryTrainingManagement.Models.DTOs;
using MilitaryTrainingManagement.Models.Entities;
using MilitaryTrainingManagement.Services.Interfaces; using MilitaryTrainingManagement.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace MilitaryTrainingManagement.Controllers; namespace MilitaryTrainingManagement.Controllers;
@ -12,10 +15,17 @@ namespace MilitaryTrainingManagement.Controllers;
public class OrganizationsController : BaseApiController public class OrganizationsController : BaseApiController
{ {
private readonly IOrganizationService _organizationService; 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; _organizationService = organizationService;
_authService = authService;
_context = context;
} }
[HttpGet] [HttpGet]
@ -63,4 +73,39 @@ public class OrganizationsController : BaseApiController
await _organizationService.DeleteAsync(id); await _organizationService.DeleteAsync(id);
return NoContent(); 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> /// </summary>
[HttpGet] [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 unitId = GetCurrentUnitId();
var userLevel = GetCurrentUnitLevel(); var userLevel = GetCurrentUnitLevel();
@ -42,8 +45,28 @@ public class PersonnelController : BaseApiController
return Unauthorized(); return Unauthorized();
} }
var personnel = await _personnelService.GetVisiblePersonnelAsync(unitId.Value, userLevel.Value); var allPersonnel = await _personnelService.GetVisiblePersonnelAsync(unitId.Value, userLevel.Value);
return Ok(personnel);
// 状态筛选
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> /// <summary>
@ -136,8 +159,21 @@ public class PersonnelController : BaseApiController
} }
[HttpPost] [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(); var unitId = GetCurrentUnitId();
if (unitId == null) if (unitId == null)
{ {
@ -162,15 +198,27 @@ public class PersonnelController : BaseApiController
SubmittedByUnitId = unitId.Value SubmittedByUnitId = unitId.Value
}; };
var created = await _personnelService.CreateAsync(personnel); try
{
var created = await _personnelService.SubmitPersonnelAsync(personnel, photo, supportingDocuments);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); 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>
/// 更新人员信息(仅限未审批的人员) /// 更新人员信息(仅限未审批的人员)
/// </summary> /// </summary>
[HttpPut("{id}")] [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); var personnel = await _personnelService.GetByIdAsync(id);
if (personnel == null) if (personnel == null)
@ -206,6 +254,30 @@ public class PersonnelController : BaseApiController
personnel.TrainingParticipation = request.TrainingParticipation; personnel.TrainingParticipation = request.TrainingParticipation;
personnel.Achievements = request.Achievements; 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); var updated = await _personnelService.UpdateAsync(personnel);
return Ok(updated); return Ok(updated);
} }
@ -356,7 +428,7 @@ public class PersonnelController : BaseApiController
var canApprove = await _personnelService.CanApprovePersonnelAsync(userId.Value, id); var canApprove = await _personnelService.CanApprovePersonnelAsync(userId.Value, id);
if (!canApprove) if (!canApprove)
{ {
return Forbid("您没有权限审批此人员"); return StatusCode(403, new { message = "您没有权限审批此人员" });
} }
try try
@ -366,7 +438,7 @@ public class PersonnelController : BaseApiController
} }
catch (ArgumentException ex) 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); var canApprove = await _personnelService.CanApprovePersonnelAsync(userId.Value, id);
if (!canApprove) if (!canApprove)
{ {
return Forbid("您没有权限拒绝此人员"); return StatusCode(403, new { message = "您没有权限拒绝此人员" });
} }
var personnel = await _personnelService.RejectAsync(id, userId.Value); var personnel = await _personnelService.RejectAsync(id, userId.Value);

View File

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

View File

@ -13,3 +13,9 @@ public class UpdateOrganizationRequest
{ {
public string Name { get; set; } = string.Empty; 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 => .AddJsonOptions(options =>
{ {
options.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles; options.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles;
// 将枚举序列化为字符串
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
}); });
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();

View File

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

View File

@ -91,10 +91,11 @@ public class PersonnelService : IPersonnelService
if (approvedByUnit == null) if (approvedByUnit == null)
throw new ArgumentException("审批单位不存在"); throw new ArgumentException("审批单位不存在");
// 验证人员等级与审批单位层级一致 // 验证审批单位层级必须高于或等于人员等级(数值越小层级越高)
var expectedLevel = (PersonnelLevel)(int)approvedByUnit.Level; var unitLevelValue = (int)approvedByUnit.Level;
if (level != expectedLevel) var personnelLevelValue = (int)level;
throw new ArgumentException("人员等级必须与审批单位层级一致"); if (unitLevelValue > personnelLevelValue)
throw new ArgumentException("审批单位层级不足以审批该等级人才");
var previousStatus = personnel.Status; var previousStatus = personnel.Status;
var previousLevel = personnel.ApprovedLevel; var previousLevel = personnel.ApprovedLevel;
@ -528,6 +529,10 @@ public class PersonnelService : IPersonnelService
if (personnel == null || personnel.Status != PersonnelStatus.Pending) if (personnel == null || personnel.Status != PersonnelStatus.Pending)
return false; return false;
// 同一单位可以审批自己提交的人员
if (user.OrganizationalUnitId == personnel.SubmittedByUnitId)
return true;
// 检查用户的组织单位是否是提交单位的上级 // 检查用户的组织单位是否是提交单位的上级
var isParent = await _organizationService.IsParentUnitAsync(user.OrganizationalUnitId, personnel.SubmittedByUnitId); 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.AssemblyCompanyAttribute("MilitaryTrainingManagement")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [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.AssemblyProductAttribute("MilitaryTrainingManagement")]
[assembly: System.Reflection.AssemblyTitleAttribute("MilitaryTrainingManagement")] [assembly: System.Reflection.AssemblyTitleAttribute("MilitaryTrainingManagement")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [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> { async approve(data: PersonnelApprovalRequest): Promise<Personnel> {
const response = await apiClient.post<Personnel>(`/personnel/${data.personnelId}/approve`, { const response = await apiClient.post<Personnel>(`/personnel/${data.personnelId}/approve`, {
approved: data.approved, level: data.level
comments: data.comments
}) })
return response.data return response.data
}, },

View File

@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { authApi } from '@/api/auth' import { authApi } from '@/api/auth'
import type { User, LoginRequest } from '@/types' import type { User, LoginRequest } from '@/types'
import { OrganizationalLevel, OrganizationalLevelValue } from '@/types'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null) const user = ref<User | null>(null)
@ -11,13 +12,17 @@ export const useAuthStore = defineStore('auth', () => {
// 只检查token是否存在 // 只检查token是否存在
const isAuthenticated = computed(() => !!token.value) 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> { async function login(credentials: LoginRequest): Promise<boolean> {
try { try {
@ -80,7 +85,7 @@ export const useAuthStore = defineStore('auth', () => {
} }
function hasPermission(requiredLevel: number): boolean { function hasPermission(requiredLevel: number): boolean {
return organizationalLevel.value <= requiredLevel return organizationalLevelNum.value <= requiredLevel
} }
return { return {
@ -88,7 +93,8 @@ export const useAuthStore = defineStore('auth', () => {
token, token,
initialized, initialized,
isAuthenticated, isAuthenticated,
organizationalLevel, organizationalLevel: computed(() => user.value?.organizationalLevel),
organizationalLevelNum,
canManageSubordinates, canManageSubordinates,
canCreateAllocations, canCreateAllocations,
canApprove, canApprove,

View File

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

View File

@ -1,66 +1,202 @@
<template> <template>
<div class="allocation-list"> <div class="allocation-list">
<el-card> <el-card class="list-card" shadow="hover">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>物资配额列表</span> <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-button v-if="authStore.canCreateAllocations" type="primary" @click="$router.push('/allocations/create')">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
创建配额 创建配额
</el-button> </el-button>
</div> </div>
</div>
</template> </template>
<el-table :data="allocations" style="width: 100%" v-loading="loading"> <el-table
<el-table-column prop="category" label="类别" width="120" /> :data="filteredAllocations"
<el-table-column prop="materialName" label="物资名称" /> style="width: 100%"
<el-table-column prop="unit" label="单位" width="80" /> v-loading="loading"
<el-table-column prop="totalQuota" label="总配额" width="120" /> stripe
<el-table-column prop="createdByUnitName" label="创建单位" width="150" /> highlight-current-row
<el-table-column prop="createdAt" label="创建时间" width="180"> :header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: 'bold' }"
>
<el-table-column prop="category" label="类别" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
{{ formatDate(row.createdAt) }} <el-tag :type="getCategoryTagType(row.category)" effect="plain" size="small">
{{ row.category }}
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200"> <el-table-column prop="materialName" label="物资名称" min-width="150">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" text size="small" @click="handleViewDistribution(row)">查看分配</el-button> <span class="material-name">{{ row.materialName }}</span>
<el-button v-if="authStore.canCreateAllocations" type="warning" text size="small" @click="handleEdit(row)">编辑</el-button> </template>
<el-button v-if="authStore.canCreateAllocations" type="danger" text size="small" @click="handleDelete(row)">删除</el-button> </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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="pagination-wrapper">
<el-pagination <el-pagination
:current-page="pagination.pageNumber" v-model:current-page="pagination.pageNumber"
:page-size="pagination.pageSize" v-model:page-size="pagination.pageSize"
:total="pagination.total" :total="pagination.total"
:page-sizes="[10, 20, 50]" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" layout="total, sizes, prev, pager, next, jumper"
class="pagination" background
@update:current-page="handlePageChange" @current-change="handlePageChange"
@update:page-size="handleSizeChange" @size-change="handleSizeChange"
/> />
</div>
</el-card> </el-card>
<!-- Distribution Dialog --> <!-- Distribution Dialog -->
<el-dialog v-model="showDistributionDialog" title="配额分配详情" width="800px"> <el-dialog
<el-table :data="distributions" style="width: 100%"> v-model="showDistributionDialog"
<el-table-column prop="targetUnitName" label="目标单位" /> :title="`配额分配详情 - ${selectedAllocation?.materialName || ''}`"
<el-table-column prop="unitQuota" label="分配配额" width="120" /> width="900px"
<el-table-column prop="actualCompletion" label="实际完成" width="120"> :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 }"> <template #default="{ row }">
{{ row.actualCompletion ?? '-' }} <span class="unit-name">{{ row.targetUnitName }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="completionRate" label="完成率" width="120"> <el-table-column prop="unitQuota" label="分配配额" width="120" align="right">
<template #default="{ row }"> <template #default="{ row }">
<el-progress :percentage="Math.round(row.completionRate * 100)" :status="getProgressStatus(row.completionRate)" /> <span class="quota-value">{{ formatNumber(row.unitQuota) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="reportedAt" label="上报时间" width="180"> <el-table-column prop="actualCompletion" label="实际完成" width="120" align="right">
<template #default="{ row }"> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -69,10 +205,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' 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 { useAuthStore } from '@/stores/auth'
import { allocationsApi } from '@/api' import { allocationsApi } from '@/api'
import type { MaterialAllocation, AllocationDistribution } from '@/types' import type { MaterialAllocation, AllocationDistribution } from '@/types'
@ -84,6 +220,9 @@ const allocations = ref<MaterialAllocation[]>([])
const distributions = ref<AllocationDistribution[]>([]) const distributions = ref<AllocationDistribution[]>([])
const loading = ref(false) const loading = ref(false)
const showDistributionDialog = ref(false) const showDistributionDialog = ref(false)
const searchKeyword = ref('')
const categoryFilter = ref('')
const selectedAllocation = ref<MaterialAllocation | null>(null)
const pagination = reactive({ const pagination = reactive({
pageNumber: 1, pageNumber: 1,
@ -91,16 +230,56 @@ const pagination = reactive({
total: 0 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 { function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString('zh-CN') 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 { function getProgressStatus(rate: number): string {
if (rate >= 1) return 'success' if (rate >= 1) return 'success'
if (rate >= 0.6) return '' if (rate >= 0.6) return ''
return 'warning' 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) { function handlePageChange(page: number) {
pagination.pageNumber = page pagination.pageNumber = page
loadAllocations() loadAllocations()
@ -129,12 +308,9 @@ async function loadAllocations() {
} }
async function handleViewDistribution(allocation: MaterialAllocation) { async function handleViewDistribution(allocation: MaterialAllocation) {
try { selectedAllocation.value = allocation
distributions.value = await allocationsApi.getDistributions(allocation.id) distributions.value = allocation.distributions || []
showDistributionDialog.value = true showDistributionDialog.value = true
} catch {
ElMessage.error('加载分配详情失败')
}
} }
function handleEdit(allocation: MaterialAllocation) { function handleEdit(allocation: MaterialAllocation) {
@ -143,9 +319,16 @@ function handleEdit(allocation: MaterialAllocation) {
async function handleDelete(allocation: MaterialAllocation) { async function handleDelete(allocation: MaterialAllocation) {
try { try {
await ElMessageBox.confirm('确定要删除该配额吗?', '确认删除', { await ElMessageBox.confirm(
type: 'warning' `确定要删除物资配额 "${allocation.materialName}" 吗?此操作不可恢复。`,
}) '确认删除',
{
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger'
}
)
await allocationsApi.delete(allocation.id) await allocationsApi.delete(allocation.id)
ElMessage.success('删除成功') ElMessage.success('删除成功')
await loadAllocations() await loadAllocations()
@ -160,14 +343,148 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.allocation-list {
padding: 0;
}
.list-card {
border-radius: 8px;
}
.card-header { .card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; 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; margin-top: 20px;
display: flex;
justify-content: flex-end; 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> </style>

View File

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