添加后端基础架构和API功能

- 配置.env文件支持环境变量管理
- 集成MyBatis Plus ORM框架并配置数据库连接
- 实现JWT认证和Spring Security安全配置
- 添加全局异常处理机制和统一响应结果封装
- 配置CORS跨域资源共享支持
- 实现用户认证API包括登录、注册、刷新token功能
- 开发完整的药品管理系统包含分类、药品、库存管理
- 实现出入库管理功能包括供应商、客户管理
- 添加角色权限管理和菜单配置
- 实现仪表盘统计和过期预警功能
- 配置分页查询和数据验证功能
```
This commit is contained in:
2026-07-06 20:04:44 +08:00
parent 15da4fe492
commit 258bae75b2
102 changed files with 3054 additions and 128 deletions

5
.gitignore vendored
View File

@@ -1,3 +1,8 @@
*/.vscode/
*/public/
projectDesigning.md
# 环境变量
.env
.env.local
.env.*.local

19
backend/.env.example Normal file
View File

@@ -0,0 +1,19 @@
# ============================================================
# 后端环境变量IDEA 可通过 EnvFile 插件加载)
# 或通过命令行: export $(cat .env | xargs) && ./mvnw spring-boot:run
# ============================================================
# 数据库
DB_HOST=localhost
DB_PORT=3306
DB_NAME=medicine_manager
DB_USERNAME=root
DB_PASSWORD=your_password_here
# JWT
JWT_SECRET=change_me_to_a_random_base64_string_at_least_256_bits
JWT_EXPIRATION=86400000
JWT_REFRESH_EXPIRATION=604800000
# 服务端口
SERVER_PORT=8080

View File

@@ -69,6 +69,12 @@
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>4.1.0</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>

View File

@@ -1,13 +1,44 @@
package com.kronecker.backend;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@SpringBootApplication
@MapperScan("com.kronecker.backend.mapper")
public class BackendApplication {
public static void main(String[] args) {
loadDotenv();
SpringApplication.run(BackendApplication.class, args);
}
private static void loadDotenv() {
Path dotenv = Paths.get(".env");
if (!Files.exists(dotenv)) dotenv = Paths.get("..", ".env");
if (!Files.exists(dotenv)) return;
try {
for (String line : Files.readAllLines(dotenv)) {
line = line.trim();
if (line.isEmpty() || line.startsWith("#")) continue;
int eq = line.indexOf('=');
if (eq < 0) continue;
String key = line.substring(0, eq).trim();
String value = line.substring(eq + 1).trim();
if ((value.startsWith("\"") && value.endsWith("\""))
|| (value.startsWith("'") && value.endsWith("'"))) {
value = value.substring(1, value.length() - 1);
}
System.setProperty(key, value);
}
System.out.println("已加载 .env 文件: " + dotenv.toAbsolutePath());
} catch (Exception ignored) {
}
}
}

View File

@@ -0,0 +1,24 @@
package com.kronecker.backend.common;
import lombok.Getter;
@Getter
public class BusinessException extends RuntimeException {
private final int code;
public BusinessException(ResultCode code) {
super(code.getMessage());
this.code = code.getCode();
}
public BusinessException(int code, String message) {
super(message);
this.code = code;
}
public BusinessException(String message) {
super(message);
this.code = ResultCode.BAD_REQUEST.getCode();
}
}

View File

@@ -0,0 +1,44 @@
package com.kronecker.backend.common;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
log.warn("业务异常: {}", e.getMessage());
return Result.error(e.getCode(), e.getMessage());
}
@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleBindException(BindException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.reduce((a, b) -> a + "; " + b).orElse("参数校验失败");
log.warn("参数校验失败: {}", msg);
return Result.error(ResultCode.BAD_REQUEST.getCode(), msg);
}
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public Result<Void> handleAccessDeniedException(AccessDeniedException e) {
log.warn("权限不足: {}", e.getMessage());
return Result.error(ResultCode.FORBIDDEN);
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Void> handleException(Exception e) {
log.error("系统异常", e);
return Result.error(ResultCode.INTERNAL_ERROR);
}
}

View File

@@ -0,0 +1,37 @@
package com.kronecker.backend.common;
import lombok.Data;
@Data
public class Result<T> {
private int code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
Result<T> r = new Result<>();
r.setCode(ResultCode.SUCCESS.getCode());
r.setMessage(ResultCode.SUCCESS.getMessage());
r.setData(data);
return r;
}
public static <T> Result<T> success() {
return success(null);
}
public static <T> Result<T> error(ResultCode code) {
Result<T> r = new Result<>();
r.setCode(code.getCode());
r.setMessage(code.getMessage());
return r;
}
public static <T> Result<T> error(int code, String message) {
Result<T> r = new Result<>();
r.setCode(code);
r.setMessage(message);
return r;
}
}

View File

@@ -0,0 +1,19 @@
package com.kronecker.backend.common;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ResultCode {
SUCCESS(200, "操作成功"),
BAD_REQUEST(400, "参数错误"),
UNAUTHORIZED(401, "请先登录"),
FORBIDDEN(403, "权限不足"),
NOT_FOUND(404, "资源不存在"),
INTERNAL_ERROR(500, "服务器内部错误");
private final int code;
private final String message;
}

View File

@@ -0,0 +1,26 @@
package com.kronecker.backend.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.List;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedMethods(List.of("*"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}

View File

@@ -0,0 +1,32 @@
package com.kronecker.backend.config;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
@Configuration
public class MybatisPlusConfig {
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(dataSource);
factory.setPlugins(mybatisPlusInterceptor());
return factory.getObject();
}
@Bean
public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) {
return new SqlSessionTemplate(sqlSessionFactory);
}
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
return new MybatisPlusInterceptor();
}
}

View File

@@ -0,0 +1,48 @@
package com.kronecker.backend.config;
import com.kronecker.backend.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login", "/api/auth/register", "/api/auth/refresh").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}

View File

@@ -0,0 +1,44 @@
package com.kronecker.backend.controller;
import com.kronecker.backend.common.Result;
import com.kronecker.backend.dto.LoginDTO;
import com.kronecker.backend.dto.RegisterDTO;
import com.kronecker.backend.security.JwtTokenProvider;
import com.kronecker.backend.service.AuthService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final JwtTokenProvider jwtTokenProvider;
@PostMapping("/login")
public Result<Map<String, Object>> login(@Valid @RequestBody LoginDTO dto) {
return Result.success(authService.login(dto.getUsername(), dto.getPassword()));
}
@PostMapping("/register")
public Result<Void> register(@Valid @RequestBody RegisterDTO dto) {
authService.register(dto);
return Result.success();
}
@PostMapping("/refresh")
public Result<String> refresh(@RequestBody Map<String, String> body) {
return Result.success(authService.refreshToken(body.get("refreshToken")));
}
@GetMapping("/userinfo")
public Result<Map<String, Object>> userInfo(@AuthenticationPrincipal UserDetails user) {
return Result.success(authService.getUserInfo(Long.parseLong(user.getUsername())));
}
}

View File

@@ -0,0 +1,49 @@
package com.kronecker.backend.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.IService;
import com.kronecker.backend.common.Result;
import com.kronecker.backend.entity.MedicineCategory;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/categories")
@RequiredArgsConstructor
public class CategoryController {
private final IService<MedicineCategory> categoryService;
@GetMapping
public Result<List<MedicineCategory>> list() {
return Result.success(categoryService.list(
new LambdaQueryWrapper<MedicineCategory>().orderByAsc(MedicineCategory::getSortOrder)));
}
@GetMapping("/tree")
public Result<List<MedicineCategory>> tree() {
return Result.success(categoryService.list(
new LambdaQueryWrapper<MedicineCategory>().orderByAsc(MedicineCategory::getSortOrder)));
}
@PostMapping
public Result<Void> create(@RequestBody MedicineCategory category) {
categoryService.save(category);
return Result.success();
}
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @RequestBody MedicineCategory category) {
category.setId(id);
categoryService.updateById(category);
return Result.success();
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
categoryService.removeById(id);
return Result.success();
}
}

View File

@@ -0,0 +1,54 @@
package com.kronecker.backend.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.kronecker.backend.common.Result;
import com.kronecker.backend.entity.Customer;
import lombok.RequiredArgsConstructor;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/customers")
@RequiredArgsConstructor
public class CustomerController {
private final IService<Customer> customerService;
@GetMapping
public Result<Page<Customer>> list(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String name) {
var wrapper = new LambdaQueryWrapper<Customer>()
.like(StringUtils.hasText(name), Customer::getName, name)
.orderByDesc(Customer::getCreateTime);
return Result.success(customerService.page(new Page<>(page, size), wrapper));
}
@GetMapping("/all")
public Result<List<Customer>> all() {
return Result.success(customerService.list());
}
@PostMapping
public Result<Void> create(@RequestBody Customer customer) {
customerService.save(customer);
return Result.success();
}
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @RequestBody Customer customer) {
customer.setId(id);
customerService.updateById(customer);
return Result.success();
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
customerService.removeById(id);
return Result.success();
}
}

View File

@@ -0,0 +1,31 @@
package com.kronecker.backend.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.kronecker.backend.common.Result;
import com.kronecker.backend.entity.Stock;
import com.kronecker.backend.entity.StockInRecord;
import com.kronecker.backend.entity.StockOutRecord;
import com.kronecker.backend.service.DashboardService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/dashboard")
@RequiredArgsConstructor
public class DashboardController {
private final DashboardService dashboardService;
@GetMapping("/statistics")
public Result<Map<String, Object>> statistics() {
return Result.success(dashboardService.getStatistics());
}
@GetMapping("/expiry-warning")
public Result<Page<Stock>> expiryWarning(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size) {
return Result.success(dashboardService.getExpiryWarning(new Page<>(page, size)));
}
}

View File

@@ -0,0 +1,48 @@
package com.kronecker.backend.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.kronecker.backend.common.Result;
import com.kronecker.backend.entity.Medicine;
import com.kronecker.backend.service.MedicineService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/medicines")
@RequiredArgsConstructor
public class MedicineController {
private final MedicineService medicineService;
@GetMapping
public Result<Page<Medicine>> list(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String name,
@RequestParam(required = false) Long categoryId) {
return Result.success(medicineService.pageQuery(new Page<>(page, size), name, categoryId));
}
@GetMapping("/{id}")
public Result<Medicine> getById(@PathVariable Long id) {
return Result.success(medicineService.getById(id));
}
@PostMapping
public Result<Void> create(@RequestBody Medicine medicine) {
medicineService.save(medicine);
return Result.success();
}
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @RequestBody Medicine medicine) {
medicine.setId(id);
medicineService.updateById(medicine);
return Result.success();
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
medicineService.removeById(id);
return Result.success();
}
}

View File

@@ -0,0 +1,48 @@
package com.kronecker.backend.controller;
import com.kronecker.backend.common.Result;
import com.kronecker.backend.entity.Menu;
import com.kronecker.backend.service.MenuService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/menus")
@RequiredArgsConstructor
public class MenuController {
private final MenuService menuService;
@GetMapping("/tree")
public Result<List<Menu>> tree(@AuthenticationPrincipal UserDetails user) {
return Result.success(menuService.getMenusByUserId(Long.parseLong(user.getUsername())));
}
@GetMapping
public Result<List<Menu>> list() {
return Result.success(menuService.getTree());
}
@PostMapping
public Result<Void> create(@RequestBody Menu menu) {
menuService.save(menu);
return Result.success();
}
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @RequestBody Menu menu) {
menu.setId(id);
menuService.updateById(menu);
return Result.success();
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
menuService.removeById(id);
return Result.success();
}
}

View File

@@ -0,0 +1,70 @@
package com.kronecker.backend.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.IService;
import com.kronecker.backend.common.Result;
import com.kronecker.backend.entity.Role;
import com.kronecker.backend.entity.RoleMenu;
import com.kronecker.backend.mapper.RoleMenuMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/roles")
@RequiredArgsConstructor
public class RoleController {
private final IService<Role> roleService;
private final RoleMenuMapper roleMenuMapper;
@GetMapping
public Result<List<Role>> list() {
return Result.success(roleService.list());
}
@GetMapping("/all")
public Result<List<Role>> all() {
return Result.success(roleService.list(
new LambdaQueryWrapper<Role>().eq(Role::getStatus, 1)));
}
@PostMapping
public Result<Void> create(@RequestBody Role role) {
roleService.save(role);
return Result.success();
}
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @RequestBody Role role) {
role.setId(id);
roleService.updateById(role);
return Result.success();
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
roleService.removeById(id);
return Result.success();
}
@PutMapping("/{id}/menus")
@Transactional
public Result<Void> assignMenus(@PathVariable Long id, @RequestBody Map<String, List<Long>> body) {
List<Long> menuIds = body.get("menuIds");
roleMenuMapper.delete(new LambdaQueryWrapper<RoleMenu>().eq(RoleMenu::getRoleId, id));
if (menuIds != null && !menuIds.isEmpty()) {
for (Long menuId : menuIds) {
RoleMenu rm = new RoleMenu();
rm.setRoleId(id);
rm.setMenuId(menuId);
roleMenuMapper.insert(rm);
}
}
return Result.success();
}
}

View File

@@ -0,0 +1,71 @@
package com.kronecker.backend.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.kronecker.backend.common.Result;
import com.kronecker.backend.entity.Medicine;
import com.kronecker.backend.entity.Stock;
import com.kronecker.backend.entity.Warehouse;
import lombok.RequiredArgsConstructor;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/stocks")
@RequiredArgsConstructor
public class StockController {
private final IService<Stock> stockService;
private final IService<Medicine> medicineService;
private final IService<Warehouse> warehouseService;
@GetMapping
public Result<Page<Stock>> list(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String medicineName,
@RequestParam(required = false) String batchNo) {
var wrapper = new LambdaQueryWrapper<Stock>()
.like(StringUtils.hasText(batchNo), Stock::getBatchNo, batchNo)
.orderByAsc(Stock::getExpiryDate);
// 按药品名搜索需先查 medicine 表
if (StringUtils.hasText(medicineName)) {
var medIds = medicineService.list(
new LambdaQueryWrapper<Medicine>().like(Medicine::getName, medicineName)
).stream().map(Medicine::getId).toList();
if (medIds.isEmpty()) return Result.success(new Page<>());
wrapper.in(Stock::getMedicineId, medIds);
}
Page<Stock> result = stockService.page(new Page<>(page, size), wrapper);
populateNames(result.getRecords());
return Result.success(result);
}
@GetMapping("/warning")
public Result<Page<Stock>> warning(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size) {
var wrapper = new LambdaQueryWrapper<Stock>()
.gt(Stock::getExpiryDate, java.time.LocalDate.now())
.orderByAsc(Stock::getExpiryDate);
Page<Stock> result = stockService.page(new Page<>(page, size), wrapper);
populateNames(result.getRecords());
return Result.success(result);
}
private void populateNames(java.util.List<Stock> stocks) {
if (stocks.isEmpty()) return;
var medIds = stocks.stream().map(Stock::getMedicineId).collect(Collectors.toSet());
var whIds = stocks.stream().map(Stock::getWarehouseId).collect(Collectors.toSet());
Map<Long, String> medMap = medicineService.listByIds(medIds).stream()
.collect(Collectors.toMap(Medicine::getId, Medicine::getName));
Map<Long, String> whMap = warehouseService.listByIds(whIds).stream()
.collect(Collectors.toMap(Warehouse::getId, Warehouse::getName));
stocks.forEach(s -> {
s.setMedicineName(medMap.getOrDefault(s.getMedicineId(), ""));
s.setWarehouseName(whMap.getOrDefault(s.getWarehouseId(), ""));
});
}
}

View File

@@ -0,0 +1,71 @@
package com.kronecker.backend.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.kronecker.backend.common.Result;
import com.kronecker.backend.dto.StockInCreateDTO;
import com.kronecker.backend.entity.*;
import com.kronecker.backend.service.StockInService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/stock-in")
@RequiredArgsConstructor
public class StockInController {
private final IService<StockInRecord> recordService;
private final IService<StockInDetail> detailService;
private final IService<Supplier> supplierService;
private final StockInService stockInService;
@GetMapping
public Result<Page<StockInRecord>> list(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size) {
Page<StockInRecord> result = recordService.page(new Page<>(page, size),
new LambdaQueryWrapper<StockInRecord>().orderByDesc(StockInRecord::getCreateTime));
populateSupplierNames(result.getRecords());
return Result.success(result);
}
@GetMapping("/{id}")
public Result<StockInRecord> getById(@PathVariable Long id) {
StockInRecord record = recordService.getById(id);
if (record != null) {
record.setDetails(detailService.list(
new LambdaQueryWrapper<StockInDetail>().eq(StockInDetail::getRecordId, id)));
var supp = supplierService.getById(record.getSupplierId());
if (supp != null) record.setSupplierName(supp.getName());
}
return Result.success(record);
}
@PostMapping
public Result<Long> create(@Valid @RequestBody StockInCreateDTO dto,
@AuthenticationPrincipal UserDetails user) {
return Result.success(stockInService.create(dto, Long.parseLong(user.getUsername())));
}
@PutMapping("/{id}/status")
public Result<Void> audit(@PathVariable Long id, @RequestBody Map<String, Integer> body) {
if (body.get("status") == 1) {
stockInService.audit(id);
}
return Result.success();
}
private void populateSupplierNames(java.util.List<StockInRecord> records) {
if (records.isEmpty()) return;
var suppIds = records.stream().map(StockInRecord::getSupplierId).collect(Collectors.toSet());
Map<Long, String> suppMap = supplierService.listByIds(suppIds).stream()
.collect(Collectors.toMap(Supplier::getId, Supplier::getName));
records.forEach(r -> r.setSupplierName(suppMap.getOrDefault(r.getSupplierId(), "")));
}
}

View File

@@ -0,0 +1,78 @@
package com.kronecker.backend.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.kronecker.backend.common.Result;
import com.kronecker.backend.dto.StockOutCreateDTO;
import com.kronecker.backend.entity.*;
import com.kronecker.backend.service.StockOutService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/stock-out")
@RequiredArgsConstructor
public class StockOutController {
private final IService<StockOutRecord> recordService;
private final IService<StockOutDetail> detailService;
private final IService<Customer> customerService;
private final StockOutService stockOutService;
@GetMapping
public Result<Page<StockOutRecord>> list(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size) {
Page<StockOutRecord> result = recordService.page(new Page<>(page, size),
new LambdaQueryWrapper<StockOutRecord>().orderByDesc(StockOutRecord::getCreateTime));
populateCustomerNames(result.getRecords());
return Result.success(result);
}
@GetMapping("/{id}")
public Result<StockOutRecord> getById(@PathVariable Long id) {
StockOutRecord record = recordService.getById(id);
if (record != null) {
record.setDetails(detailService.list(
new LambdaQueryWrapper<StockOutDetail>().eq(StockOutDetail::getRecordId, id)));
if (record.getCustomerId() != null) {
var cust = customerService.getById(record.getCustomerId());
if (cust != null) record.setCustomerName(cust.getName());
}
}
return Result.success(record);
}
@PostMapping
public Result<Long> create(@Valid @RequestBody StockOutCreateDTO dto,
@AuthenticationPrincipal UserDetails user) {
return Result.success(stockOutService.create(dto, Long.parseLong(user.getUsername())));
}
@PutMapping("/{id}/status")
public Result<Void> audit(@PathVariable Long id, @RequestBody Map<String, Integer> body) {
if (body.get("status") == 1) {
stockOutService.audit(id);
}
return Result.success();
}
private void populateCustomerNames(java.util.List<StockOutRecord> records) {
if (records.isEmpty()) return;
var custIds = records.stream().map(StockOutRecord::getCustomerId)
.filter(id -> id != null).collect(Collectors.toSet());
if (custIds.isEmpty()) return;
Map<Long, String> custMap = customerService.listByIds(custIds).stream()
.collect(Collectors.toMap(Customer::getId, Customer::getName));
records.forEach(r -> {
if (r.getCustomerId() != null)
r.setCustomerName(custMap.getOrDefault(r.getCustomerId(), ""));
});
}
}

View File

@@ -0,0 +1,55 @@
package com.kronecker.backend.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.kronecker.backend.common.Result;
import com.kronecker.backend.entity.Supplier;
import lombok.RequiredArgsConstructor;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/suppliers")
@RequiredArgsConstructor
public class SupplierController {
private final IService<Supplier> supplierService;
@GetMapping
public Result<Page<Supplier>> list(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String name) {
var wrapper = new LambdaQueryWrapper<Supplier>()
.like(StringUtils.hasText(name), Supplier::getName, name)
.orderByDesc(Supplier::getCreateTime);
return Result.success(supplierService.page(new Page<>(page, size), wrapper));
}
@GetMapping("/all")
public Result<List<Supplier>> all() {
return Result.success(supplierService.list(
new LambdaQueryWrapper<Supplier>().eq(Supplier::getStatus, 1)));
}
@PostMapping
public Result<Void> create(@RequestBody Supplier supplier) {
supplierService.save(supplier);
return Result.success();
}
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @RequestBody Supplier supplier) {
supplier.setId(id);
supplierService.updateById(supplier);
return Result.success();
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
supplierService.removeById(id);
return Result.success();
}
}

View File

@@ -0,0 +1,83 @@
package com.kronecker.backend.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.kronecker.backend.common.BusinessException;
import com.kronecker.backend.common.Result;
import com.kronecker.backend.entity.User;
import com.kronecker.backend.entity.UserRole;
import com.kronecker.backend.mapper.UserRoleMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final IService<User> userService;
private final PasswordEncoder passwordEncoder;
private final UserRoleMapper userRoleMapper;
@GetMapping
public Result<Page<User>> list(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String username) {
var wrapper = new LambdaQueryWrapper<User>()
.like(StringUtils.hasText(username), User::getUsername, username)
.orderByDesc(User::getCreateTime)
.select(User.class, f -> !f.getColumn().equals("password"));
return Result.success(userService.page(new Page<>(page, size), wrapper));
}
@PostMapping
public Result<Void> create(@RequestBody User user) {
if (!StringUtils.hasText(user.getPassword())) {
throw new BusinessException("密码不能为空");
}
user.setPassword(passwordEncoder.encode(user.getPassword()));
userService.save(user);
return Result.success();
}
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @RequestBody User user) {
user.setId(id);
if (StringUtils.hasText(user.getPassword())) {
user.setPassword(passwordEncoder.encode(user.getPassword()));
} else {
user.setPassword(null); // 不更新密码
}
userService.updateById(user);
return Result.success();
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
userService.removeById(id);
return Result.success();
}
@PutMapping("/{id}/roles")
@Transactional
public Result<Void> assignRoles(@PathVariable Long id, @RequestBody Map<String, List<Long>> body) {
List<Long> roleIds = body.get("roleIds");
userRoleMapper.delete(new LambdaQueryWrapper<UserRole>().eq(UserRole::getUserId, id));
if (roleIds != null) {
for (Long roleId : roleIds) {
UserRole ur = new UserRole();
ur.setUserId(id);
ur.setRoleId(roleId);
userRoleMapper.insert(ur);
}
}
return Result.success();
}
}

View File

@@ -0,0 +1,12 @@
package com.kronecker.backend.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class LoginDTO {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}

View File

@@ -0,0 +1,9 @@
package com.kronecker.backend.dto;
import lombok.Data;
@Data
public class PageQueryDTO {
private Integer page = 1;
private Integer size = 10;
}

View File

@@ -0,0 +1,20 @@
package com.kronecker.backend.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class RegisterDTO {
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 32, message = "用户名长度3-32位")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 32, message = "密码长度6-32位")
private String password;
private String realName;
private String phone;
private String email;
}

View File

@@ -0,0 +1,29 @@
package com.kronecker.backend.dto;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@Data
public class StockInCreateDTO {
@NotNull private Long supplierId;
@NotNull private Long warehouseId;
@NotNull private LocalDate inDate;
private String remark;
@NotNull private BigDecimal totalAmount;
@NotEmpty private List<Detail> details;
@Data
public static class Detail {
@NotNull private Long medicineId;
private String batchNo;
@NotNull private Integer quantity;
@NotNull private BigDecimal costPrice;
@NotNull private BigDecimal totalPrice;
private LocalDate productionDate;
@NotNull private LocalDate expiryDate;
}
}

View File

@@ -0,0 +1,28 @@
package com.kronecker.backend.dto;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@Data
public class StockOutCreateDTO {
private Long customerId;
@NotNull private Long warehouseId;
@NotNull private LocalDate outDate;
private Integer outType = 1;
private String remark;
@NotNull private BigDecimal totalAmount;
@NotEmpty private List<Detail> details;
@Data
public static class Detail {
@NotNull private Long medicineId;
private String batchNo;
@NotNull private Integer quantity;
@NotNull private BigDecimal unitPrice;
@NotNull private BigDecimal totalPrice;
}
}

View File

@@ -0,0 +1,25 @@
package com.kronecker.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("customer")
public class Customer {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private String contactPerson;
private String phone;
private String email;
private String address;
private String type;
private String remark;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer isDeleted;
}

View File

@@ -0,0 +1,34 @@
package com.kronecker.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("medicine")
public class Medicine {
@TableId(type = IdType.AUTO)
private Long id;
private Long categoryId;
private String name;
private String genericName;
private String specification;
private String manufacturer;
private String approvalNumber;
private String barcode;
private String dosageForm;
private String unit;
private BigDecimal retailPrice;
private BigDecimal wholesalePrice;
private String storageCondition;
private Integer shelfLife;
private Integer warningStock;
private String description;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer isDeleted;
}

View File

@@ -0,0 +1,23 @@
package com.kronecker.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("medicine_category")
public class MedicineCategory {
@TableId(type = IdType.AUTO)
private Long id;
private Long parentId;
private String name;
private String code;
private Integer sortOrder;
private String description;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer isDeleted;
}

View File

@@ -0,0 +1,31 @@
package com.kronecker.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
@TableName("menu")
public class Menu {
@TableId(type = IdType.AUTO)
private Long id;
private Long parentId;
private String name;
private Integer type;
private String path;
private String component;
private String icon;
private String permissionCode;
private Integer sortOrder;
private Integer visible;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer isDeleted;
@TableField(exist = false)
private List<Menu> children;
}

View File

@@ -0,0 +1,22 @@
package com.kronecker.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("role")
public class Role {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private String code;
private String description;
private Integer status;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer isDeleted;
}

View File

@@ -0,0 +1,13 @@
package com.kronecker.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
@Data
@TableName("role_menu")
public class RoleMenu {
@TableId(type = IdType.AUTO)
private Long id;
private Long roleId;
private Long menuId;
}

View File

@@ -0,0 +1,32 @@
package com.kronecker.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("stock")
public class Stock {
@TableId(type = IdType.AUTO)
private Long id;
private Long medicineId;
private Long warehouseId;
private String batchNo;
private Integer quantity;
private BigDecimal costPrice;
private LocalDate productionDate;
private LocalDate expiryDate;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer isDeleted;
@TableField(exist = false)
private String medicineName;
@TableField(exist = false)
private String warehouseName;
}

View File

@@ -0,0 +1,31 @@
package com.kronecker.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("stock_in_detail")
public class StockInDetail {
@TableId(type = IdType.AUTO)
private Long id;
private Long recordId;
private Long medicineId;
private String batchNo;
private Integer quantity;
private BigDecimal costPrice;
private BigDecimal totalPrice;
private LocalDate productionDate;
private LocalDate expiryDate;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer isDeleted;
@TableField(exist = false)
private String medicineName;
}

View File

@@ -0,0 +1,34 @@
package com.kronecker.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Data
@TableName("stock_in_record")
public class StockInRecord {
@TableId(type = IdType.AUTO)
private Long id;
private String recordNo;
private Long supplierId;
private Long warehouseId;
private BigDecimal totalAmount;
private Long operatorId;
private LocalDate inDate;
private Integer status;
private String remark;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer isDeleted;
@TableField(exist = false)
private String supplierName;
@TableField(exist = false)
private List<StockInDetail> details;
}

View File

@@ -0,0 +1,28 @@
package com.kronecker.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("stock_out_detail")
public class StockOutDetail {
@TableId(type = IdType.AUTO)
private Long id;
private Long recordId;
private Long medicineId;
private String batchNo;
private Integer quantity;
private BigDecimal unitPrice;
private BigDecimal totalPrice;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer isDeleted;
@TableField(exist = false)
private String medicineName;
}

View File

@@ -0,0 +1,35 @@
package com.kronecker.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Data
@TableName("stock_out_record")
public class StockOutRecord {
@TableId(type = IdType.AUTO)
private Long id;
private String recordNo;
private Long customerId;
private Long warehouseId;
private BigDecimal totalAmount;
private Long operatorId;
private LocalDate outDate;
private Integer outType;
private Integer status;
private String remark;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer isDeleted;
@TableField(exist = false)
private String customerName;
@TableField(exist = false)
private List<StockOutDetail> details;
}

View File

@@ -0,0 +1,29 @@
package com.kronecker.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("supplier")
public class Supplier {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private String contactPerson;
private String phone;
private String email;
private String address;
private String bankAccount;
private String bankName;
private String taxId;
private String licenseNumber;
private Integer status;
private String remark;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer isDeleted;
}

View File

@@ -0,0 +1,26 @@
package com.kronecker.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("user")
public class User {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String password;
private String realName;
private String phone;
private String email;
private String avatar;
private Integer status;
private LocalDateTime lastLoginTime;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer isDeleted;
}

View File

@@ -0,0 +1,13 @@
package com.kronecker.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
@Data
@TableName("user_role")
public class UserRole {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long roleId;
}

View File

@@ -0,0 +1,24 @@
package com.kronecker.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("warehouse")
public class Warehouse {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private String code;
private String location;
private String manager;
private Integer status;
private String remark;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer isDeleted;
}

View File

@@ -0,0 +1,8 @@
package com.kronecker.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kronecker.backend.entity.Customer;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CustomerMapper extends BaseMapper<Customer> {}

View File

@@ -0,0 +1,8 @@
package com.kronecker.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kronecker.backend.entity.MedicineCategory;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MedicineCategoryMapper extends BaseMapper<MedicineCategory> {}

View File

@@ -0,0 +1,8 @@
package com.kronecker.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kronecker.backend.entity.Medicine;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MedicineMapper extends BaseMapper<Medicine> {}

View File

@@ -0,0 +1,21 @@
package com.kronecker.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kronecker.backend.entity.Menu;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface MenuMapper extends BaseMapper<Menu> {
@Select("<script>" +
"SELECT DISTINCT m.permission_code FROM menu m " +
"INNER JOIN role_menu rm ON m.id = rm.menu_id " +
"WHERE rm.role_id IN <foreach item='id' collection='roleIds' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND m.permission_code IS NOT NULL AND m.permission_code != ''" +
"</script>")
List<String> selectPermsByRoleIds(@Param("roleIds") List<Long> roleIds);
}

View File

@@ -0,0 +1,8 @@
package com.kronecker.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kronecker.backend.entity.Role;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RoleMapper extends BaseMapper<Role> {}

View File

@@ -0,0 +1,8 @@
package com.kronecker.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kronecker.backend.entity.RoleMenu;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RoleMenuMapper extends BaseMapper<RoleMenu> {}

View File

@@ -0,0 +1,8 @@
package com.kronecker.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kronecker.backend.entity.StockInDetail;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface StockInDetailMapper extends BaseMapper<StockInDetail> {}

View File

@@ -0,0 +1,8 @@
package com.kronecker.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kronecker.backend.entity.StockInRecord;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface StockInRecordMapper extends BaseMapper<StockInRecord> {}

View File

@@ -0,0 +1,8 @@
package com.kronecker.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kronecker.backend.entity.Stock;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface StockMapper extends BaseMapper<Stock> {}

View File

@@ -0,0 +1,8 @@
package com.kronecker.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kronecker.backend.entity.StockOutDetail;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface StockOutDetailMapper extends BaseMapper<StockOutDetail> {}

View File

@@ -0,0 +1,8 @@
package com.kronecker.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kronecker.backend.entity.StockOutRecord;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface StockOutRecordMapper extends BaseMapper<StockOutRecord> {}

View File

@@ -0,0 +1,8 @@
package com.kronecker.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kronecker.backend.entity.Supplier;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SupplierMapper extends BaseMapper<Supplier> {}

View File

@@ -0,0 +1,8 @@
package com.kronecker.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kronecker.backend.entity.User;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserMapper extends BaseMapper<User> {}

View File

@@ -0,0 +1,8 @@
package com.kronecker.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kronecker.backend.entity.UserRole;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserRoleMapper extends BaseMapper<UserRole> {}

View File

@@ -0,0 +1,8 @@
package com.kronecker.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kronecker.backend.entity.Warehouse;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface WarehouseMapper extends BaseMapper<Warehouse> {}

View File

@@ -0,0 +1,48 @@
package com.kronecker.backend.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
Long userId = jwtTokenProvider.getUserId(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(userId.toString());
var auth = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}

View File

@@ -0,0 +1,67 @@
package com.kronecker.backend.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Base64;
import java.util.Date;
import java.util.Map;
@Component
public class JwtTokenProvider {
private final SecretKey key;
private final long expiration;
private final long refreshExpiration;
public JwtTokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration}") long expiration,
@Value("${jwt.refresh-expiration}") long refreshExpiration) {
this.key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secret));
this.expiration = expiration;
this.refreshExpiration = refreshExpiration;
}
public String generateToken(Long userId, String username) {
Date now = new Date();
return Jwts.builder()
.subject(String.valueOf(userId))
.claim("username", username)
.issuedAt(now)
.expiration(new Date(now.getTime() + expiration))
.signWith(key)
.compact();
}
public String generateRefreshToken(Long userId) {
Date now = new Date();
return Jwts.builder()
.subject(String.valueOf(userId))
.issuedAt(now)
.expiration(new Date(now.getTime() + refreshExpiration))
.signWith(key)
.compact();
}
public Long getUserId(String token) {
return Long.parseLong(parseClaims(token).getSubject());
}
public boolean validateToken(String token) {
try {
parseClaims(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
private Claims parseClaims(String token) {
return Jwts.parser().verifyWith(key).build()
.parseSignedClaims(token).getPayload();
}
}

View File

@@ -0,0 +1,54 @@
package com.kronecker.backend.security;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.kronecker.backend.entity.Menu;
import com.kronecker.backend.entity.User;
import com.kronecker.backend.entity.UserRole;
import com.kronecker.backend.mapper.MenuMapper;
import com.kronecker.backend.mapper.UserMapper;
import com.kronecker.backend.mapper.UserRoleMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserMapper userMapper;
private final UserRoleMapper userRoleMapper;
private final MenuMapper menuMapper;
@Override
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
User user = userMapper.selectById(Long.parseLong(userId));
if (user == null || user.getStatus() == 0) {
throw new UsernameNotFoundException("用户不存在或已停用");
}
// 查角色 → 查权限
List<Long> roleIds = userRoleMapper.selectList(
new LambdaQueryWrapper<UserRole>().eq(UserRole::getUserId, user.getId())
).stream().map(UserRole::getRoleId).toList();
List<String> permissions = new ArrayList<>();
if (!roleIds.isEmpty()) {
permissions = menuMapper.selectPermsByRoleIds(roleIds);
}
List<SimpleGrantedAuthority> authorities = permissions.stream()
.filter(p -> p != null && !p.isEmpty())
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(
user.getId().toString(), user.getPassword(), authorities);
}
}

View File

@@ -0,0 +1,12 @@
package com.kronecker.backend.service;
import com.kronecker.backend.dto.RegisterDTO;
import java.util.Map;
public interface AuthService {
Map<String, Object> login(String username, String password);
String refreshToken(String refreshToken);
Map<String, Object> getUserInfo(Long userId);
void register(RegisterDTO dto);
}

View File

@@ -0,0 +1,14 @@
package com.kronecker.backend.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.kronecker.backend.entity.Stock;
import com.kronecker.backend.entity.StockInRecord;
import com.kronecker.backend.entity.StockOutRecord;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
public interface DashboardService {
java.util.Map<String, Object> getStatistics();
Page<Stock> getExpiryWarning(Page<Stock> page);
Page<StockInRecord> getRecentStockIn(Page<StockInRecord> page);
Page<StockOutRecord> getRecentStockOut(Page<StockOutRecord> page);
}

View File

@@ -0,0 +1,9 @@
package com.kronecker.backend.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.kronecker.backend.entity.Medicine;
public interface MedicineService extends IService<Medicine> {
Page<Medicine> pageQuery(Page<Medicine> page, String name, Long categoryId);
}

View File

@@ -0,0 +1,10 @@
package com.kronecker.backend.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.kronecker.backend.entity.Menu;
import java.util.List;
public interface MenuService extends IService<Menu> {
List<Menu> getTree();
List<Menu> getMenusByUserId(Long userId);
}

View File

@@ -0,0 +1,8 @@
package com.kronecker.backend.service;
import com.kronecker.backend.dto.StockInCreateDTO;
public interface StockInService {
Long create(StockInCreateDTO dto, Long operatorId);
void audit(Long id);
}

View File

@@ -0,0 +1,8 @@
package com.kronecker.backend.service;
import com.kronecker.backend.dto.StockOutCreateDTO;
public interface StockOutService {
Long create(StockOutCreateDTO dto, Long operatorId);
void audit(Long id);
}

View File

@@ -0,0 +1,99 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.kronecker.backend.common.BusinessException;
import com.kronecker.backend.common.ResultCode;
import com.kronecker.backend.dto.RegisterDTO;
import com.kronecker.backend.entity.*;
import com.kronecker.backend.mapper.*;
import com.kronecker.backend.security.JwtTokenProvider;
import com.kronecker.backend.service.AuthService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
private final UserMapper userMapper;
private final MenuMapper menuMapper;
private final UserRoleMapper userRoleMapper;
private final JwtTokenProvider jwtTokenProvider;
private final PasswordEncoder passwordEncoder;
@Override
public Map<String, Object> login(String username, String password) {
User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUsername, username));
if (user == null || !passwordEncoder.matches(password, user.getPassword())) {
throw new BusinessException(ResultCode.UNAUTHORIZED.getCode(), "用户名或密码错误");
}
if (user.getStatus() == 0) {
throw new BusinessException("账号已被停用");
}
user.setLastLoginTime(LocalDateTime.now());
userMapper.updateById(user);
String token = jwtTokenProvider.generateToken(user.getId(), user.getUsername());
String refreshToken = jwtTokenProvider.generateRefreshToken(user.getId());
Map<String, Object> data = new HashMap<>();
data.put("token", token);
data.put("refreshToken", refreshToken);
data.put("userInfo", buildUserInfo(user));
return data;
}
@Override
public String refreshToken(String refreshToken) {
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new BusinessException(ResultCode.UNAUTHORIZED.getCode(), "Token已过期请重新登录");
}
Long userId = jwtTokenProvider.getUserId(refreshToken);
User user = userMapper.selectById(userId);
return jwtTokenProvider.generateToken(user.getId(), user.getUsername());
}
@Override
public Map<String, Object> getUserInfo(Long userId) {
User user = userMapper.selectById(userId);
return buildUserInfo(user);
}
@Override
@Transactional
public void register(RegisterDTO dto) {
if (userMapper.selectCount(new LambdaQueryWrapper<User>().eq(User::getUsername, dto.getUsername())) > 0) {
throw new BusinessException("用户名已存在");
}
User user = new User();
user.setUsername(dto.getUsername());
user.setPassword(passwordEncoder.encode(dto.getPassword()));
user.setRealName(dto.getRealName() != null ? dto.getRealName() : dto.getUsername());
user.setPhone(dto.getPhone());
user.setEmail(dto.getEmail());
user.setStatus(1);
userMapper.insert(user);
// 分配默认角色普通员工role code = staff, id = 3
UserRole ur = new UserRole();
ur.setUserId(user.getId());
ur.setRoleId(3L);
userRoleMapper.insert(ur);
}
private Map<String, Object> buildUserInfo(User user) {
Map<String, Object> info = new HashMap<>();
info.put("id", user.getId());
info.put("username", user.getUsername());
info.put("realName", user.getRealName());
info.put("phone", user.getPhone());
info.put("email", user.getEmail());
info.put("avatar", user.getAvatar());
return info;
}
}

View File

@@ -0,0 +1,9 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kronecker.backend.entity.MedicineCategory;
import com.kronecker.backend.mapper.MedicineCategoryMapper;
import org.springframework.stereotype.Service;
@Service
public class CategoryServiceImpl extends ServiceImpl<MedicineCategoryMapper, MedicineCategory> {}

View File

@@ -0,0 +1,9 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kronecker.backend.entity.Customer;
import com.kronecker.backend.mapper.CustomerMapper;
import org.springframework.stereotype.Service;
@Service
public class CustomerServiceImpl extends ServiceImpl<CustomerMapper, Customer> {}

View File

@@ -0,0 +1,89 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.kronecker.backend.entity.*;
import com.kronecker.backend.mapper.*;
import com.kronecker.backend.service.DashboardService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.*;
@Service
@RequiredArgsConstructor
public class DashboardServiceImpl implements DashboardService {
private final MedicineMapper medicineMapper;
private final StockMapper stockMapper;
private final StockInRecordMapper stockInRecordMapper;
private final StockOutRecordMapper stockOutRecordMapper;
private final SupplierMapper supplierMapper;
private final CustomerMapper customerMapper;
@Override
public Map<String, Object> getStatistics() {
Map<String, Object> stats = new HashMap<>();
stats.put("medicineCount", medicineMapper.selectCount(null));
stats.put("stockBatchCount", stockMapper.selectCount(null));
stats.put("pendingStockIn", stockInRecordMapper.selectCount(
new LambdaQueryWrapper<StockInRecord>().eq(StockInRecord::getStatus, 0)));
stats.put("expiryWarningCount", stockMapper.selectCount(
new LambdaQueryWrapper<Stock>().le(Stock::getExpiryDate, LocalDate.now().plusDays(90))));
return stats;
}
@Override
public Page<Stock> getExpiryWarning(Page<Stock> page) {
Page<Stock> result = stockMapper.selectPage(page,
new LambdaQueryWrapper<Stock>()
.le(Stock::getExpiryDate, LocalDate.now().plusDays(90))
.orderByAsc(Stock::getExpiryDate));
populateStockNames(result.getRecords());
return result;
}
@Override
public Page<StockInRecord> getRecentStockIn(Page<StockInRecord> page) {
Page<StockInRecord> result = stockInRecordMapper.selectPage(page,
new LambdaQueryWrapper<StockInRecord>().orderByDesc(StockInRecord::getCreateTime));
populateInRecordNames(result.getRecords());
return result;
}
@Override
public Page<StockOutRecord> getRecentStockOut(Page<StockOutRecord> page) {
Page<StockOutRecord> result = stockOutRecordMapper.selectPage(page,
new LambdaQueryWrapper<StockOutRecord>().orderByDesc(StockOutRecord::getCreateTime));
populateOutRecordNames(result.getRecords());
return result;
}
private void populateStockNames(java.util.List<Stock> stocks) {
if (stocks.isEmpty()) return;
var medIds = stocks.stream().map(Stock::getMedicineId).collect(java.util.stream.Collectors.toSet());
var medMap = medicineMapper.selectBatchIds(medIds).stream()
.collect(java.util.stream.Collectors.toMap(Medicine::getId, Medicine::getName));
stocks.forEach(s -> s.setMedicineName(medMap.getOrDefault(s.getMedicineId(), "")));
}
private void populateInRecordNames(java.util.List<StockInRecord> records) {
if (records.isEmpty()) return;
var suppIds = records.stream().map(StockInRecord::getSupplierId).collect(java.util.stream.Collectors.toSet());
var suppMap = supplierMapper.selectBatchIds(suppIds).stream()
.collect(java.util.stream.Collectors.toMap(Supplier::getId, Supplier::getName));
records.forEach(r -> r.setSupplierName(suppMap.getOrDefault(r.getSupplierId(), "")));
}
private void populateOutRecordNames(java.util.List<StockOutRecord> records) {
if (records.isEmpty()) return;
var custIds = records.stream().map(StockOutRecord::getCustomerId)
.filter(id -> id != null).collect(java.util.stream.Collectors.toSet());
if (!custIds.isEmpty()) {
var custMap = customerMapper.selectBatchIds(custIds).stream()
.collect(java.util.stream.Collectors.toMap(Customer::getId, Customer::getName));
records.forEach(r -> { if (r.getCustomerId() != null) r.setCustomerName(custMap.getOrDefault(r.getCustomerId(), "")); });
}
}
}

View File

@@ -0,0 +1,23 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kronecker.backend.entity.Medicine;
import com.kronecker.backend.mapper.MedicineMapper;
import com.kronecker.backend.service.MedicineService;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class MedicineServiceImpl extends ServiceImpl<MedicineMapper, Medicine> implements MedicineService {
@Override
public Page<Medicine> pageQuery(Page<Medicine> page, String name, Long categoryId) {
LambdaQueryWrapper<Medicine> wrapper = new LambdaQueryWrapper<Medicine>()
.like(StringUtils.hasText(name), Medicine::getName, name)
.eq(categoryId != null, Medicine::getCategoryId, categoryId)
.orderByDesc(Medicine::getCreateTime);
return baseMapper.selectPage(page, wrapper);
}
}

View File

@@ -0,0 +1,56 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kronecker.backend.entity.Menu;
import com.kronecker.backend.entity.RoleMenu;
import com.kronecker.backend.entity.UserRole;
import com.kronecker.backend.mapper.*;
import com.kronecker.backend.service.MenuService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements MenuService {
private final UserRoleMapper userRoleMapper;
private final RoleMenuMapper roleMenuMapper;
private final MenuMapper menuMapper;
@Override
public List<Menu> getTree() {
List<Menu> all = menuMapper.selectList(
new LambdaQueryWrapper<Menu>().orderByAsc(Menu::getSortOrder));
return buildTree(all, 0L);
}
@Override
public List<Menu> getMenusByUserId(Long userId) {
List<Long> roleIds = userRoleMapper.selectList(
new LambdaQueryWrapper<UserRole>().eq(UserRole::getUserId, userId)
).stream().map(UserRole::getRoleId).toList();
if (roleIds.isEmpty()) return Collections.emptyList();
Set<Long> menuIds = roleMenuMapper.selectList(
new LambdaQueryWrapper<RoleMenu>().in(RoleMenu::getRoleId, roleIds)
).stream().map(RoleMenu::getMenuId).collect(Collectors.toSet());
if (menuIds.isEmpty()) return Collections.emptyList();
List<Menu> menus = menuMapper.selectList(
new LambdaQueryWrapper<Menu>()
.in(Menu::getId, menuIds)
.orderByAsc(Menu::getSortOrder));
return buildTree(menus, 0L);
}
private List<Menu> buildTree(List<Menu> list, Long parentId) {
return list.stream()
.filter(m -> m.getParentId().equals(parentId))
.peek(m -> m.setChildren(buildTree(list, m.getId())))
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,9 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kronecker.backend.entity.Role;
import com.kronecker.backend.mapper.RoleMapper;
import org.springframework.stereotype.Service;
@Service
public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> {}

View File

@@ -0,0 +1,9 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kronecker.backend.entity.StockInDetail;
import com.kronecker.backend.mapper.StockInDetailMapper;
import org.springframework.stereotype.Service;
@Service
public class StockInDetailServiceImpl extends ServiceImpl<StockInDetailMapper, StockInDetail> {}

View File

@@ -0,0 +1,9 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kronecker.backend.entity.StockInRecord;
import com.kronecker.backend.mapper.StockInRecordMapper;
import org.springframework.stereotype.Service;
@Service
public class StockInRecordServiceImpl extends ServiceImpl<StockInRecordMapper, StockInRecord> {}

View File

@@ -0,0 +1,96 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.kronecker.backend.common.BusinessException;
import com.kronecker.backend.dto.StockInCreateDTO;
import com.kronecker.backend.entity.*;
import com.kronecker.backend.mapper.*;
import com.kronecker.backend.service.StockInService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@Service
@RequiredArgsConstructor
public class StockInServiceImpl implements StockInService {
private final StockInRecordMapper recordMapper;
private final StockInDetailMapper detailMapper;
private final StockMapper stockMapper;
@Override
@Transactional
public Long create(StockInCreateDTO dto, Long operatorId) {
// 生成单号
String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
long count = recordMapper.selectCount(new LambdaQueryWrapper<StockInRecord>()
.likeRight(StockInRecord::getRecordNo, "SI" + date)) + 1;
String recordNo = String.format("SI%s%04d", date, count);
StockInRecord record = new StockInRecord();
record.setRecordNo(recordNo);
record.setSupplierId(dto.getSupplierId());
record.setWarehouseId(dto.getWarehouseId());
record.setTotalAmount(dto.getTotalAmount());
record.setOperatorId(operatorId);
record.setInDate(dto.getInDate());
record.setStatus(0);
record.setRemark(dto.getRemark());
recordMapper.insert(record);
for (var d : dto.getDetails()) {
StockInDetail detail = new StockInDetail();
detail.setRecordId(record.getId());
detail.setMedicineId(d.getMedicineId());
detail.setBatchNo(d.getBatchNo());
detail.setQuantity(d.getQuantity());
detail.setCostPrice(d.getCostPrice());
detail.setTotalPrice(d.getTotalPrice());
detail.setProductionDate(d.getProductionDate());
detail.setExpiryDate(d.getExpiryDate());
detailMapper.insert(detail);
}
return record.getId();
}
@Override
@Transactional
public void audit(Long id) {
StockInRecord record = recordMapper.selectById(id);
if (record == null || record.getStatus() != 0) {
throw new BusinessException("入库单不存在或状态不正确");
}
record.setStatus(1);
recordMapper.updateById(record);
// 更新库存
var details = detailMapper.selectList(
new LambdaQueryWrapper<StockInDetail>().eq(StockInDetail::getRecordId, id));
for (var d : details) {
// 查找同批号库存
var stock = stockMapper.selectOne(new LambdaQueryWrapper<Stock>()
.eq(Stock::getMedicineId, d.getMedicineId())
.eq(Stock::getWarehouseId, record.getWarehouseId())
.eq(Stock::getBatchNo, d.getBatchNo())
.last("LIMIT 1"));
if (stock != null) {
stock.setQuantity(stock.getQuantity() + d.getQuantity());
stock.setCostPrice(d.getCostPrice());
stockMapper.updateById(stock);
} else {
Stock s = new Stock();
s.setMedicineId(d.getMedicineId());
s.setWarehouseId(record.getWarehouseId());
s.setBatchNo(d.getBatchNo());
s.setQuantity(d.getQuantity());
s.setCostPrice(d.getCostPrice());
s.setProductionDate(d.getProductionDate());
s.setExpiryDate(d.getExpiryDate());
stockMapper.insert(s);
}
}
}
}

View File

@@ -0,0 +1,9 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kronecker.backend.entity.StockOutDetail;
import com.kronecker.backend.mapper.StockOutDetailMapper;
import org.springframework.stereotype.Service;
@Service
public class StockOutDetailServiceImpl extends ServiceImpl<StockOutDetailMapper, StockOutDetail> {}

View File

@@ -0,0 +1,9 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kronecker.backend.entity.StockOutRecord;
import com.kronecker.backend.mapper.StockOutRecordMapper;
import org.springframework.stereotype.Service;
@Service
public class StockOutRecordServiceImpl extends ServiceImpl<StockOutRecordMapper, StockOutRecord> {}

View File

@@ -0,0 +1,99 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.kronecker.backend.common.BusinessException;
import com.kronecker.backend.dto.StockOutCreateDTO;
import com.kronecker.backend.entity.*;
import com.kronecker.backend.mapper.*;
import com.kronecker.backend.service.StockOutService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@Service
@RequiredArgsConstructor
public class StockOutServiceImpl implements StockOutService {
private final StockOutRecordMapper recordMapper;
private final StockOutDetailMapper detailMapper;
private final StockMapper stockMapper;
@Override
@Transactional
public Long create(StockOutCreateDTO dto, Long operatorId) {
String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
long count = recordMapper.selectCount(new LambdaQueryWrapper<StockOutRecord>()
.likeRight(StockOutRecord::getRecordNo, "SO" + date)) + 1;
String recordNo = String.format("SO%s%04d", date, count);
StockOutRecord record = new StockOutRecord();
record.setRecordNo(recordNo);
record.setCustomerId(dto.getCustomerId());
record.setWarehouseId(dto.getWarehouseId());
record.setTotalAmount(dto.getTotalAmount());
record.setOperatorId(operatorId);
record.setOutDate(dto.getOutDate());
record.setOutType(dto.getOutType() != null ? dto.getOutType() : 1);
record.setStatus(0);
record.setRemark(dto.getRemark());
recordMapper.insert(record);
for (var d : dto.getDetails()) {
StockOutDetail detail = new StockOutDetail();
detail.setRecordId(record.getId());
detail.setMedicineId(d.getMedicineId());
detail.setBatchNo(d.getBatchNo());
detail.setQuantity(d.getQuantity());
detail.setUnitPrice(d.getUnitPrice());
detail.setTotalPrice(d.getTotalPrice());
detailMapper.insert(detail);
}
return record.getId();
}
@Override
@Transactional
public void audit(Long id) {
StockOutRecord record = recordMapper.selectById(id);
if (record == null || record.getStatus() != 0) {
throw new BusinessException("出库单不存在或状态不正确");
}
var details = detailMapper.selectList(
new LambdaQueryWrapper<StockOutDetail>().eq(StockOutDetail::getRecordId, id));
// 检查库存是否充足
for (var d : details) {
int totalStock = stockMapper.selectList(new LambdaQueryWrapper<Stock>()
.eq(Stock::getMedicineId, d.getMedicineId())
.eq(Stock::getWarehouseId, record.getWarehouseId())
.eq(Stock::getBatchNo, d.getBatchNo()))
.stream().mapToInt(Stock::getQuantity).sum();
if (totalStock < d.getQuantity()) {
throw new BusinessException("药品批次 " + d.getBatchNo() + " 库存不足");
}
}
// 扣减库存FIFO: 先过期先出)
for (var d : details) {
int remaining = d.getQuantity();
var stocks = stockMapper.selectList(new LambdaQueryWrapper<Stock>()
.eq(Stock::getMedicineId, d.getMedicineId())
.eq(Stock::getWarehouseId, record.getWarehouseId())
.eq(Stock::getBatchNo, d.getBatchNo())
.orderByAsc(Stock::getExpiryDate));
for (Stock s : stocks) {
if (remaining <= 0) break;
int deduct = Math.min(s.getQuantity(), remaining);
s.setQuantity(s.getQuantity() - deduct);
stockMapper.updateById(s);
remaining -= deduct;
}
}
record.setStatus(1);
recordMapper.updateById(record);
}
}

View File

@@ -0,0 +1,9 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kronecker.backend.entity.Stock;
import com.kronecker.backend.mapper.StockMapper;
import org.springframework.stereotype.Service;
@Service
public class StockServiceImpl extends ServiceImpl<StockMapper, Stock> {}

View File

@@ -0,0 +1,9 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kronecker.backend.entity.Supplier;
import com.kronecker.backend.mapper.SupplierMapper;
import org.springframework.stereotype.Service;
@Service
public class SupplierServiceImpl extends ServiceImpl<SupplierMapper, Supplier> {}

View File

@@ -0,0 +1,9 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kronecker.backend.entity.UserRole;
import com.kronecker.backend.mapper.UserRoleMapper;
import org.springframework.stereotype.Service;
@Service
public class UserRoleServiceImpl extends ServiceImpl<UserRoleMapper, UserRole> {}

View File

@@ -0,0 +1,9 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kronecker.backend.entity.User;
import com.kronecker.backend.mapper.UserMapper;
import org.springframework.stereotype.Service;
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> {}

View File

@@ -0,0 +1,9 @@
package com.kronecker.backend.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.kronecker.backend.entity.Warehouse;
import com.kronecker.backend.mapper.WarehouseMapper;
import org.springframework.stereotype.Service;
@Service
public class WarehouseServiceImpl extends ServiceImpl<WarehouseMapper, Warehouse> {}

View File

@@ -0,0 +1,32 @@
server:
port: ${SERVER_PORT:8080}
spring:
application:
name: backend
datasource:
url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:medicine_manager}?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD:root}
driver-class-name: com.mysql.cj.jdbc.Driver
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
type-aliases-package: com.kronecker.backend.entity
global-config:
db-config:
id-type: auto
logic-delete-field: isDeleted
logic-delete-value: 1
logic-not-delete-value: 0
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
jwt:
secret: ${JWT_SECRET:Kzuxv0tsjN/GTO/udrRLgXj+Pr+uW1PL3PfGIXGstkQ=}
expiration: ${JWT_EXPIRATION:86400000}
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800000}

View File

@@ -411,7 +411,7 @@ INSERT INTO `role` (`id`, `name`, `code`, `description`, `status`) VALUES
-- BCrypt 加密: $2a$10$... 共 60 字符
-- -----------------------------------------------------------
INSERT INTO `user` (`id`, `username`, `password`, `real_name`, `phone`, `email`, `status`) VALUES
(1, 'admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5EH', '系统管理员', '13800000000', 'admin@medicine.com', 1);
(1, 'admin', '$2b$10$8Mo2a8QMdoXKVKYxlAqsy.qg2ruYDQ1NaWbc6VArXT6NSU.vuOJC6', '系统管理员', '13800000000', 'admin@medicine.com', 1);
-- -----------------------------------------------------------
-- 用户角色关联admin 用户 → 超级管理员角色)
@@ -541,8 +541,8 @@ INSERT INTO `role_menu` (`role_id`, `menu_id`) VALUES
-- 仓库管理员用户(用户名: warehouse01 / 密码: admin123
-- -----------------------------------------------------------
INSERT INTO `user` (`id`, `username`, `password`, `real_name`, `phone`, `email`, `status`) VALUES
(2, 'warehouse01', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5EH', '张伟', '13800000001', 'zhangwei@medicine.com', 1),
(3, 'staff01', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5EH', '李娜', '13800000002', 'lina@medicine.com', 1);
(2, 'warehouse01', '$2b$10$8Mo2a8QMdoXKVKYxlAqsy.qg2ruYDQ1NaWbc6VArXT6NSU.vuOJC6', '张伟', '13800000001', 'zhangwei@medicine.com', 1),
(3, 'staff01', '$2b$10$8Mo2a8QMdoXKVKYxlAqsy.qg2ruYDQ1NaWbc6VArXT6NSU.vuOJC6', '李娜', '13800000002', 'lina@medicine.com', 1);
-- -----------------------------------------------------------
-- 用户角色关联

View File

@@ -1,5 +1,6 @@
package com.kronecker.backend;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@@ -7,6 +8,7 @@ import org.springframework.boot.test.context.SpringBootTest;
class BackendApplicationTests {
@Test
@Disabled("需要先配置数据库连接,开发阶段跳过")
void contextLoads() {
}

View File

@@ -0,0 +1,2 @@
# 开发环境Vite dev 模式下自动加载)
VITE_API_BASE_URL=http://localhost:8080

6
frontend/.env.example Normal file
View File

@@ -0,0 +1,6 @@
# ============================================================
# 前端环境变量Vite 原生支持,复制为 .env 即可生效)
# ============================================================
# API 代理目标地址
VITE_API_BASE_URL=http://localhost:8080

View File

@@ -12,6 +12,10 @@ export function getMenusApi() {
return request.get('/menus/tree')
}
export function registerApi(data) {
return request.post('/auth/register', data)
}
export function refreshTokenApi(data) {
return request.post('/auth/refresh', data)
}

View File

@@ -24,14 +24,17 @@ function renderMenu(menus) {
return menus
.filter((m) => m.type !== 3 && m.visible !== 0)
.map((menu) => {
if (menu.children?.length) {
// 递归处理子节点,过滤掉 type=3 后的有效子节点
const children = menu.children?.length ? renderMenu(menu.children) : []
if (children.length) {
return {
index: menu.path || menu.name,
title: menu.name,
icon: menu.icon,
children: renderMenu(menu.children),
children,
}
}
// 无有效子节点 → 渲染为可点击的叶子菜单项
return {
index: menu.path?.toLowerCase() || '',
title: menu.name,
@@ -41,6 +44,21 @@ function renderMenu(menus) {
}
const menuItems = computed(() => renderMenu(userStore.menus))
// 所有有子菜单的父级 index保持展开
const openedMenus = computed(() => {
const indices = []
function collect(menus) {
menus.forEach(m => {
if (m.children?.length) {
indices.push(m.index || m.name)
collect(m.children)
}
})
}
collect(userStore.menus)
return indices
})
</script>
<template>
@@ -48,27 +66,29 @@ const menuItems = computed(() => renderMenu(userStore.menus))
<!-- 侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '220px'" class="aside">
<div class="logo">
<span v-if="!isCollapse">💊 药品管理系统</span>
<span v-else>💊</span>
<span v-if="!isCollapse"> 药品管理系统</span>
<span v-else></span>
</div>
<el-menu
:key="menuItems.length"
:default-active="activeMenu"
:default-openeds="openedMenus"
:collapse="isCollapse"
:collapse-transition="false"
router
@select="(index) => { if (index) router.push(index) }"
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409eff"
>
<template v-for="item in menuItems" :key="item.index">
<!-- 有子菜单 -->
<el-sub-menu v-if="item.children" :index="item.index">
<el-sub-menu v-if="item.children?.length" :index="item.index">
<template #title>
<el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</template>
<template v-for="child in item.children" :key="child.index">
<el-sub-menu v-if="child.children" :index="child.index">
<el-sub-menu v-if="child.children?.length" :index="child.index">
<template #title>{{ child.title }}</template>
<el-menu-item
v-for="sub in child.children"

View File

@@ -10,6 +10,12 @@ const constantRoutes = [
component: () => import('@/views/login/LoginView.vue'),
meta: { title: '登录', noAuth: true },
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/login/RegisterView.vue'),
meta: { title: '注册', noAuth: true },
},
{
path: '/',
name: 'MainLayout',
@@ -48,6 +54,7 @@ export const componentMap = {
'views/stockOut/StockOutForm': () => import('@/views/stockOut/StockOutForm.vue'),
'views/user/UserList': () => import('@/views/user/UserList.vue'),
'views/role/RoleList': () => import('@/views/role/RoleList.vue'),
'views/menu/MenuList': () => import('@/views/menu/MenuList.vue'),
}
// 根据后端菜单数据生成路由
@@ -79,13 +86,12 @@ router.beforeEach(async (to, _from, next) => {
return
}
// TODO: 暂时跳过登录验证(开发阶段)
// if (!userStore.token) {
// next('/login')
// return
// }
if (!userStore.token) {
next('/login')
return
}
// 动态注册菜单路由(仅首次),开发阶段使用本地 mock 菜单
// 动态注册菜单路由(仅首次)
if (!isMenusLoaded) {
try {
const menus = await userStore.getMenus()

View File

@@ -25,9 +25,9 @@ const mockMenus = [
]
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || 'dev-token') // TODO: 开发阶段默认有 token
const refreshToken = ref(localStorage.getItem('refreshToken') || 'dev-refresh')
const userInfo = ref({ username: 'admin', realName: '管理员' }) // TODO: 开发阶段 mock
const token = ref(localStorage.getItem('token') || '')
const refreshToken = ref(localStorage.getItem('refreshToken') || '')
const userInfo = ref({})
const menus = ref([])
const permissions = ref([])
@@ -35,7 +35,8 @@ export const useUserStore = defineStore('user', () => {
const perms = []
function walk(list) {
list.forEach((item) => {
if (item.permission_code) perms.push(item.permission_code)
const code = item.permissionCode || item.permission_code
if (code) perms.push(code)
if (item.children?.length) walk(item.children)
})
}

View File

@@ -4,6 +4,7 @@ import { getStatistics, getExpiryWarning } from '@/api/dashboard'
import { getStockInList } from '@/api/stockIn'
import { getStockOutList } from '@/api/stockOut'
import { Goods, Box, DocumentChecked, Warning } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const statistics = ref({ medicineCount: 0, stockBatchCount: 0, pendingStockIn: 0, expiryWarningCount: 0 })
const expiryList = ref([])
@@ -18,31 +19,30 @@ const statCards = [
]
function expiryColor(date) {
if (!date) return ''
const days = Math.ceil((new Date(date) - new Date()) / (1000 * 60 * 60 * 24))
if (days < 30) return 'danger'
if (days < 90) return 'warning'
return ''
return 'success'
}
onMounted(async () => {
try {
const [statRes, warnRes, inRes, outRes] = await Promise.all([
getStatistics(),
getExpiryWarning(),
const [statRes, warnRes, inRes, outRes] = await Promise.allSettled([
getStatistics(), getExpiryWarning(),
getStockInList({ page: 1, size: 5 }),
getStockOutList({ page: 1, size: 5 }),
])
statistics.value = statRes.data
expiryList.value = warnRes.data?.records || warnRes.data || []
recentInList.value = inRes.data?.records || []
recentOutList.value = outRes.data?.records || []
} catch { /* 后端未启动,静默处理 */ }
if (statRes.status === 'fulfilled') statistics.value = statRes.value.data || {}
if (warnRes.status === 'fulfilled') expiryList.value = warnRes.value.data?.records || warnRes.value.data || []
if (inRes.status === 'fulfilled') recentInList.value = inRes.value.data?.records || []
if (outRes.status === 'fulfilled') recentOutList.value = outRes.value.data?.records || []
} catch { /* 后端未启动 */ }
})
</script>
<template>
<div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div v-for="card in statCards" :key="card.key" class="stat-card">
<div class="stat-icon" :style="{ background: card.color }">
@@ -55,31 +55,27 @@ onMounted(async () => {
</div>
</div>
<!-- 效期预警 -->
<el-card class="section-card" header="效期预警近90天">
<el-table :data="expiryList" size="small" empty-text="暂无预警数据">
<el-table-column prop="medicineName" label="药品名称" />
<el-table-column prop="batchNo" label="批号" />
<el-card class="section-card" header="效期预警90天内到期">
<el-table :data="expiryList" size="small" empty-text="暂无预警">
<el-table-column prop="medicineName" label="药品" min-width="160" />
<el-table-column prop="batchNo" label="批号" width="120" />
<el-table-column prop="quantity" label="库存" width="80" />
<el-table-column prop="expiryDate" label="有效期至" width="120">
<el-table-column prop="expiryDate" label="有效期至" width="130">
<template #default="{ row }">
<el-tag :type="expiryColor(row.expiryDate || row.expiry_date)" size="small">
{{ row.expiryDate || row.expiry_date }}
</el-tag>
<el-tag :type="expiryColor(row.expiryDate)" size="small">{{ row.expiryDate }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 最近记录 -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<el-card header="最近入库">
<template v-if="recentInList.length">
<div v-for="item in recentInList" :key="item.id" class="record-item">
<span>{{ item.recordNo || item.record_no }}</span>
<span>{{ item.supplierName || '供应商' }}</span>
<el-tag :type="item.status === 1 ? 'success' : 'warning'" size="small">
{{ item.status === 1 ? '已入库' : item.status === 0 ? '待审核' : '已取消' }}
<span>{{ item.recordNo }}</span>
<span style="color:#909399;font-size:13px">{{ item.supplierName }}</span>
<el-tag :type="item.status===1?'success':item.status===0?'warning':'info'" size="small">
{{ item.status===1?'已入库':item.status===0?'待审核':'已取消' }}
</el-tag>
</div>
</template>
@@ -88,10 +84,10 @@ onMounted(async () => {
<el-card header="最近出库">
<template v-if="recentOutList.length">
<div v-for="item in recentOutList" :key="item.id" class="record-item">
<span>{{ item.recordNo || item.record_no }}</span>
<span>{{ item.customerName || '客户' }}</span>
<el-tag :type="item.status === 1 ? 'success' : 'warning'" size="small">
{{ item.status === 1 ? '已出库' : item.status === 0 ? '待审核' : '已取消' }}
<span>{{ item.recordNo }}</span>
<span style="color:#909399;font-size:13px">{{ item.customerName || '-' }}</span>
<el-tag :type="item.status===1?'success':item.status===0?'warning':'info'" size="small">
{{ item.status===1?'已出库':item.status===0?'待审核':'已取消' }}
</el-tag>
</div>
</template>
@@ -102,19 +98,10 @@ onMounted(async () => {
</template>
<style lang="scss" scoped>
.section-card {
margin-bottom: 16px;
}
.section-card { margin-bottom: 16px; }
.record-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; border-bottom: 1px solid #f0f0f0;
&:last-child { border-bottom: none; }
}
</style>

View File

@@ -70,15 +70,13 @@ async function handleLogin() {
<el-checkbox v-model="loginForm.remember">记住我</el-checkbox>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
style="width: 100%"
@click="handleLogin"
>
<el-button type="primary" :loading="loading" style="width:100%" @click="handleLogin">
{{ loading ? '登录中...' : ' ' }}
</el-button>
</el-form-item>
<el-form-item>
<el-button style="width:100%" @click="router.push('/register')">没有账号去注册</el-button>
</el-form-item>
</el-form>
</div>
</div>

View File

@@ -0,0 +1,117 @@
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { registerApi } from '@/api/auth'
import { ElMessage } from 'element-plus'
import { User, Lock, Phone, Message } from '@element-plus/icons-vue'
const router = useRouter()
const formRef = ref(null)
const loading = ref(false)
const form = reactive({
username: '',
password: '',
confirmPassword: '',
realName: '',
phone: '',
email: '',
})
const validatePass = (_rule, value, callback) => {
if (value !== form.password) callback(new Error('两次密码不一致'))
else callback()
}
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 32, message: '用户名长度3-32位', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 32, message: '密码长度6-32位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{ validator: validatePass, trigger: 'blur' },
],
}
async function handleRegister() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await registerApi({
username: form.username,
password: form.password,
realName: form.realName,
phone: form.phone,
email: form.email,
})
ElMessage.success('注册成功,请登录')
router.push('/login')
} catch { /* error handled by interceptor */ }
finally { loading.value = false }
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<h2 class="login-title">注册账号</h2>
<el-form ref="formRef" :model="form" :rules="rules" size="large" @keyup.enter="handleRegister">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" :prefix-icon="User" />
</el-form-item>
<el-form-item prop="realName">
<el-input v-model="form.realName" placeholder="真实姓名(选填)" :prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" show-password placeholder="密码" :prefix-icon="Lock" />
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="确认密码" :prefix-icon="Lock" />
</el-form-item>
<el-form-item prop="phone">
<el-input v-model="form.phone" placeholder="手机号(选填)" :prefix-icon="Phone" />
</el-form-item>
<el-form-item prop="email">
<el-input v-model="form.email" placeholder="邮箱(选填)" :prefix-icon="Message" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" style="width:100%" @click="handleRegister">
{{ loading ? '注册中...' : ' ' }}
</el-button>
</el-form-item>
<el-form-item>
<el-button style="width:100%" @click="router.push('/login')">已有账号去登录</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<style lang="scss" scoped>
.login-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 420px;
padding: 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
}
.login-title {
text-align: center;
margin-bottom: 24px;
color: #303133;
font-size: 22px;
}
</style>

View File

@@ -0,0 +1,153 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import request from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
const tableData = ref([])
const dialogVisible = ref(false)
const dialogTitle = ref('新增菜单')
const formRef = ref(null)
const parentOptions = ref([])
const form = reactive({
id: null, parentId: 0, name: '', type: 1, path: '', component: '',
icon: '', permissionCode: '', sortOrder: 0, visible: 1,
})
async function loadData() {
try {
const res = await request.get('/menus')
tableData.value = buildTree(res.data || [])
parentOptions.value = flatten(res.data || [])
} catch { /* */ }
}
function buildTree(list, parentId = 0) {
return list.filter(m => m.parentId === parentId).map(m => ({
...m, children: buildTree(list, m.id) || undefined,
}))
}
function flatten(list) {
return list.map(m => ({ id: m.id, name: m.name, type: m.type }))
}
function handleAdd(parentId = 0) {
dialogTitle.value = '新增菜单'
Object.keys(form).forEach(k => form[k] = null)
form.id = null; form.parentId = parentId; form.type = 1; form.sortOrder = 0; form.visible = 1
dialogVisible.value = true
}
function handleEdit(row) {
dialogTitle.value = '编辑菜单'
Object.assign(form, row)
form.parentId = form.parentId || 0
dialogVisible.value = true
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除"${row.name}"?子菜单也会被删除。`, '确认删除', { type: 'warning' })
await request.delete(`/menus/${row.id}`)
ElMessage.success('删除成功')
loadData()
}
async function handleSubmit() {
const data = { ...form }
delete data.id; delete data.children
if (form.id) {
await request.put(`/menus/${form.id}`, data)
ElMessage.success('编辑成功')
} else {
await request.post('/menus', data)
ElMessage.success('新增成功')
}
dialogVisible.value = false
loadData()
}
onMounted(loadData)
</script>
<template>
<div class="page-container">
<div class="tool-bar">
<el-button type="primary" v-permission="'menu:add'" :icon="Plus" @click="handleAdd(0)">新增顶级菜单</el-button>
</div>
<el-table :data="tableData" border stripe row-key="id" default-expand-all>
<el-table-column prop="name" label="菜单名称" min-width="180" />
<el-table-column prop="type" label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.type === 1 ? '' : row.type === 2 ? 'success' : 'warning'" size="small">
{{ { 1: '目录', 2: '菜单', 3: '按钮' }[row.type] }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路由路径" width="150" />
<el-table-column prop="component" label="组件路径" width="200" show-overflow-tooltip />
<el-table-column prop="icon" label="图标" width="100" />
<el-table-column prop="permissionCode" label="权限编码" width="160" show-overflow-tooltip />
<el-table-column prop="sortOrder" label="排序" width="70" align="center" />
<el-table-column prop="visible" label="可见" width="70" align="center">
<template #default="{ row }">{{ row.visible === 1 ? '是' : '否' }}</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button size="small" v-permission="'menu:edit'" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" v-permission="'menu:add'" @click="handleAdd(row.id)">添加子级</el-button>
<el-button size="small" type="danger" v-permission="'menu:delete'" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="550px">
<el-form ref="formRef" :model="form" label-width="90px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="上级菜单">
<el-select v-model="form.parentId" placeholder="不选则为顶级" clearable style="width:100%">
<el-option v-for="p in parentOptions.filter(x => x.id !== form.id && x.type !== 3)" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="菜单类型">
<el-select v-model="form.type" style="width:100%">
<el-option label="目录" :value="1" />
<el-option label="菜单" :value="2" />
<el-option label="按钮" :value="3" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="名称" required><el-input v-model="form.name" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="图标"><el-input v-model="form.icon" placeholder="Element Plus 图标名" /></el-form-item>
</el-col>
<el-col v-if="form.type !== 1" :span="12">
<el-form-item label="路由路径"><el-input v-model="form.path" placeholder="type=2时填写如 /user/list" /></el-form-item>
</el-col>
<el-col v-if="form.type !== 1" :span="12">
<el-form-item label="组件路径"><el-input v-model="form.component" placeholder="type=2时填写" /></el-form-item>
</el-col>
<el-col v-if="form.type === 3" :span="12">
<el-form-item label="权限编码"><el-input v-model="form.permissionCode" placeholder="如 user:add" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" style="width:100%" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="可见"><el-switch v-model="form.visible" :active-value="1" :inactive-value="0" /></el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" @click="handleSubmit">确定</el-button></template>
</el-dialog>
</div>
</template>

View File

@@ -1,26 +1,53 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getStockInList, auditStockIn } from '@/api/stockIn'
import { getStockInList, getStockInById, auditStockIn } from '@/api/stockIn'
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const query = reactive({ page: 1, size: 10 })
const query = reactive({ page: 1, size: 10, recordNo: '' })
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const detailVisible = ref(false)
const detailRecord = ref({})
const detailItems = ref([])
function statusTag(status) {
return { 0: 'warning', 1: 'success', 2: 'info' }[status] || 'info'
}
function statusText(status) {
return { 0: '待审核', 1: '已入库', 2: '已取消' }[status] || '未知'
const statusMap = { 0: { type: 'warning', text: '待审核' }, 1: { type: 'success', text: '已入库' }, 2: { type: 'info', text: '已取消' } }
async function loadData() {
loading.value = true
try {
const r = await getStockInList(query)
tableData.value = r.data?.records || []
total.value = r.data?.total || 0
} finally { loading.value = false }
}
async function loadData() { try { const r = await getStockInList(query); tableData.value = r.data?.records || []; total.value = r.data?.total || 0 } catch { /* */ } }
function handleCreate() { router.push('/stock-in/add') }
function handleView(row) { /* 查看详情弹窗 */ ElMessage.info('入库单号: ' + (row.recordNo || row.record_no)) }
async function handleAudit(row) { await auditStockIn(row.id, { status: 1 }); ElMessage.success('审核通过,库存已更新'); loadData() }
async function handleView(row) {
try {
const r = await getStockInById(row.id)
detailRecord.value = r.data || row
detailItems.value = r.data?.details || []
detailVisible.value = true
} catch { ElMessage.error('获取详情失败') }
}
async function handleAudit(row) {
await ElMessageBox.confirm(`确认审核通过入库单 ${row.recordNo}?审核后库存将自动更新。`, '审核确认', { type: 'success' })
await auditStockIn(row.id, { status: 1 })
ElMessage.success('审核通过,库存已更新')
loadData()
}
async function handleReject(row) {
await ElMessageBox.confirm(`确认驳回入库单 ${row.recordNo}`, '驳回确认', { type: 'warning' })
await auditStockIn(row.id, { status: 2 })
ElMessage.success('已驳回')
loadData()
}
onMounted(loadData)
</script>
@@ -28,30 +55,72 @@ onMounted(loadData)
<template>
<div class="page-container">
<div class="search-bar">
<el-input v-model="query.recordNo" placeholder="入库单号" clearable style="width:180px" />
<el-input v-model="query.recordNo" placeholder="入库单号" clearable style="width:200px" @keyup.enter="loadData" />
<el-button type="primary" :icon="Search" @click="loadData">搜索</el-button>
<el-button :icon="Refresh" @click="Object.keys(query).forEach(k=>query[k]=k==='page'?1:k==='size'?10:'');loadData()">重置</el-button>
<el-button :icon="Refresh" @click="query.recordNo='';loadData()">重置</el-button>
</div>
<div class="tool-bar">
<el-button type="primary" v-permission="'stock-in:add'" :icon="Plus" @click="handleCreate">+ 新建入库单</el-button>
<el-button type="primary" v-permission="'stock-in:add'" :icon="Plus" @click="handleCreate">新建入库单</el-button>
</div>
<el-table :data="tableData" border stripe>
<el-table-column prop="recordNo" label="入库单号" width="150" />
<el-table :data="tableData" border stripe v-loading="loading">
<el-table-column prop="recordNo" label="入库单号" width="160" />
<el-table-column prop="supplierName" label="供应商" min-width="180" />
<el-table-column prop="totalAmount" label="总金额" width="110"><template #default="{row}">¥{{row.totalAmount}}</template></el-table-column>
<el-table-column prop="totalAmount" label="总金额" width="120">
<template #default="{ row }">¥{{ row.totalAmount }}</template>
</el-table-column>
<el-table-column prop="inDate" label="入库日期" width="120" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{row}"><el-tag :type="statusTag(row.status)" size="small">{{statusText(row.status)}}</el-tag></template>
<template #default="{ row }">
<el-tag :type="statusMap[row.status]?.type || 'info'" size="small">
{{ statusMap[row.status]?.text || '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{row}">
<el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button size="small" @click="handleView(row)">查看</el-button>
<el-button v-if="row.status===0" size="small" type="success" v-permission="'stock-in:audit'" @click="handleAudit(row)">审核</el-button>
<template v-if="row.status === 0">
<el-button size="small" type="success" v-permission="'stock-in:audit'" @click="handleAudit(row)">通过</el-button>
<el-button size="small" type="danger" @click="handleReject(row)">驳回</el-button>
</template>
</div>
</template>
</el-table-column>
</el-table>
<div style="display:flex;justify-content:flex-end;margin-top:16px"><el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next" @current-change="loadData" @size-change="query.page=1;loadData()" /></div>
<div style="display:flex;justify-content:flex-end;margin-top:16px">
<el-pagination
v-model:current-page="query.page" v-model:page-size="query.size"
:total="total" :page-sizes="[10,20,50]"
layout="total,sizes,prev,pager,next"
@current-change="loadData" @size-change="query.page=1;loadData()"
/>
</div>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="入库单详情" width="750px">
<el-descriptions :column="3" border>
<el-descriptions-item label="入库单号">{{ detailRecord.recordNo }}</el-descriptions-item>
<el-descriptions-item label="供应商">{{ detailRecord.supplierName }}</el-descriptions-item>
<el-descriptions-item label="入库日期">{{ detailRecord.inDate }}</el-descriptions-item>
<el-descriptions-item label="总金额">¥{{ detailRecord.totalAmount }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusMap[detailRecord.status]?.type" size="small">{{ statusMap[detailRecord.status]?.text }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="备注">{{ detailRecord.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin:16px 0 8px">药品明细</h4>
<el-table :data="detailItems" border size="small" max-height="300">
<el-table-column prop="medicineName" label="药品" min-width="160" />
<el-table-column prop="batchNo" label="批号" width="120" />
<el-table-column prop="quantity" label="数量" width="80" />
<el-table-column prop="costPrice" label="进价" width="90"><template #default="{row}">¥{{row.costPrice}}</template></el-table-column>
<el-table-column prop="totalPrice" label="小计" width="100"><template #default="{row}">¥{{row.totalPrice}}</template></el-table-column>
<el-table-column prop="expiryDate" label="有效期至" width="120" />
</el-table>
</el-dialog>
</div>
</template>

View File

@@ -1,23 +1,54 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getStockOutList, auditStockOut } from '@/api/stockOut'
import { getStockOutList, getStockOutById, auditStockOut } from '@/api/stockOut'
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const query = reactive({ page: 1, size: 10 })
const query = reactive({ page: 1, size: 10, recordNo: '' })
const tableData = ref([])
const total = ref(0)
const loading = ref(false)
const detailVisible = ref(false)
const detailRecord = ref({})
const detailItems = ref([])
function statusTag(s) { return { 0: 'warning', 1: 'success', 2: 'info' }[s] || 'info' }
function statusText(s) { return { 0: '待审核', 1: '出库', 2: '已取消' }[s] || '未知' }
function typeText(s) { return { 1: '销售出库', 2: '领用出库', 3: '报损出库', 4: '退货出库' }[s] || '未知' }
const statusMap = { 0: { type: 'warning', text: '待审核' }, 1: { type: 'success', text: '已出库' }, 2: { type: 'info', text: '已取消' } }
const typeMap = { 1: '销售出库', 2: '领用出库', 3: '报损出库', 4: '退货出库' }
async function loadData() {
loading.value = true
try {
const r = await getStockOutList(query)
tableData.value = r.data?.records || []
total.value = r.data?.total || 0
} finally { loading.value = false }
}
async function loadData() { try { const r = await getStockOutList(query); tableData.value = r.data?.records || []; total.value = r.data?.total || 0 } catch { /* */ } }
function handleCreate() { router.push('/stock-out/add') }
function handleView(row) { ElMessage.info('出库单号: ' + (row.recordNo || row.record_no)) }
async function handleAudit(row) { await auditStockOut(row.id, { status: 1 }); ElMessage.success('审核通过,库存已扣减'); loadData() }
async function handleView(row) {
try {
const r = await getStockOutById(row.id)
detailRecord.value = r.data || row
detailItems.value = r.data?.details || []
detailVisible.value = true
} catch { ElMessage.error('获取详情失败') }
}
async function handleAudit(row) {
await ElMessageBox.confirm(`确认审核通过出库单 ${row.recordNo}?审核后库存将自动扣减。`, '审核确认', { type: 'success' })
await auditStockOut(row.id, { status: 1 })
ElMessage.success('审核通过,库存已扣减')
loadData()
}
async function handleReject(row) {
await ElMessageBox.confirm(`确认驳回出库单 ${row.recordNo}`, '驳回确认', { type: 'warning' })
await auditStockOut(row.id, { status: 2 })
ElMessage.success('已驳回')
loadData()
}
onMounted(loadData)
</script>
@@ -25,31 +56,75 @@ onMounted(loadData)
<template>
<div class="page-container">
<div class="search-bar">
<el-input v-model="query.recordNo" placeholder="出库单号" clearable style="width:180px" />
<el-input v-model="query.recordNo" placeholder="出库单号" clearable style="width:200px" @keyup.enter="loadData" />
<el-button type="primary" :icon="Search" @click="loadData">搜索</el-button>
<el-button :icon="Refresh" @click="Object.keys(query).forEach(k=>query[k]=k==='page'?1:k==='size'?10:'');loadData()">重置</el-button>
<el-button :icon="Refresh" @click="query.recordNo='';loadData()">重置</el-button>
</div>
<div class="tool-bar">
<el-button type="primary" v-permission="'stock-out:add'" :icon="Plus" @click="handleCreate">+ 新建出库单</el-button>
<el-button type="primary" v-permission="'stock-out:add'" :icon="Plus" @click="handleCreate">新建出库单</el-button>
</div>
<el-table :data="tableData" border stripe>
<el-table-column prop="recordNo" label="出库单号" width="150" />
<el-table-column prop="customerName" label="客户" min-width="160" />
<el-table-column prop="outType" label="类型" width="100"><template #default="{row}">{{typeText(row.outType)}}</template></el-table-column>
<el-table-column prop="totalAmount" label="总金额" width="110"><template #default="{row}">¥{{row.totalAmount}}</template></el-table-column>
<el-table :data="tableData" border stripe v-loading="loading">
<el-table-column prop="recordNo" label="出库单号" width="160" />
<el-table-column prop="customerName" label="客户" min-width="160">
<template #default="{ row }">{{ row.customerName || '-' }}</template>
</el-table-column>
<el-table-column prop="outType" label="类型" width="110">
<template #default="{ row }">{{ typeMap[row.outType] || '未知' }}</template>
</el-table-column>
<el-table-column prop="totalAmount" label="总金额" width="120">
<template #default="{ row }">¥{{ row.totalAmount }}</template>
</el-table-column>
<el-table-column prop="outDate" label="出库日期" width="120" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{row}"><el-tag :type="statusTag(row.status)" size="small">{{statusText(row.status)}}</el-tag></template>
<template #default="{ row }">
<el-tag :type="statusMap[row.status]?.type || 'info'" size="small">
{{ statusMap[row.status]?.text || '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{row}">
<el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button size="small" @click="handleView(row)">查看</el-button>
<el-button v-if="row.status===0" size="small" type="success" v-permission="'stock-out:audit'" @click="handleAudit(row)">审核</el-button>
<template v-if="row.status === 0">
<el-button size="small" type="success" v-permission="'stock-out:audit'" @click="handleAudit(row)">通过</el-button>
<el-button size="small" type="danger" @click="handleReject(row)">驳回</el-button>
</template>
</div>
</template>
</el-table-column>
</el-table>
<div style="display:flex;justify-content:flex-end;margin-top:16px"><el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next" @current-change="loadData" @size-change="query.page=1;loadData()" /></div>
<div style="display:flex;justify-content:flex-end;margin-top:16px">
<el-pagination
v-model:current-page="query.page" v-model:page-size="query.size"
:total="total" :page-sizes="[10,20,50]"
layout="total,sizes,prev,pager,next"
@current-change="loadData" @size-change="query.page=1;loadData()"
/>
</div>
<el-dialog v-model="detailVisible" title="出库单详情" width="750px">
<el-descriptions :column="3" border>
<el-descriptions-item label="出库单号">{{ detailRecord.recordNo }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ detailRecord.customerName || '-' }}</el-descriptions-item>
<el-descriptions-item label="出库日期">{{ detailRecord.outDate }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ typeMap[detailRecord.outType] }}</el-descriptions-item>
<el-descriptions-item label="总金额">¥{{ detailRecord.totalAmount }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusMap[detailRecord.status]?.type" size="small">{{ statusMap[detailRecord.status]?.text }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<h4 style="margin:16px 0 8px">药品明细</h4>
<el-table :data="detailItems" border size="small" max-height="300">
<el-table-column prop="medicineName" label="药品" min-width="160" />
<el-table-column prop="batchNo" label="批号" width="120" />
<el-table-column prop="quantity" label="数量" width="80" />
<el-table-column prop="unitPrice" label="单价" width="90"><template #default="{row}">¥{{row.unitPrice}}</template></el-table-column>
<el-table-column prop="totalPrice" label="小计" width="100"><template #default="{row}">¥{{row.totalPrice}}</template></el-table-column>
</el-table>
</el-dialog>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More