临时易耗品申请与审批

This commit is contained in:
feie9454 2025-10-05 21:22:42 +08:00
parent 7734c0cd89
commit b0e010f80e
31 changed files with 7897 additions and 447 deletions

View File

@ -1,17 +1,38 @@
<script setup lang="ts">
import { onLaunch, onShow, onHide } from "@dcloudio/uni-app";
import { onLaunch, onShow, onHide } from "@dcloudio/uni-app"
import { startPolling, stopPolling } from "@/utils/messageManager"
onLaunch(() => {
console.log("App Launch");
});
console.log("App Launch")
// 30
startPolling(30000)
})
onShow(() => {
console.log("App Show");
});
console.log("App Show")
//
startPolling(30000)
})
onHide(() => {
console.log("App Hide");
});
console.log("App Hide")
//
stopPolling()
})
</script>
<style>
body{
/* 全局盒模型设置 */
*, *::before, *::after {
box-sizing: border-box;
}
/* 全局字体设置 */
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
/* uni-app 组件也应用 border-box */
page, view, text, image, button, input, textarea, scroll-view, swiper, picker {
box-sizing: border-box;
}
</style>

View File

@ -110,3 +110,9 @@ export default {
unloadSpecimentPic: "/API/specimentransport/finishpic/upload/",
},
};
export const enum AssetRole {
DEVICE_MANAGER = 0b100,
TEAM_LEAD = 0b010,
CONSUMABLES_MANAGER = 0b001
}

View File

@ -118,8 +118,43 @@
"style": {
"navigationBarTitleText": "易耗品盘点(进行中)"
}
}
,
},
{
"path": "pages/consumables/temp-request",
"style": {
"navigationBarTitleText": "临时易耗品申请"
}
},
{
"path": "pages/consumables/temp-request-create",
"style": {
"navigationBarTitleText": "新建申请"
}
},
{
"path": "pages/consumables/temp-request-detail",
"style": {
"navigationBarTitleText": "申请详情"
}
},
{
"path": "pages/consumables/temp-approve",
"style": {
"navigationBarTitleText": "临时易耗品审批"
}
},
{
"path": "pages/consumables/temp-approve-detail",
"style": {
"navigationBarTitleText": "审批详情"
}
},
{
"path": "pages/consumables/temp-catalog",
"style": {
"navigationBarTitleText": "耗材种类管理"
}
},
{
"path": "pages/fixed-assets/scan",
"style": {

142
src/pages/api/assets.ts Normal file
View File

@ -0,0 +1,142 @@
import { formatTime, formatDate, formatDateTime, BASE_URL, getHeaders } from './index.js';
// 定义借用资产接口
interface BorrowAsset {
borrowId: number;
assetId: number;
assetName: string;
borrowDate: string;
returnDate: string | null;
actualReturnDate: string | null;
borrowerId: string;
borrowerName: string;
borrowerMobile: string;
borrowDepartmentId: string | null;
borrowDepartmentName: string | null;
location: string | null;
lenderId: string | null;
lenderName: string | null;
lenderMobile: string | null;
approverId: string | null;
approverName: string | null;
approverMobile: string | null;
registrantId: string | null;
registrantName: string | null;
registrantMobile: string | null;
registrantDate: string | null;
isReturn: number;
note: string;
deleteFlag: number;
}
// 定义分页响应接口
interface PagedResponse<T> {
total: number;
list: T[];
pageNum: number;
pageSize: number;
size: number;
startRow: number;
endRow: number;
pages: number;
prePage: number;
nextPage: number;
isFirstPage: boolean;
isLastPage: boolean;
hasPreviousPage: boolean;
hasNextPage: boolean;
navigatePages: number;
navigatepageNums: number[];
navigateFirstPage: number;
navigateLastPage: number;
}
// 定义 API 响应接口
interface ApiResponse<T> {
code: number;
msg: string;
token: string | null;
data: T;
}
// 借用列表
/* {
"code": 0,
"msg": "查询成功",
"token": null,
"data": {
"total": 16,
"list": [
{
"borrowId": 49,
"assetId": 83,
"assetName": "和法国哈哈",
"borrowDate": "2025-09-07",
"returnDate": "2025-09-01",
"actualReturnDate": null,
"borrowerId": "40288083894a5a38018999ea74ff0135",
"borrowerName": "熊三",
"borrowerMobile": "123",
"borrowDepartmentId": null,
"borrowDepartmentName": null,
"location": null,
"lenderId": null,
"lenderName": null,
"lenderMobile": null,
"approverId": null,
"approverName": null,
"approverMobile": null,
"registrantId": "null",
"registrantName": null,
"registrantMobile": null,
"registrantDate": "2025-09-29",
"isReturn": 0,
"note": "123213",
"deleteFlag": 0
},...
],
"pageNum": 1,
"pageSize": 10,
"size": 10,
"startRow": 1,
"endRow": 10,
"pages": 2,
"prePage": 0,
"nextPage": 2,
"isFirstPage": true,
"isLastPage": false,
"hasPreviousPage": false,
"hasNextPage": true,
"navigatePages": 8,
"navigatepageNums": [
1,
2
],
"navigateFirstPage": 1,
"navigateLastPage": 2
}
} */
export function getLentAssetsList(page: number, pageSize: number, searchParams: any): Promise<ApiResponse<PagedResponse<BorrowAsset>>> {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/asset/borrow/page`,
method: 'POST',
header: getHeaders(),
data: {
pageNum: page,
pageSize,
...searchParams
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data as ApiResponse<PagedResponse<BorrowAsset>>);
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,188 @@
import { formatTime, formatDate, formatDateTime, getHeaders } from './index';
const BASE_URL = 'http://localhost:3000' // 替换为你的实际后端地址
// 请求封装
const request = (url: string, options: any = {}) => {
const token = uni.getStorageSync('token')
return uni.request({
url: `${BASE_URL}${url}`,
method: options.method || 'GET',
data: options.data,
header: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
...options.header
}
})
}
// 类型定义
export interface RequestItem {
id?: string
name: string
spec?: string
quantity: number
unit?: string
estimatedUnitCost?: number
remark?: string
catalogId?: string
requestId?: string
createdAt?: string
updatedAt?: string
}
export interface ConsumableRequest {
id?: string
applicantName?: string
applicantPhone: string
department?: string
reason?: string
status?: 'PENDING' | 'APPROVED' | 'REJECTED' | 'CANCELLED' | 'COMPLETED'
approverId?: string
approvedAt?: string
neededAt?: string
remark?: string
createdAt?: string
updatedAt?: string
items: RequestItem[]
}
export interface CatalogItem {
id?: string
name: string
spec?: string
unit?: string
isActive?: boolean
createdAt?: string
updatedAt?: string
}
export interface ListQuery {
page?: number
pageSize?: number
status?: string
applicantPhone?: string
from?: string
to?: string
}
export interface CatalogQuery {
page?: number
pageSize?: number
isActive?: boolean
q?: string
}
// API 方法
export default {
// 创建临时申请
createRequest(data: Partial<ConsumableRequest>) {
return request('/api/consumable-temp-requests', {
method: 'POST',
data
})
},
// 查询申请列表
getRequestList(params?: ListQuery) {
return request('/api/consumable-temp-requests', {
method: 'GET',
data: params
})
},
// 获取申请详情
getRequestDetail(id: string) {
return request(`/api/consumable-temp-requests/${id}`, {
method: 'GET'
})
},
// 更新申请
updateRequest(id: string, data: Partial<ConsumableRequest>) {
return request(`/api/consumable-temp-requests/${id}`, {
method: 'PUT',
data
})
},
// 删除申请
deleteRequest(id: string) {
return request(`/api/consumable-temp-requests/${id}`, {
method: 'DELETE'
})
},
// 更新申请状态
updateRequestStatus(id: string, data: {
status: string
approverId?: string
remark?: string
}) {
return request(`/api/consumable-temp-requests/${id}/status`, {
method: 'PATCH',
data
})
},
// 添加物料明细
addRequestItem(requestId: string, data: Partial<RequestItem>) {
return request(`/api/consumable-temp-requests/${requestId}/items`, {
method: 'POST',
data
})
},
// 更新物料明细
updateRequestItem(itemId: string, data: Partial<RequestItem>) {
return request(`/api/consumable-temp-items/${itemId}`, {
method: 'PUT',
data
})
},
// 删除物料明细
deleteRequestItem(itemId: string) {
return request(`/api/consumable-temp-items/${itemId}`, {
method: 'DELETE'
})
},
// 创建耗材目录
createCatalog(data: Partial<CatalogItem>) {
return request('/api/consumable-temp-catalog', {
method: 'POST',
data
})
},
// 查询目录列表
getCatalogList(params?: CatalogQuery) {
return request('/api/consumable-temp-catalog', {
method: 'GET',
data: params
})
},
// 获取目录详情
getCatalogDetail(id: string) {
return request(`/api/consumable-temp-catalog/${id}`, {
method: 'GET'
})
},
// 更新目录
updateCatalog(id: string, data: Partial<CatalogItem>) {
return request(`/api/consumable-temp-catalog/${id}`, {
method: 'PUT',
data
})
},
// 停用目录
deleteCatalog(id: string) {
return request(`/api/consumable-temp-catalog/${id}`, {
method: 'DELETE'
})
}
}

View File

@ -1,8 +1,4 @@
const BASE_URL = 'http://110.42.33.196:8089';//定义后端基础接口地址
import config from '../../config.js'; // 引入配置文件
import { formatTime, formatDate, formatDateTime, BASE_URL, getHeaders } from './index.ts'; // 引入时间格式化函数和BASE_URL
// 根据手机号搜索用户列表
export function searchUserByMobile(mobile) {
@ -16,7 +12,7 @@ export function searchUserByMobile(mobile) {
},
success: (res) => {
console.log(res);
if (res.statusCode === 200) {
resolve(res.data);
} else {
@ -31,63 +27,6 @@ export function searchUserByMobile(mobile) {
}
//——————————————————————————————————————————————————————时间————————————————————————————————————————————————————
// 数字补零工具函数(辅助日期格式化)
const formatNumber = n => {
n = n.toString()// 1. 将数字转为字符串(如 5 → "5"12 → "12"
return n[1] ? n : '0' + n// 2. 补零逻辑:若字符串长度<2如 "5"),则在前面加"0" → "05";否则直接返回(如 "12"
}
//时间格式化函数(仅返回时分秒)
const formatTime = date => {
date = new Date(date);// 1. 将输入的日期参数(可能是时间戳/字符串)转为标准 Date 对象
const hour = date.getHours()// 2. 获取小时0-23
const minute = date.getMinutes()// 3. 获取分钟0-59
const second = date.getSeconds()// 4. 获取秒0-59
// 5. 拼接为 "时:分:秒" 格式(调用 formatNumber 补零),如 "09:05:03
return `${formatNumber(hour)}:${formatNumber(minute)}:${formatNumber(second)}`
}
//日期格式化函数(仅返回年月日)
const formatDate = date => {
date = new Date(date);// 1. 转为标准 Date 对象
const year = date.getFullYear()// 2. 获取完整年份(如 2024
const month = date.getMonth() + 1// 3. 获取月份注意Date 中月份是 0-11需+1 才是实际月份,如 2→3月
const day = date.getDate()// 4. 获取日期1-31
// 5. 拼接为 "年-月-日" 格式(补零),如 "2024-03-15"
return `${year}-${formatNumber(month)}-${formatNumber(day)}`
}
//完整日期时间格式化函数(年月日 + 时分秒)
const formatDateTime = date => {
return `${formatDate(date)} ${formatTime(date)}`
}
//——————————————————————————————————————————————————————————————————————————————————————————————————————————————————
//用户信息初始化与判断
let staff = uni.getStorageSync('staff'); // 1. 从 UniApp 本地缓存中读取 "staff"用户信息如ID、医院ID、手机号等
let staffisnull = (staff == null || staff == undefined || staff == '');// 2. 判断用户信息是否为空null/undefined/空字符串)
//统一请求头配置函数(核心!!!!!!!!!!!!!!!!!!!!所有接口共用)
export function getHeaders() {
const staff = uni.getStorageSync('staff') || null// 1. 再次读取用户信息(避免缓存过期,确保最新)
// console.log("用户信息:",staff)// 2. 控制台打印用户信息,用于开发调试
const staffisnull = !staff // 3. 简化判断:用户信息为空则为 true否则为 false
// 4. 返回请求头对象(接口请求时会携带这些信息,供后端验证)
return {
'content-type': 'application/json',// ① 声明请求体格式为 JSON后端需按 JSON 解析数据)
//'Authorization': staffisnull ? null : `Bearer ${uni.getStorageSync('token')}`,
'hosp-ID': staffisnull ? config.defaultHospId : staff.hosp_id,// ③ 医院ID用户未登录用配置文件的默认值登录后用用户所属医院ID
'user-ID': staffisnull ? null : staff.id,// ④ 用户ID用户未登录为 null登录后用用户ID后端用于识别请求用户
'datetime': formatDateTime(new Date()),// ⑤ 请求时间:当前完整日期时间(后端可用于校验请求时效性)
'createby': staffisnull ? null : staff.id// ⑥ 创建人ID与 user-ID 类似,用于后端记录操作人
}////核心作用:统一所有接口的请求头格式,避免每个接口重复写头信息,同时支持 “登录 / 未登录” 状态的动态适配。
}
//————————————————————————————————————————————————————固定资产相关————————————————————————————————————————————————————————
//分页请求固定资产数据
//作用:前端加载固定资产列表时调用(如 “资产列表页” 分页加载数据)。
@ -104,11 +43,11 @@ export function fetchFixedAssets(pageNum, pageSize) {
url: `${BASE_URL}/api/assetInformation/page?pageNum=${pageNum}&pageSize=${pageSize}`,// 接口路径:拼接基础地址+分页参数pageNum=页码pageSize=每页条数)
method: 'POST',// 请求方法POST
data: {// 请求体:空查询条件(后端可能支持按条件筛选,此处默认无筛选)
query:{},
query: {},
},
header:getHeaders(),// 请求头:调用上面的 getHeaders() 获取统一头信息
header: getHeaders(),// 请求头:调用上面的 getHeaders() 获取统一头信息
success: (res) => { // 请求成功回调(后端有响应,无论业务成功与否)
if (res.statusCode === 200) {// 若 HTTP 状态码为 200表示网络请求成功
resolve(res.data); // 根据你的接口返回结构调整 // 将后端返回的业务数据传给前端(前端用 .then() 接收)
@ -133,11 +72,11 @@ export function searchFixedAssets(name) {
url: `${BASE_URL}/api/assetInformation/list`,// 接口路径:资产搜索接口
method: 'POST',
data: {
assetName:name // 请求体传递搜索关键词assetName=资产名称)
assetName: name // 请求体传递搜索关键词assetName=资产名称)
},
header:getHeaders(),
header: getHeaders(),
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
@ -159,12 +98,12 @@ export function fetchAssetInfoById(id) {
uni.request({
url: `${BASE_URL}/api/assetInformation/list`, // 接口路径:获取单条资产信息
method: 'POST',
header:getHeaders(),
data:{
"assetSn":id // 请求体传递资产编号assetSn=资产唯一编号)
},
header: getHeaders(),
data: {
"assetSn": id // 请求体传递资产编号assetSn=资产唯一编号)
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
@ -184,7 +123,7 @@ export function fetchMaintenanceInfoById(id) {
uni.request({
url: `${BASE_URL}/api/asset/maintenance/getByAssetId/${id}`, // 接口路径拼接资产IDRESTful 风格)
method: 'GET', // 请求方法GET此处用 GET 传递路径参数)
header:getHeaders(),
header: getHeaders(),
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
@ -208,17 +147,17 @@ export function borrowAssetById(addDTO) {
method: 'POST',
header: getHeaders(),
data: { // 请求体:传递借用所需的所有参数(从前端表单收集的信息)
assetId: addDTO.assetId, // 资产ID
assetName: addDTO.assetName, // 资产名称
borrowDate: addDTO.borrowDate,// 借用日期(需前端传递格式化后的日期)
borrowerId: addDTO.borrowerId,// 借用人ID
borrowerMobile: addDTO.borrowerMobile, // 借用人手机号
lenderId: addDTO.lenderId,// 出借人ID
lenderMobile: addDTO.lenderMobile,// 出借人手机号
note: addDTO.note,// 备注(可选)
registrantDate: addDTO.registrantDate,// 登记日期
registrantId: addDTO.registrantId,// 登记人ID
returnDate: addDTO.returnDate// 预计归还日期
assetId: addDTO.assetId, // 资产ID
assetName: addDTO.assetName, // 资产名称
borrowDate: addDTO.borrowDate,// 借用日期(需前端传递格式化后的日期)
borrowerId: addDTO.borrowerId,// 借用人ID
borrowerMobile: addDTO.borrowerMobile, // 借用人手机号
lenderId: addDTO.lenderId,// 出借人ID
lenderMobile: addDTO.lenderMobile,// 出借人手机号
note: addDTO.note,// 备注(可选)
registrantDate: addDTO.registrantDate,// 登记日期
registrantId: addDTO.registrantId,// 登记人ID
returnDate: addDTO.returnDate// 预计归还日期
},
success: (res) => {
if (res.statusCode === 200) {
@ -244,7 +183,7 @@ export function borrowInfo(borrowerId) {
method: 'POST',
header: getHeaders(),
data: {
borrowerId:borrowerId // 请求体传递借用人ID查询该用户的所有借用记录
borrowerId: borrowerId // 请求体传递借用人ID查询该用户的所有借用记录
},
success: (res) => {
if (res.statusCode === 200) {
@ -268,7 +207,7 @@ export function returnAsset(id) {
uni.request({
url: `${BASE_URL}/api/asset/borrow/returnAsset?assetId=${id}`, // 接口路径拼接资产IDURL参数
method: 'PUT', // 请求方法PUT通常用于“更新资源状态”此处更新资产为“已归还”
header:getHeaders(),
header: getHeaders(),
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
@ -285,124 +224,18 @@ export function returnAsset(id) {
//————————————————————————————————————————————————业务接口函数(易耗品相关)——————————————————————————————————————————
//易耗品领用
//作用:前端提交 “易耗品领用表单” 时调用(完成领用申请)。
export function getConsumption(addDTO) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/consumable-distribution-item/add`, //接口路径:易耗品领用提交
method: 'POST',
header:getHeaders(),
data:{ // 请求体易耗品领用所需参数数量、组别ID、备注等
actualQuantity:addDTO.actualQuantity, // 实际领用数量
groupId:addDTO.groupId,// 所属组别ID
notes:addDTO.notes,// 备注
planId:addDTO.planId,// 发放计划ID
recipientMobile:addDTO.recipientMobile,// 领取人手机号
skuId:addDTO.skuId// 易耗品SKU编号唯一标识
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//根据条码查询sku Id
export function getConsumptionSkuId(barcode) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/consumable/sku/list`,// 接口路径条码查询SKU
method: 'POST',
header:getHeaders(),
data:{
barcode:barcode // 请求体传递易耗品条码通过条码获取对应的SKU ID
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//查询商品库存
export function getConsumptionQuantity(goodsInfo) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/consumable-inventory/findByCondition`, // 接口路径:库存查询
method: 'POST',
header:getHeaders(),
data:{ // 请求体库存查询条件SKU ID、仓库ID、位置ID
skuId:goodsInfo.skuId,
locationId:goodsInfo.locationId,
warehouseId:goodsInfo.warehouseId
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//——————————————————————————————————————————易耗品领用计划??????????????????????????????
//获取组别信息
export function getGroupsInfo(mobile) { //参数 mobile 表示“组长手机号”
return new Promise((resolve, reject) => { // 返回 Promise 对象,支持异步调用(用 async/await 或 .then() 处理结果)
// 调用 UniApp 的网络请求 API
uni.request({
// 请求地址:拼接基础地址 + 易耗品组别查询接口
// 接口作用:根据条件查询易耗品组别(此处条件是组长手机号)
url: `${BASE_URL}/api/consumable-groups/findByCondition`,
method: 'POST',
header:getHeaders(), // 请求头:使用统一的 getHeaders() 配置(携带用户、医院等信息)
data:{ // 请求体:传递查询条件
groupHeadMobile:mobile // 按“组长手机号”查询对应的组别(如组长管理的易耗品分组)
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//获取发放计划信息
export function getPlanInfo(id,pageNum, pageSize) {
export function getPlanInfo(id, pageNum, pageSize) {
return new Promise((resolve, reject) => {
uni.request({
// 请求地址:拼接基础地址 + 发放计划接口 + 分页参数pageNum/pageSize
// 请求地址:拼接基础地址 + 发放计划接口 + 分页参数pageNum/pageSize
url: `${BASE_URL}/api/consumable-distribution-plan/findByCondition?pageNum=${pageNum}&pageSize=${pageSize}`,
method: 'POST',
header:getHeaders(),
data:{
hospId:id // 按“医院ID”查询该医院的易耗品发放计划如“2024年Q1耗材发放计划”
},
header: getHeaders(),
data: {
hospId: id // 按“医院ID”查询该医院的易耗品发放计划如“2024年Q1耗材发放计划”
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
@ -420,13 +253,13 @@ export function getPlanInfo(id,pageNum, pageSize) {
export function getConsumptionDetail(id) {
return new Promise((resolve, reject) => {
uni.request({
// 请求地址:拼接基础地址 + 领用详情接口 + 领用记录IDRESTful风格
// 请求地址:拼接基础地址 + 领用详情接口 + 领用记录IDRESTful风格
url: `${BASE_URL}/api/consumable-distribution-item/app_getDistributeItem/${id}`,
method: 'GET', // 请求方法GET查询详情常用GET
header:getHeaders(),
data:{
// GET请求无需请求体参数通过URL路径传递
},
header: getHeaders(),
data: {
// GET请求无需请求体参数通过URL路径传递
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
@ -446,10 +279,10 @@ export function consumptonConfirm(id) {
uni.request({
url: `${BASE_URL}/api/consumable-distribution-item/app_getDistributeItem/${id}`,
method: 'POST', // 请求方法POST用于更新状态确认领用
header:getHeaders(),
data:{
},
header: getHeaders(),
data: {
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
@ -467,50 +300,18 @@ export function consumptonConfirm(id) {
//————————————————————————————————————————————————资产盘点————————————————————————————————————————————————————————————
//新增固定资产盘点
export function addInventory(addDTO) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/asset/inventory/add`, // 接口路径:固定资产盘点记录提交接口
method: 'POST', // 请求方法POST新增数据用POST
data: { // 请求体:盘点记录所需的核心参数(从前端盘点表单收集)
assetId:addDTO.assetId, // 被盘点的固定资产ID
assetName:addDTO.assetName, // 固定资产名称(冗余字段,便于后端校验)
inventoryDate:addDTO.inventoryDate, // 盘点日期(格式化后的日期时间)
inventoryManId:addDTO.inventoryManId, // 盘点人ID当前操作用户ID
inventoryManMobile:addDTO.inventoryManMobile, // 盘点人手机号(用于身份校验)
inventoryPic:addDTO.inventoryPic, // 盘点照片可选如资产实物照片通常是Base64或图片URL
inventoryPlanId:addDTO.inventoryPlanId, // 所属盘点计划ID关联到具体的盘点任务
inventoryResult:addDTO.inventoryResult, // 盘点结果(如“正常”“缺失”“损坏”等枚举值)
note:addDTO.note // 盘点备注(可选,记录特殊情况,如“资产外观有划痕”)
},
header:getHeaders(), // 统一请求头(携带用户、医院等信息)
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 成功时返回后端的盘点记录ID或成功信息
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//获取固定资产盘点计划信息
export function getInventoryPlan(pageNum, pageSize) {
return new Promise((resolve, reject) => {
uni.request({
// 接口路径固定资产盘点计划分页接口拼接页码pageNum和每页条数pageSize
// 接口路径固定资产盘点计划分页接口拼接页码pageNum和每页条数pageSize
url: `${BASE_URL}/api/asset/inventoryPlan/page?pageNum=${pageNum}&pageSize=${pageSize}`,
method: 'POST',
header:getHeaders(),
data:{
// 请求体:空(后端可能默认返回当前用户有权限的盘点计划,无需额外筛选条件)
},
header: getHeaders(),
data: {
// 请求体:空(后端可能默认返回当前用户有权限的盘点计划,无需额外筛选条件)
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
@ -529,14 +330,14 @@ export function getInventoryPlan(pageNum, pageSize) {
export function getInventoryDetailEnd(id) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/asset/inventory/app_getInventoryPlan/${id}`,
// 接口路径拼接盘点计划ID专门获取“进行中”计划的详情与已结束计划接口区分开
// 接口路径拼接盘点计划ID专门获取“进行中”计划的详情与已结束计划接口区分开
method: 'GET',
header:getHeaders(),
data:{
},
header: getHeaders(),
data: {
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 返回进行中计划的详情(如待盘点资产列表、已盘点进度)
@ -555,13 +356,13 @@ export function getInventoryDetailEnd(id) {
export function getInventoryDetailIng(id) {
return new Promise((resolve, reject) => {
uni.request({
// 接口路径拼接盘点计划ID专门获取“进行中”计划的详情与已结束计划接口区分开
// 接口路径拼接盘点计划ID专门获取“进行中”计划的详情与已结束计划接口区分开
url: `${BASE_URL}/api/asset/inventory/app_getInventoryPlan2/${id}`,
method: 'GET',
header:getHeaders(),
data:{
},
header: getHeaders(),
data: {
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
@ -582,16 +383,16 @@ export function inventory(addDTO) {
uni.request({
url: `${BASE_URL}/api/asset/inventory/add`, // 与“addInventory”接口路径相同可能是后端兼容的简化版
method: 'POST',
header:getHeaders(),
data:{ // 请求体简化后的参数去掉了assetName、note等非必填项inventoryDate注释后可能由后端自动填充
assetId:addDTO.assetId, // 被盘点资产ID必填
inventoryManId:addDTO.inventoryManId, // 盘点人ID必填
inventoryManMobile:addDTO.inventoryManMobile, // 盘点人手机号(必填)
inventoryPic:addDTO.inventoryPic, // 盘点照片(可选)
inventoryPlanId:addDTO.inventoryPlanId, // 所属计划ID必填
inventoryResult:addDTO.inventoryResult, // 盘点结果(必填)
//inventoryDate:addDTO.inventoryDate // 注释后,后端可能自动用当前时间作为盘点日期
},
header: getHeaders(),
data: { // 请求体简化后的参数去掉了assetName、note等非必填项inventoryDate注释后可能由后端自动填充
assetId: addDTO.assetId, // 被盘点资产ID必填
inventoryManId: addDTO.inventoryManId, // 盘点人ID必填
inventoryManMobile: addDTO.inventoryManMobile, // 盘点人手机号(必填)
inventoryPic: addDTO.inventoryPic, // 盘点照片(可选)
inventoryPlanId: addDTO.inventoryPlanId, // 所属计划ID必填
inventoryResult: addDTO.inventoryResult, // 盘点结果(必填)
//inventoryDate:addDTO.inventoryDate // 注释后,后端可能自动用当前时间作为盘点日期
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
@ -612,13 +413,13 @@ export function inventory(addDTO) {
export function getConsumpationPlan(pageNum, pageSize) {
return new Promise((resolve, reject) => {
uni.request({
// 接口路径:易耗品盘点计划分页接口,名称区分于固定资产计划
// 接口路径:易耗品盘点计划分页接口,名称区分于固定资产计划
url: `${BASE_URL}/api/consumable-check-plan/findByConditionWithPage?pageNum=${pageNum}&pageSize=${pageSize}`,
method: 'POST',
header:getHeaders(),
data:{
// 空请求体(后端默认返回当前用户的易耗品盘点计划)
},
header: getHeaders(),
data: {
// 空请求体(后端默认返回当前用户的易耗品盘点计划)
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 返回易耗品盘点计划的分页数据
@ -636,13 +437,16 @@ export function getConsumpationPlan(pageNum, pageSize) {
export function getConsumpationInventoryDetailEnd(id) {
return new Promise((resolve, reject) => {
uni.request({
// 接口路径拼接易耗品盘点计划ID专门获取“已结束”计划的详情
// 接口路径拼接易耗品盘点计划ID专门获取“已结束”计划的详情
url: `${BASE_URL}/api/consumable-check-item/app_getInventoryPlan/${id}`,
method: 'GET',
header:getHeaders(),
data:{
},
header: {
...getHeaders()
},
data: {
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 返回已结束计划的详情(如易耗品盘点总数、盈亏统计)
@ -661,13 +465,13 @@ export function getConsumpationInventoryDetailEnd(id) {
export function getConsumpationInventoryDetailIng(id) {
return new Promise((resolve, reject) => {
uni.request({
// 接口路径拼接易耗品盘点计划ID专门获取“进行中”计划的详情
// 接口路径拼接易耗品盘点计划ID专门获取“进行中”计划的详情
url: `${BASE_URL}/api/consumable-check-item/app_getInventoryPlan2/${id}`,
method: 'GET',
header:getHeaders(),
data:{
},
header: getHeaders(),
data: {
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 返回进行中计划的详情(如待盘点易耗品列表、已盘点进度)
@ -689,14 +493,14 @@ export function addComInventory(addDTO) {
uni.request({
url: `${BASE_URL}/api/consumable-check-item/add`, // 接口路径:易耗品盘点记录提交接口
method: 'POST',
header:getHeaders(),
data:{ // 请求体:易耗品盘点的核心参数(与固定资产盘点参数差异在于“数量”字段)
checkPlanId:addDTO.checkPlanId, // 所属易耗品盘点计划ID
checkResult:addDTO.checkResult, // 盘点结果(如“账实相符”“盘盈”“盘亏”)
inventoryId:addDTO.inventoryId, // 库存ID关联到具体的易耗品库存记录
quantity:addDTO.quantity // 实际盘点数量(易耗品需统计数量,固定资产通常是“有无”)
},
header: getHeaders(),
data: { // 请求体:易耗品盘点的核心参数(与固定资产盘点参数差异在于“数量”字段)
checkPlanId: addDTO.checkPlanId, // 所属易耗品盘点计划ID
checkResult: addDTO.checkResult, // 盘点结果(如“账实相符”“盘盈”“盘亏”)
inventoryId: addDTO.inventoryId, // 库存ID关联到具体的易耗品库存记录
quantity: addDTO.quantity // 实际盘点数量(易耗品需统计数量,固定资产通常是“有无”)
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整

49
src/pages/api/index.ts Normal file
View File

@ -0,0 +1,49 @@
const BASE_URL = 'http://110.42.33.196:8089';//定义后端基础接口地址
import config from '../../config'; // 引入配置文件
const formatNumber = (n: number | string): string => {
n = n.toString();// 1. 将数字转为字符串(如 5 → "5"12 → "12"
return n[1] ? n : '0' + n;// 2. 补零逻辑:若字符串长度<2如 "5"),则在前面加"0" → "05";否则直接返回(如 "12"
}
const formatTime = (date: Date | string | number) => {
date = new Date(date);// 1. 将输入的日期参数(可能是时间戳/字符串)转为标准 Date 对象
const hour: number = date.getHours();// 2. 获取小时0-23
const minute: number = date.getMinutes();// 3. 获取分钟0-59
const second: number = date.getSeconds();// 4. 获取秒0-59
// 5. 拼接为 "时:分:秒" 格式(调用 formatNumber 补零),如 "09:05:03"
return `${formatNumber(hour)}:${formatNumber(minute)}:${formatNumber(second)}`;
}
//日期格式化函数(仅返回年月日)
const formatDate = (date: Date | string | number) => {
date = new Date(date);// 1. 转为标准 Date 对象
const year = date.getFullYear()// 2. 获取完整年份(如 2024
const month = date.getMonth() + 1// 3. 获取月份注意Date 中月份是 0-11需+1 才是实际月份,如 2→3月
const day = date.getDate()// 4. 获取日期1-31
// 5. 拼接为 "年-月-日" 格式(补零),如 "2024-03-15"
return `${year}-${formatNumber(month)}-${formatNumber(day)}`
}
//完整日期时间格式化函数(年月日 + 时分秒)
const formatDateTime = (date: Date | string | number) => {
return `${formatDate(date)} ${formatTime(date)}`
}
//统一请求头配置函数(核心!!!!!!!!!!!!!!!!!!!!所有接口共用)
function getHeaders() {
const staff = uni.getStorageSync('staff') || null// 1. 再次读取用户信息(避免缓存过期,确保最新)
// console.log("用户信息:",staff)// 2. 控制台打印用户信息,用于开发调试
const staffisnull = !staff // 3. 简化判断:用户信息为空则为 true否则为 false
// 4. 返回请求头对象(接口请求时会携带这些信息,供后端验证)
return {
'content-type': 'application/json',// ① 声明请求体格式为 JSON后端需按 JSON 解析数据)
//'Authorization': staffisnull ? null : `Bearer ${uni.getStorageSync('token')}`,
'hosp-ID': staffisnull ? config.defaultHospId : staff.hosp_id,// ③ 医院ID用户未登录用配置文件的默认值登录后用用户所属医院ID
'user-ID': staffisnull ? null : staff.id,// ④ 用户ID用户未登录为 null登录后用用户ID后端用于识别请求用户
'datetime': formatDateTime(new Date()),// ⑤ 请求时间:当前完整日期时间(后端可用于校验请求时效性)
'createby': staffisnull ? null : staff.id// ⑥ 创建人ID与 user-ID 类似,用于后端记录操作人
}////核心作用:统一所有接口的请求头格式,避免每个接口重复写头信息,同时支持 “登录 / 未登录” 状态的动态适配。
}
export { formatTime, formatDate, formatDateTime, BASE_URL, getHeaders };// 导出时间格式化函数、BASE_URL 和 getHeaders 供其他模块使用

View File

@ -1,46 +1,4 @@
const BASE_URL = 'http://110.42.33.196:8089';
const config = require('config.js');
const formatTime = date => {
date = new Date(date);
const hour = date.getHours()
const minute = date.getMinutes()
const second = date.getSeconds()
return `${formatNumber(hour)}:${formatNumber(minute)}:${formatNumber(second)}`
}
const formatNumber = n => {
n = n.toString()
return n[1] ? n : '0' + n
}
const formatDate = date => {
date = new Date(date);
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
return `${year}-${formatNumber(month)}-${formatNumber(day)}`
}
const formatDateTime = date => {
return `${formatDate(date)} ${formatTime(date)}`
}
let staff = uni.getStorageSync('staff');
let staffisnull = (staff == null || staff == undefined || staff == '');
export function getHeaders() {
const staff = uni.getStorageSync('staff') || null
const staffisnull = !staff
return {
'content-type': 'application/json',
'Authorization': staffisnull ? null : `Bearer ${uni.getStorageSync('token')}`,
'hosp-ID': staffisnull ? config.defaultHospId : staff.hosp_id,
'user-ID': staffisnull ? null : staff.id,
'datetime': formatDateTime(new Date()),
'createby': staffisnull ? null : staff.id
}
}
import { formatTime, formatDate, formatDateTime, BASE_URL ,getHeaders} from './index.js'; // 引入时间格式化函数和BASE_URL
//分页请求固定资产盘点信息
export function fetchAssetInventory(pageNum, pageSize) {
@ -49,11 +7,11 @@ export function fetchAssetInventory(pageNum, pageSize) {
url: `${BASE_URL}/api/asset/inventory/page?pageNum=${pageNum}&pageSize=${pageSize}`,
method: 'POST',
data: {
query:{},
query: {},
},
header:getHeaders(),
header: getHeaders(),
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整

54
src/pages/api/user.ts Normal file
View File

@ -0,0 +1,54 @@
import { formatTime, formatDate, formatDateTime, BASE_URL, getHeaders } from './index.js'; // 引入时间格式化函数和BASE_URL
//@ts-ignore
import md5 from '@/utils/md5.js'
export function login(username: string, password: string): Promise<{
code: number;
msg: string;
data: {
token: string;
userLoginRequestVO: {
id: number;
user_name: string;
user_login_name: string;
user_type: string;
user_pic_path: string | null;
role_id: number | null;
role_name: string | null;
hosp_id: number | null;
hospName: string | null;
hospPic: string | null;
hospCenterX: number | null;
hospCenterY: number | null;
role: string[] | null;
assetsRole: number | null;
roles: string[] | null;
}
}
}> {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/myUser/login2`,
method: 'POST',// 请求方法POST
data: {
mobile: username,
password: md5.hexMD5(username + password),
role: '007'
},
header: getHeaders(),
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data as any);
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}

View File

@ -0,0 +1,851 @@
<template>
<view class="page">
<scroll-view scroll-y class="content" v-if="!loading">
<!-- 进度条 -->
<view class="progress-section">
<view class="progress-bar">
<view class="progress-step" :class="{ active: true, completed: ['APPROVED', 'COMPLETED', 'REJECTED', 'CANCELLED'].includes(detail.status || '') }">
<view class="step-circle">
<text class="step-icon">{{ ['REJECTED', 'CANCELLED'].includes(detail.status || '') ? '✕' : '✓' }}</text>
</view>
<text class="step-label">待审批</text>
</view>
<view class="progress-line" :class="{ completed: ['APPROVED', 'COMPLETED'].includes(detail.status || '') }"></view>
<view class="progress-step" :class="{ active: ['APPROVED', 'COMPLETED'].includes(detail.status || ''), completed: detail.status === 'COMPLETED' }">
<view class="step-circle">
<text class="step-icon"></text>
</view>
<text class="step-label">待发放</text>
</view>
<view class="progress-line" :class="{ completed: detail.status === 'COMPLETED' }"></view>
<view class="progress-step" :class="{ active: detail.status === 'COMPLETED', completed: detail.status === 'COMPLETED' }">
<view class="step-circle">
<text class="step-icon"></text>
</view>
<text class="step-label">已完成</text>
</view>
</view>
</view>
<!-- 状态卡片 -->
<view class="status-card">
<view class="status-badge" :class="`status-${detail.status?.toLowerCase()}`">
{{ getStatusText(detail.status) }}
</view>
<text class="status-time">{{ formatTime(detail.createdAt) }}</text>
</view>
<!-- 申请人信息 -->
<view class="section">
<view class="section-title">申请人信息</view>
<view class="applicant-card">
<view class="avatar">
<text class="avatar-text">{{ getAvatarText(detail.applicantName) }}</text>
</view>
<view class="applicant-info">
<text class="name">{{ detail.applicantName || '未填写' }}</text>
<text class="phone">{{ detail.applicantPhone }}</text>
<text class="department" v-if="detail.department">{{ detail.department }}</text>
</view>
</view>
</view>
<!-- 申请信息 -->
<view class="section">
<view class="section-title">申请信息</view>
<view class="info-card">
<view class="info-row">
<text class="label">申请原因</text>
<text class="value">{{ detail.reason || '-' }}</text>
</view>
<view class="info-row">
<text class="label">需要时间</text>
<text class="value">{{ detail.neededAt || '-' }}</text>
</view>
<view class="info-row" v-if="detail.approverId">
<text class="label">审批人ID</text>
<text class="value">{{ detail.approverId }}</text>
</view>
<view class="info-row" v-if="detail.approvedAt">
<text class="label">审批时间</text>
<text class="value">{{ formatTime(detail.approvedAt) }}</text>
</view>
<view class="info-row" v-if="detail.remark">
<text class="label">审批意见</text>
<text class="value remark">{{ detail.remark }}</text>
</view>
</view>
</view>
<!-- 物料明细 -->
<view class="section">
<view class="section-title">物料明细{{ detail.items?.length || 0 }}</view>
<view v-for="(item, index) in detail.items" :key="item.id" class="item-card">
<view class="item-header">
<text class="item-number">#{{ index + 1 }}</text>
<text class="item-name">{{ item.name }}</text>
</view>
<view class="item-details">
<view class="detail-row" v-if="item.spec">
<text class="detail-label">规格</text>
<text class="detail-value">{{ item.spec }}</text>
</view>
<view class="detail-row">
<text class="detail-label">数量</text>
<text class="detail-value highlight">{{ item.quantity }} {{ item.unit || '' }}</text>
</view>
<view class="detail-row" v-if="item.estimatedUnitCost">
<text class="detail-label">预估单价</text>
<text class="detail-value">¥{{ item.estimatedUnitCost }}</text>
</view>
<view class="detail-row" v-if="item.estimatedUnitCost && item.quantity">
<text class="detail-label">小计</text>
<text class="detail-value total">¥{{ (item.estimatedUnitCost * item.quantity).toFixed(2) }}</text>
</view>
</view>
<view class="item-remark" v-if="item.remark">
<text class="remark-label">备注</text>
<text class="remark-text">{{ item.remark }}</text>
</view>
</view>
</view>
<!-- 合计 -->
<view class="section" v-if="totalCost > 0">
<view class="total-card">
<text class="total-label">预估总金额</text>
<text class="total-value">¥{{ totalCost.toFixed(2) }}</text>
</view>
</view>
</scroll-view>
<view v-if="loading" class="loading-container">
<text>加载中...</text>
</view>
<!-- 底部操作按钮 -->
<view class="footer-actions" v-if="!loading && detail.status === 'PENDING'">
<button class="btn-reject" @tap="handleReject">拒绝</button>
<button class="btn-approve" @tap="handleApprove">通过</button>
</view>
<view class="footer-actions" v-if="!loading && detail.status === 'APPROVED'">
<button class="btn-deliver" @tap="handleDeliver">确认发放</button>
</view>
<!-- 审批弹窗 -->
<view v-if="showApproveModal" class="modal-mask" @tap="closeApproveModal">
<view class="modal-content" @tap.stop>
<view class="modal-header">
<text class="modal-title">{{ approveType === 'APPROVED' ? '通过申请' : '拒绝申请' }}</text>
<text class="modal-close" @tap="closeApproveModal"></text>
</view>
<view class="modal-body">
<view class="form-item">
<text class="form-label">审批意见</text>
<textarea class="form-textarea" v-model="approveRemark"
:placeholder="approveType === 'APPROVED' ? '请输入通过意见(可选)' : '请输入拒绝原因'"
maxlength="200" />
</view>
</view>
<view class="modal-footer">
<button class="btn-cancel" @tap="closeApproveModal">取消</button>
<button class="btn-confirm"
:class="approveType === 'REJECTED' ? 'btn-danger' : ''"
@tap="confirmApprove"
:disabled="submitting">
{{ submitting ? '提交中...' : '确认' }}
</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import consumableTempAPI, { type ConsumableRequest } from '@/pages/api/consumable-temp'
import { refreshNow } from '@/utils/messageManager'
const detail = ref<ConsumableRequest>({
applicantPhone: '',
items: []
})
const loading = ref(true)
const requestId = ref('')
//
const showApproveModal = ref(false)
const approveType = ref<'APPROVED' | 'REJECTED' | 'COMPLETED'>('APPROVED')
const approveRemark = ref('')
const submitting = ref(false)
//
function getStatusText(status?: string) {
const statusMap: Record<string, string> = {
'PENDING': '待审批',
'APPROVED': '待发放',
'REJECTED': '已拒绝',
'CANCELLED': '已取消',
'COMPLETED': '已完成'
}
return statusMap[status || ''] || '未知'
}
//
const totalCost = computed(() => {
return detail.value.items?.reduce((sum, item) => {
const cost = (item.estimatedUnitCost || 0) * (item.quantity || 0)
return sum + cost
}, 0) || 0
})
//
async function loadDetail() {
if (!requestId.value) return
loading.value = true
try {
const res = await consumableTempAPI.getRequestDetail(requestId.value)
if (res.statusCode === 200 && (res.data as any)?.data) {
detail.value = (res.data as any).data
} else {
uni.showToast({ title: '加载失败', icon: 'none' })
}
} catch (err) {
console.error('加载详情失败:', err)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
//
function formatTime(time?: string) {
if (!time) return '-'
const date = new Date(time)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
//
function getAvatarText(name?: string) {
if (!name) return '?'
return name.substring(0, 1)
}
//
function handleApprove() {
approveType.value = 'APPROVED'
approveRemark.value = ''
showApproveModal.value = true
}
//
function handleReject() {
approveType.value = 'REJECTED'
approveRemark.value = ''
showApproveModal.value = true
}
//
function handleDeliver() {
uni.showModal({
title: '确认发放',
content: '确认已发放该申请的易耗品?',
success: async (res) => {
if (res.confirm) {
try {
const result = await consumableTempAPI.updateRequestStatus(requestId.value, {
status: 'COMPLETED'
})
if (result.statusCode === 200) {
uni.showToast({
title: '已完成发放',
icon: 'success',
success: () => {
setTimeout(() => {
//
uni.$emit('tempApprove:changed')
uni.$emit('tempMessage:changed')
uni.navigateBack()
}, 500)
}
})
//
refreshNow()
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
} catch (err) {
console.error('发放失败:', err)
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
}
})
}
//
function closeApproveModal() {
showApproveModal.value = false
approveRemark.value = ''
}
//
async function confirmApprove() {
if (submitting.value) return
//
if (approveType.value === 'REJECTED' && !approveRemark.value.trim()) {
uni.showToast({ title: '请输入拒绝原因', icon: 'none' })
return
}
submitting.value = true
try {
const staff = uni.getStorageSync('staff')
const res = await consumableTempAPI.updateRequestStatus(requestId.value, {
status: approveType.value,
approverId: staff?.id || undefined,
remark: approveRemark.value.trim() || undefined
})
if (res.statusCode === 200) {
uni.showToast({
title: approveType.value === 'APPROVED' ? '已通过' : '已拒绝',
icon: 'success',
success: () => {
setTimeout(() => {
//
uni.$emit('tempApprove:changed')
uni.$emit('tempMessage:changed')
uni.navigateBack()
}, 500)
}
})
closeApproveModal()
//
refreshNow()
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
} catch (err) {
console.error('审批失败:', err)
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
submitting.value = false
}
}
//
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1] as any
const options = currentPage.options || {}
if (options.id) {
requestId.value = options.id
loadDetail()
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f7fb;
display: flex;
flex-direction: column;
}
.content {
flex: 1;
padding: 24rpx 32rpx 140rpx;
box-sizing: border-box;
}
.loading-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #999;
}
/* 进度条区域 */
.progress-section {
margin-bottom: 24rpx;
}
.progress-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.progress-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
flex: 0 0 auto;
}
.step-circle {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.progress-step.active .step-circle {
background: #fff3e0;
border: 3rpx solid #f57c00;
}
.progress-step.completed .step-circle {
background: #4caf50;
border: 3rpx solid #4caf50;
}
.step-icon {
font-size: 28rpx;
color: #fff;
display: none;
}
.progress-step.completed .step-icon {
display: block;
}
.step-label {
font-size: 24rpx;
color: #999;
white-space: nowrap;
}
.progress-step.active .step-label {
color: #f57c00;
font-weight: 600;
}
.progress-step.completed .step-label {
color: #4caf50;
font-weight: 500;
}
.progress-line {
flex: 1;
height: 3rpx;
background: #e0e0e0;
margin: 0 12rpx;
transition: all 0.3s ease;
}
.progress-line.completed {
background: #4caf50;
}
.status-card {
background: #3a5ddd;
border-radius: 20rpx;
padding: 32rpx;
margin-bottom: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.status-badge {
padding: 12rpx 32rpx;
border-radius: 24rpx;
font-size: 32rpx;
font-weight: 600;
background: rgba(255, 255, 255, 0.9);
}
.status-pending {
color: #f57c00;
}
.status-approved {
color: #2e7d32;
}
.status-rejected {
color: #c62828;
}
.status-cancelled {
color: #757575;
}
.status-completed {
color: #1976d2;
}
.status-time {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
}
.section {
margin-bottom: 32rpx;
}
.section-title {
padding: 0 12rpx 16rpx;
font-size: 32rpx;
font-weight: 600;
color: #111;
}
.applicant-card {
background: #fff;
border-radius: 20rpx;
padding: 32rpx;
display: flex;
align-items: center;
gap: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background: #3a5ddd;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.avatar-text {
font-size: 40rpx;
font-weight: 600;
color: #fff;
}
.applicant-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.name {
font-size: 32rpx;
font-weight: 600;
color: #111;
}
.phone {
font-size: 28rpx;
color: #666;
}
.department {
font-size: 26rpx;
color: #999;
}
.info-card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.info-row {
display: flex;
padding: 16rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.info-row:last-child {
border-bottom: none;
}
.info-row .label {
color: #666;
font-size: 28rpx;
width: 160rpx;
flex-shrink: 0;
}
.info-row .value {
color: #333;
font-size: 28rpx;
flex: 1;
}
.info-row .value.remark {
color: #f57c00;
font-weight: 500;
}
.item-card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 16rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.item-header {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
padding-bottom: 12rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.item-number {
font-size: 24rpx;
font-weight: 600;
color: #4b7aff;
padding: 4rpx 12rpx;
background: #e3f2fd;
border-radius: 6rpx;
}
.item-name {
font-size: 30rpx;
font-weight: 600;
color: #111;
flex: 1;
}
.item-details {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.detail-row {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.detail-label {
font-size: 24rpx;
color: #999;
}
.detail-value {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
.detail-value.highlight {
color: #4b7aff;
font-weight: 600;
}
.detail-value.total {
color: #ff6b6b;
font-weight: 600;
}
.item-remark {
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid #f5f5f5;
font-size: 26rpx;
}
.remark-label {
color: #999;
}
.remark-text {
color: #666;
}
.total-card {
background: #f9a825;
border-radius: 20rpx;
padding: 32rpx;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 8rpx 24rpx rgba(249, 168, 37, 0.2);
}
.total-label {
font-size: 32rpx;
font-weight: 600;
color: #fff;
}
.total-value {
font-size: 40rpx;
font-weight: 700;
color: #fff;
}
.footer-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 24rpx 32rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.06);
display: flex;
gap: 16rpx;
}
.btn-reject,
.btn-approve,
.btn-deliver {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 16rpx;
font-size: 32rpx;
font-weight: 600;
}
.btn-reject {
background: #ef5350;
color: #fff;
}
.btn-approve {
background: #3a5ddd;
color: #fff;
}
.btn-deliver {
background: #4caf50;
color: #fff;
}
/* 弹窗样式 */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 600rpx;
max-height: 70vh;
background: #fff;
border-radius: 24rpx;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 32rpx;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1rpx solid #f0f0f0;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #111;
}
.modal-close {
font-size: 40rpx;
color: #999;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-body {
flex: 1;
padding: 32rpx;
overflow-y: auto;
}
.form-item {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 12rpx;
}
.form-textarea {
width: 100%;
min-height: 200rpx;
padding: 16rpx;
background: #f5f7fb;
border-radius: 12rpx;
font-size: 28rpx;
color: #333;
}
.modal-footer {
padding: 24rpx 32rpx;
border-top: 1rpx solid #f0f0f0;
display: flex;
gap: 16rpx;
}
.btn-cancel,
.btn-confirm {
flex: 1;
height: 80rpx;
line-height: 80rpx;
border-radius: 12rpx;
font-size: 30rpx;
font-weight: 500;
}
.btn-cancel {
background: #f5f5f5;
color: #666;
}
.btn-confirm {
background: #3a5ddd;
color: #fff;
}
.btn-confirm.btn-danger {
background: #ef5350;
}
.btn-confirm[disabled] {
opacity: 0.6;
}
</style>

View File

@ -0,0 +1,798 @@
<template>
<view class="page">
<!-- 顶部标题栏 -->
<view class="header">
<text class="title">临时易耗品审批</text>
</view>
<!-- 筛选区 -->
<view class="filter-bar">
<view class="switch-item">
<text class="switch-label">显示已完成</text>
<switch :checked="showCompleted" @change="onShowCompletedChange" color="#3a5ddd" />
</view>
</view>
<!-- 申请列表 -->
<scroll-view scroll-y class="list-container" @scrolltolower="loadMore">
<view v-if="list.length === 0 && !loading" class="empty">
<image class="empty-icon" src="/static/icons/consumables-temp-approve.png" mode="aspectFit" />
<text class="empty-text">暂无审批记录</text>
</view>
<view v-for="item in list" :key="item.id" class="card" @tap="goToApprove(item.id)">
<!-- 进度条 -->
<view class="progress-bar">
<view class="progress-step" :class="{ active: true, completed: ['APPROVED', 'COMPLETED', 'REJECTED', 'CANCELLED'].includes(item.status || '') }">
<view class="step-circle">
<text class="step-icon">{{ ['REJECTED', 'CANCELLED'].includes(item.status || '') ? '✕' : '✓' }}</text>
</view>
<text class="step-label">待审批</text>
</view>
<view class="progress-line" :class="{ completed: ['APPROVED', 'COMPLETED'].includes(item.status || '') }"></view>
<view class="progress-step" :class="{ active: ['APPROVED', 'COMPLETED'].includes(item.status || ''), completed: item.status === 'COMPLETED' }">
<view class="step-circle">
<text class="step-icon"></text>
</view>
<text class="step-label">待发放</text>
</view>
<view class="progress-line" :class="{ completed: item.status === 'COMPLETED' }"></view>
<view class="progress-step" :class="{ active: item.status === 'COMPLETED', completed: item.status === 'COMPLETED' }">
<view class="step-circle">
<text class="step-icon"></text>
</view>
<text class="step-label">已完成</text>
</view>
</view>
<view class="card-header">
<view class="status-badge" :class="`status-${item.status?.toLowerCase()}`">
{{ getStatusText(item.status) }}
</view>
<text class="time">{{ formatTime(item.createdAt) }}</text>
</view>
<view class="card-body">
<view class="applicant-info">
<view class="avatar">
<text class="avatar-text">{{ getAvatarText(item.applicantName) }}</text>
</view>
<view class="applicant-details">
<text class="name">{{ item.applicantName || '未填写' }}</text>
<text class="phone">{{ item.applicantPhone }}</text>
</view>
</view>
<view class="info-grid">
<view class="grid-item">
<text class="grid-label">部门</text>
<text class="grid-value">{{ item.department || '-' }}</text>
</view>
<view class="grid-item">
<text class="grid-label">物料数量</text>
<text class="grid-value highlight">{{ item.items?.length || 0 }} </text>
</view>
</view>
<view class="reason-box">
<text class="reason-label">申请原因</text>
<text class="reason-text">{{ item.reason || '-' }}</text>
</view>
</view>
<view class="card-footer" v-if="item.status === 'PENDING'">
<button class="btn-reject" @tap.stop="handleReject(item.id)">拒绝</button>
<button class="btn-approve" @tap.stop="handleApprove(item.id)">通过</button>
</view>
<view class="card-footer" v-else-if="item.status === 'APPROVED'">
<button class="btn-deliver" @tap.stop="handleDeliver(item.id)">确认发放</button>
</view>
<view class="card-footer" v-else>
<text class="detail-link">查看详情 </text>
</view>
</view>
<view v-if="loading" class="loading">
<text>加载中...</text>
</view>
<view v-if="!hasMore && list.length > 0" class="no-more">
<text>没有更多了</text>
</view>
</scroll-view>
<!-- 审批弹窗 -->
<view v-if="showApproveModal" class="modal-mask" @tap="closeApproveModal">
<view class="modal-content" @tap.stop>
<view class="modal-header">
<text class="modal-title">{{ approveType === 'APPROVED' ? '通过申请' : '拒绝申请' }}</text>
<text class="modal-close" @tap="closeApproveModal"></text>
</view>
<view class="modal-body">
<view class="form-item">
<text class="form-label">审批意见</text>
<textarea class="form-textarea" v-model="approveRemark"
:placeholder="approveType === 'APPROVED' ? '请输入通过意见(可选)' : '请输入拒绝原因'"
maxlength="200" />
</view>
</view>
<view class="modal-footer">
<button class="btn-cancel" @tap="closeApproveModal">取消</button>
<button class="btn-confirm"
:class="approveType === 'REJECTED' ? 'btn-danger' : ''"
@tap="confirmApprove"
:disabled="submitting">
{{ submitting ? '提交中...' : '确认' }}
</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import consumableTempAPI, { type ConsumableRequest } from '@/pages/api/consumable-temp'
import { refreshNow } from '@/utils/messageManager'
//
const list = ref<ConsumableRequest[]>([])
const loading = ref(false)
const showCompleted = ref(false) //
const page = ref(1)
const pageSize = ref(10)
const hasMore = ref(true)
const needRefresh = ref(false) //
//
const showApproveModal = ref(false)
const approveType = ref<'APPROVED' | 'REJECTED' | 'COMPLETED'>('APPROVED')
const approveRemark = ref('')
const currentApproveId = ref('')
const submitting = ref(false)
//
async function loadList(reset = false) {
if (loading.value) return
if (reset) {
page.value = 1
hasMore.value = true
list.value = []
}
if (!hasMore.value) return
loading.value = true
try {
const res = await consumableTempAPI.getRequestList({
page: page.value,
pageSize: pageSize.value
})
if (res.statusCode === 200 && (res.data as any)?.data) {
let newData = (res.data as any).data
// COMPLETED
if (!showCompleted.value) {
newData = newData.filter((item: ConsumableRequest) => item.status !== 'COMPLETED')
}
list.value = reset ? newData : [...list.value, ...newData]
hasMore.value = newData.length >= pageSize.value
} else {
uni.showToast({ title: '加载失败', icon: 'none' })
}
} catch (err) {
console.error('加载列表失败:', err)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
//
function onShowCompletedChange(e: any) {
showCompleted.value = e.detail.value
loadList(true)
}
//
function loadMore() {
if (!loading.value && hasMore.value) {
page.value++
loadList()
}
}
//
function formatTime(time?: string) {
if (!time) return '-'
const date = new Date(time)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
//
function getStatusText(status?: string) {
const statusMap: Record<string, string> = {
'PENDING': '待审批',
'APPROVED': '待发放',
'REJECTED': '已拒绝',
'CANCELLED': '已取消',
'COMPLETED': '已完成'
}
return statusMap[status || ''] || '未知'
}
//
function getAvatarText(name?: string) {
if (!name) return '?'
return name.substring(0, 1)
}
//
function handleApprove(id?: string) {
if (!id) return
currentApproveId.value = id
approveType.value = 'APPROVED'
approveRemark.value = ''
showApproveModal.value = true
}
//
function handleReject(id?: string) {
if (!id) return
currentApproveId.value = id
approveType.value = 'REJECTED'
approveRemark.value = ''
showApproveModal.value = true
}
//
function handleDeliver(id?: string) {
if (!id) return
uni.showModal({
title: '确认发放',
content: '确认已发放该申请的易耗品?',
success: async (res) => {
if (res.confirm) {
try {
const result = await consumableTempAPI.updateRequestStatus(id, {
status: 'COMPLETED'
})
if (result.statusCode === 200) {
uni.showToast({ title: '已完成发放', icon: 'success' })
loadList(true)
//
refreshNow()
uni.$emit('tempMessage:changed')
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
} catch (err) {
console.error('发放失败:', err)
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
}
})
}
//
function closeApproveModal() {
showApproveModal.value = false
approveRemark.value = ''
currentApproveId.value = ''
}
//
async function confirmApprove() {
if (submitting.value) return
//
if (approveType.value === 'REJECTED' && !approveRemark.value.trim()) {
uni.showToast({ title: '请输入拒绝原因', icon: 'none' })
return
}
submitting.value = true
try {
const staff = uni.getStorageSync('staff')
const res = await consumableTempAPI.updateRequestStatus(currentApproveId.value, {
status: approveType.value,
approverId: staff?.id || undefined,
remark: approveRemark.value.trim() || undefined
})
if (res.statusCode === 200) {
uni.showToast({
title: approveType.value === 'APPROVED' ? '已通过' : '已拒绝',
icon: 'success'
})
closeApproveModal()
loadList(true)
//
refreshNow()
uni.$emit('tempMessage:changed')
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
} catch (err) {
console.error('审批失败:', err)
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
submitting.value = false
}
}
//
function goToApprove(id?: string) {
if (!id) return
uni.navigateTo({
url: `/pages/consumables/temp-approve-detail?id=${id}`
})
}
//
onLoad(() => {
loadList(true)
//
uni.$on('tempApprove:changed', () => {
needRefresh.value = true
})
})
onShow(() => {
if (needRefresh.value) {
loadList(true)
needRefresh.value = false
}
})
onUnload(() => {
uni.$off('tempApprove:changed')
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f7fb;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.header {
background: #fff;
padding: 32rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
.title {
font-size: 36rpx;
font-weight: 600;
color: #111;
}
.filter-bar {
background: #fff;
padding: 24rpx 32rpx;
margin-bottom: 12rpx;
}
.switch-item {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 28rpx;
color: #333;
}
.switch-label {
font-weight: 500;
}
.list-container {
flex: 1;
padding: 0 32rpx 24rpx;
}
.empty {
padding: 120rpx 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.empty-icon {
width: 160rpx;
height: 160rpx;
opacity: 0.3;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
.card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
/* 进度条样式 */
.progress-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
padding: 20rpx;
background: #f8f9fa;
border-radius: 12rpx;
}
.progress-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
flex: 0 0 auto;
}
.step-circle {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.progress-step.active .step-circle {
background: #fff3e0;
border: 3rpx solid #f57c00;
}
.progress-step.completed .step-circle {
background: #4caf50;
border: 3rpx solid #4caf50;
}
.step-icon {
font-size: 24rpx;
color: #fff;
display: none;
}
.progress-step.completed .step-icon {
display: block;
}
.step-label {
font-size: 22rpx;
color: #999;
white-space: nowrap;
}
.progress-step.active .step-label {
color: #f57c00;
font-weight: 600;
}
.progress-step.completed .step-label {
color: #4caf50;
font-weight: 500;
}
.progress-line {
flex: 1;
height: 3rpx;
background: #e0e0e0;
margin: 0 8rpx;
transition: all 0.3s ease;
}
.progress-line.completed {
background: #4caf50;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 16rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.status-badge {
padding: 8rpx 16rpx;
border-radius: 8rpx;
font-size: 24rpx;
font-weight: 500;
}
.status-pending {
background: #fff3e0;
color: #f57c00;
}
.status-approved {
background: #e8f5e9;
color: #2e7d32;
}
.status-rejected {
background: #ffebee;
color: #c62828;
}
.time {
font-size: 24rpx;
color: #999;
}
.card-body {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.applicant-info {
display: flex;
align-items: center;
gap: 16rpx;
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: #3a5ddd;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-text {
font-size: 32rpx;
font-weight: 600;
color: #fff;
}
.applicant-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.name {
font-size: 30rpx;
font-weight: 600;
color: #111;
}
.phone {
font-size: 26rpx;
color: #666;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
padding: 16rpx;
background: #f5f7fb;
border-radius: 12rpx;
}
.grid-item {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.grid-label {
font-size: 24rpx;
color: #999;
}
.grid-value {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
.grid-value.highlight {
color: #4b7aff;
font-weight: 600;
}
.reason-box {
padding: 16rpx;
background: #fffbf0;
border-left: 4rpx solid #ffa726;
border-radius: 8rpx;
}
.reason-label {
font-size: 24rpx;
color: #666;
margin-right: 8rpx;
}
.reason-text {
font-size: 26rpx;
color: #333;
}
.card-footer {
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid #f0f0f0;
display: flex;
gap: 12rpx;
justify-content: flex-end;
}
.btn-reject,
.btn-approve,
.btn-deliver {
flex: 1;
height: 72rpx;
line-height: 72rpx;
border-radius: 12rpx;
font-size: 28rpx;
font-weight: 500;
}
.btn-reject {
background: #ffebee;
color: #c62828;
}
.btn-approve {
background: #3a5ddd;
color: #fff;
}
.btn-deliver {
background: #4caf50;
color: #fff;
}
.detail-link {
font-size: 26rpx;
color: #4b7aff;
}
.loading,
.no-more {
padding: 32rpx;
text-align: center;
font-size: 26rpx;
color: #999;
}
/* 弹窗样式 */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 600rpx;
max-height: 70vh;
background: #fff;
border-radius: 24rpx;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 32rpx;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1rpx solid #f0f0f0;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #111;
}
.modal-close {
font-size: 40rpx;
color: #999;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-body {
flex: 1;
padding: 32rpx;
overflow-y: auto;
}
.form-item {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 12rpx;
}
.form-textarea {
width: 100%;
min-height: 200rpx;
padding: 16rpx;
background: #f5f7fb;
border-radius: 12rpx;
font-size: 28rpx;
color: #333;
}
.modal-footer {
padding: 24rpx 32rpx;
border-top: 1rpx solid #f0f0f0;
display: flex;
gap: 16rpx;
}
.btn-cancel,
.btn-confirm {
flex: 1;
height: 80rpx;
line-height: 80rpx;
border-radius: 12rpx;
font-size: 30rpx;
font-weight: 500;
}
.btn-cancel {
background: #f5f5f5;
color: #666;
}
.btn-confirm {
background: #3a5ddd;
color: #fff;
}
.btn-confirm.btn-danger {
background: #ef5350;
}
.btn-confirm[disabled] {
opacity: 0.6;
}
</style>

View File

@ -0,0 +1,588 @@
<template>
<view class="page">
<!-- 顶部标题栏 -->
<view class="header">
<text class="title">耗材种类管理</text>
</view>
<!-- 搜索栏 -->
<view class="search-bar">
<view class="search-box">
<text class="search-icon">🔍</text>
<input class="search-input" v-model="searchKeyword" placeholder="搜索物料名称或规格" @confirm="loadList(true)" />
</view>
<button class="btn-add" @tap="showAddModal">+ 新增</button>
</view>
<!-- 列表 -->
<scroll-view scroll-y class="list-container" @scrolltolower="loadMore">
<view v-if="list.length === 0 && !loading" class="empty">
<text class="empty-text">暂无耗材种类</text>
</view>
<view v-for="item in list" :key="item.id" class="catalog-card">
<view class="card-main">
<view class="catalog-info">
<text class="catalog-name">{{ item.name }}</text>
<view class="catalog-meta">
<text v-if="item.spec" class="meta-item">规格: {{ item.spec }}</text>
<text v-if="item.unit" class="meta-item">单位: {{ item.unit }}</text>
</view>
</view>
<view class="catalog-status" :class="item.isActive ? 'active' : 'inactive'">
{{ item.isActive ? '启用' : '停用' }}
</view>
</view>
<view class="card-footer">
<text class="time">{{ formatTime(item.updatedAt) }}</text>
<view class="actions">
<button class="btn-edit" @tap="handleEdit(item)">编辑</button>
<button v-if="item.isActive" class="btn-disable" @tap="handleDisable(item.id)">停用</button>
<button v-else class="btn-enable" @tap="handleEnable(item.id)">启用</button>
</view>
</view>
</view>
<view v-if="loading" class="loading">
<text>加载中...</text>
</view>
<view v-if="!hasMore && list.length > 0" class="no-more">
<text>没有更多了</text>
</view>
</scroll-view>
<!-- 编辑/新增弹窗 -->
<view v-if="showModal" class="modal-mask" @tap="closeModal">
<view class="modal-content" @tap.stop>
<view class="modal-header">
<text class="modal-title">{{ isEdit ? '编辑种类' : '新增种类' }}</text>
<text class="modal-close" @tap="closeModal"></text>
</view>
<view class="modal-body">
<view class="form-item">
<text class="form-label required">物料名称</text>
<input class="form-input" v-model="formData.name" placeholder="请输入物料名称" />
</view>
<view class="form-item">
<text class="form-label">规格</text>
<input class="form-input" v-model="formData.spec" placeholder="请输入规格(可选)" />
</view>
<view class="form-item">
<text class="form-label">单位</text>
<input class="form-input" v-model="formData.unit" placeholder="请输入单位(可选)" />
</view>
<view class="form-item">
<text class="form-label">状态</text>
<view class="switch-box">
<switch :checked="formData.isActive" @change="onSwitchChange" color="#667eea" />
<text class="switch-label">{{ formData.isActive ? '启用' : '停用' }}</text>
</view>
</view>
</view>
<view class="modal-footer">
<button class="btn-cancel" @tap="closeModal">取消</button>
<button class="btn-confirm" @tap="handleSubmit" :disabled="submitting">
{{ submitting ? '提交中...' : '确认' }}
</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import consumableTempAPI, { type CatalogItem } from '@/pages/api/consumable-temp'
//
const list = ref<CatalogItem[]>([])
const loading = ref(false)
const searchKeyword = ref('')
const page = ref(1)
const pageSize = ref(20)
const hasMore = ref(true)
//
const showModal = ref(false)
const isEdit = ref(false)
const formData = ref<CatalogItem>({
name: '',
spec: '',
unit: '',
isActive: true
})
const submitting = ref(false)
//
async function loadList(reset = false) {
if (loading.value) return
if (reset) {
page.value = 1
hasMore.value = true
list.value = []
}
if (!hasMore.value) return
loading.value = true
try {
const res = await consumableTempAPI.getCatalogList({
page: page.value,
pageSize: pageSize.value,
q: searchKeyword.value || undefined
})
if (res.statusCode === 200 && (res.data as any)?.data) {
const newData = (res.data as any).data
list.value = reset ? newData : [...list.value, ...newData]
hasMore.value = newData.length >= pageSize.value
} else {
uni.showToast({ title: '加载失败', icon: 'none' })
}
} catch (err) {
console.error('加载列表失败:', err)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
//
function loadMore() {
if (!loading.value && hasMore.value) {
page.value++
loadList()
}
}
//
function formatTime(time?: string) {
if (!time) return '-'
const date = new Date(time)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
//
function showAddModal() {
isEdit.value = false
formData.value = {
name: '',
spec: '',
unit: '',
isActive: true
}
showModal.value = true
}
//
function handleEdit(item: CatalogItem) {
isEdit.value = true
formData.value = { ...item }
showModal.value = true
}
//
function closeModal() {
showModal.value = false
}
//
function onSwitchChange(e: any) {
formData.value.isActive = e.detail.value
}
//
async function handleSubmit() {
if (!formData.value.name) {
uni.showToast({ title: '请输入物料名称', icon: 'none' })
return
}
if (submitting.value) return
submitting.value = true
try {
let res
if (isEdit.value && formData.value.id) {
res = await consumableTempAPI.updateCatalog(formData.value.id, formData.value)
} else {
res = await consumableTempAPI.createCatalog(formData.value)
}
if (res.statusCode === 200 || res.statusCode === 201) {
uni.showToast({
title: isEdit.value ? '更新成功' : '创建成功',
icon: 'success'
})
closeModal()
loadList(true)
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
} catch (err) {
console.error('提交失败:', err)
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
submitting.value = false
}
}
//
function handleDisable(id?: string) {
if (!id) return
uni.showModal({
title: '确认停用',
content: '停用后该种类将不会出现在申请选择列表中',
success: async (res) => {
if (res.confirm) {
try {
const result = await consumableTempAPI.deleteCatalog(id)
if (result.statusCode === 204) {
uni.showToast({ title: '已停用', icon: 'success' })
loadList(true)
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
} catch (err) {
console.error('停用失败:', err)
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
}
})
}
//
async function handleEnable(id?: string) {
if (!id) return
try {
const res = await consumableTempAPI.updateCatalog(id, { isActive: true })
if (res.statusCode === 200) {
uni.showToast({ title: '已启用', icon: 'success' })
loadList(true)
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
} catch (err) {
console.error('启用失败:', err)
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
onMounted(() => {
loadList(true)
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f7fb;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.header {
background: #fff;
padding: 32rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
.title {
font-size: 36rpx;
font-weight: 600;
color: #111;
}
.search-bar {
background: #fff;
padding: 24rpx 32rpx;
margin-bottom: 12rpx;
display: flex;
gap: 12rpx;
}
.search-box {
flex: 1;
display: flex;
align-items: center;
padding: 0 20rpx;
background: #f5f7fb;
border-radius: 12rpx;
height: 72rpx;
}
.search-icon {
font-size: 28rpx;
margin-right: 12rpx;
}
.search-input {
flex: 1;
height: 72rpx;
line-height: 72rpx;
font-size: 28rpx;
}
.btn-add {
padding: 0 24rpx;
height: 72rpx;
line-height: 72rpx;
background: #3a5ddd;
color: #fff;
border-radius: 12rpx;
font-size: 28rpx;
white-space: nowrap;
}
.list-container {
flex: 1;
padding: 0 32rpx 24rpx;
}
.empty {
padding: 120rpx 0;
text-align: center;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
.catalog-card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 16rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.card-main {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16rpx;
}
.catalog-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.catalog-name {
font-size: 30rpx;
font-weight: 600;
color: #111;
}
.catalog-meta {
display: flex;
gap: 16rpx;
flex-wrap: wrap;
}
.meta-item {
font-size: 24rpx;
color: #666;
padding: 4rpx 12rpx;
background: #f5f7fb;
border-radius: 6rpx;
}
.catalog-status {
padding: 8rpx 16rpx;
border-radius: 8rpx;
font-size: 24rpx;
font-weight: 500;
}
.catalog-status.active {
background: #e8f5e9;
color: #2e7d32;
}
.catalog-status.inactive {
background: #f5f5f5;
color: #757575;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16rpx;
border-top: 1rpx solid #f0f0f0;
}
.time {
font-size: 24rpx;
color: #999;
}
.actions {
display: flex;
gap: 8rpx;
}
.btn-edit,
.btn-disable,
.btn-enable {
padding: 8rpx 20rpx;
border-radius: 8rpx;
font-size: 24rpx;
}
.btn-edit {
background: #e3f2fd;
color: #1976d2;
}
.btn-disable {
background: #fff3e0;
color: #f57c00;
}
.btn-enable {
background: #e8f5e9;
color: #2e7d32;
}
.loading,
.no-more {
padding: 32rpx;
text-align: center;
font-size: 26rpx;
color: #999;
}
/* 弹窗样式 */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 600rpx;
background: #fff;
border-radius: 24rpx;
}
.modal-header {
padding: 32rpx;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1rpx solid #f0f0f0;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #111;
}
.modal-close {
font-size: 40rpx;
color: #999;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-body {
padding: 32rpx;
}
.form-item {
margin-bottom: 24rpx;
}
.form-item:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 12rpx;
}
.form-label.required::before {
content: '*';
color: #ff4d4f;
margin-right: 4rpx;
}
.form-input {
width: 100%;
height: 72rpx;
line-height: 72rpx;
padding: 0 20rpx;
background: #f5f7fb;
border-radius: 12rpx;
font-size: 28rpx;
}
.switch-box {
display: flex;
align-items: center;
gap: 16rpx;
}
.switch-label {
font-size: 28rpx;
color: #333;
}
.modal-footer {
padding: 24rpx 32rpx;
border-top: 1rpx solid #f0f0f0;
display: flex;
gap: 16rpx;
}
.btn-cancel,
.btn-confirm {
flex: 1;
height: 80rpx;
line-height: 80rpx;
border-radius: 12rpx;
font-size: 30rpx;
font-weight: 500;
}
.btn-cancel {
background: #f5f5f5;
color: #666;
}
.btn-confirm {
background: #3a5ddd;
color: #fff;
}
.btn-confirm[disabled] {
opacity: 0.6;
}
</style>

View File

@ -0,0 +1,613 @@
<template>
<view class="page">
<scroll-view scroll-y class="content">
<!-- 基本信息 -->
<view class="section">
<view class="section-title">
<text>基本信息</text>
</view>
<view class="form-card">
<view class="form-item">
<text class="form-label required">申请人姓名</text>
<input class="form-input" v-model="formData.applicantName" placeholder="请输入申请人姓名" />
</view>
<view class="form-item">
<text class="form-label required">联系电话</text>
<input class="form-input" v-model="formData.applicantPhone" type="number" placeholder="请输入联系电话" />
</view>
<view class="form-item">
<text class="form-label">部门</text>
<input class="form-input" v-model="formData.department" placeholder="请输入部门" />
</view>
<view class="form-item">
<text class="form-label">申请原因</text>
<textarea class="form-textarea" v-model="formData.reason" placeholder="请输入申请原因" maxlength="200" />
</view>
<view class="form-item">
<text class="form-label">需要时间</text>
<picker mode="date" :value="formData.neededAt" @change="onDateChange">
<view class="form-picker">
<text>{{ formData.neededAt || '请选择需要时间' }}</text>
</view>
</picker>
</view>
</view>
</view>
<!-- 物料明细 -->
<view class="section">
<view class="section-title">
<text>物料明细</text>
<button class="btn-add" @tap="addItem">+ 添加物料</button>
</view>
<view v-if="formData.items.length === 0" class="empty-items">
<text>暂无物料请点击上方按钮添加</text>
</view>
<view v-for="(item, index) in formData.items" :key="index" class="item-card">
<view class="item-header">
<text class="item-number">#{{ index + 1 }}</text>
<button class="btn-delete" @tap="deleteItem(index)">删除</button>
</view>
<view class="form-item">
<text class="form-label required">物料名称</text>
<view class="form-row">
<input class="form-input flex-1" v-model="item.name" placeholder="请输入物料名称" />
<button class="btn-catalog" @tap="selectFromCatalog(index)">从目录选择</button>
</view>
</view>
<view class="form-item">
<text class="form-label">规格</text>
<input class="form-input" v-model="item.spec" placeholder="请输入规格" />
</view>
<view class="form-row-group">
<view class="form-item flex-1">
<text class="form-label required">数量</text>
<input class="form-input" v-model.number="item.quantity" type="digit" placeholder="数量" />
</view>
<view class="form-item flex-1">
<text class="form-label">单位</text>
<input class="form-input" v-model="item.unit" placeholder="单位" />
</view>
</view>
<view class="form-item">
<text class="form-label">预估单价</text>
<input class="form-input" v-model.number="item.estimatedUnitCost" type="digit" placeholder="请输入预估单价" />
</view>
<view class="form-item">
<text class="form-label">备注</text>
<textarea class="form-textarea" v-model="item.remark" placeholder="请输入备注" maxlength="100" />
</view>
</view>
</view>
</scroll-view>
<!-- 底部操作按钮 -->
<view class="footer-actions">
<button class="btn-secondary" @tap="goBack">取消</button>
<button class="btn-primary" @tap="submitForm" :disabled="submitting">
{{ submitting ? '提交中...' : '提交申请' }}
</button>
</view>
<!-- 目录选择弹窗 -->
<view v-if="showCatalogModal" class="modal-mask" @tap="closeCatalog">
<view class="modal-content" @tap.stop>
<view class="modal-header">
<text class="modal-title">选择物料</text>
<text class="modal-close" @tap="closeCatalog"></text>
</view>
<view class="modal-search">
<input class="search-input" v-model="catalogSearch" placeholder="搜索物料名称" />
</view>
<scroll-view scroll-y class="modal-list">
<view v-for="catalog in filteredCatalog" :key="catalog.id"
class="catalog-item"
@tap="selectCatalog(catalog)">
<view class="catalog-name">{{ catalog.name }}</view>
<view class="catalog-info">
<text v-if="catalog.spec">{{ catalog.spec }}</text>
<text v-if="catalog.unit">{{ catalog.unit }}</text>
</view>
</view>
<view v-if="filteredCatalog.length === 0" class="empty">
<text>暂无数据</text>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import consumableTempAPI, { type RequestItem, type CatalogItem } from '@/pages/api/consumable-temp'
//
const formData = ref({
applicantName: '',
applicantPhone: '',
department: '',
reason: '',
neededAt: '',
items: [] as RequestItem[]
})
const submitting = ref(false)
const currentItemIndex = ref(-1)
const showCatalogModal = ref(false)
const catalogList = ref<CatalogItem[]>([])
const catalogSearch = ref('')
//
const filteredCatalog = computed(() => {
if (!catalogSearch.value) return catalogList.value
const keyword = catalogSearch.value.toLowerCase()
return catalogList.value.filter(item =>
item.name.toLowerCase().includes(keyword) ||
item.spec?.toLowerCase().includes(keyword)
)
})
//
async function loadCatalog() {
try {
const res = await consumableTempAPI.getCatalogList({
isActive: true,
pageSize: 99
})
if (res.statusCode === 200 && (res.data as any)?.data) {
catalogList.value = (res.data as any).data
}
} catch (err) {
console.error('加载目录失败:', err)
}
}
//
function onDateChange(e: any) {
formData.value.neededAt = e.detail.value
}
//
function addItem() {
formData.value.items.push({
name: '',
spec: '',
quantity: 1,
unit: '',
estimatedUnitCost: 0,
remark: ''
})
}
//
function deleteItem(index: number) {
uni.showModal({
title: '确认删除',
content: '确定要删除这个物料吗?',
success: (res) => {
if (res.confirm) {
formData.value.items.splice(index, 1)
}
}
})
}
//
function selectFromCatalog(index: number) {
currentItemIndex.value = index
showCatalogModal.value = true
}
//
function selectCatalog(catalog: CatalogItem) {
if (currentItemIndex.value >= 0) {
const item = formData.value.items[currentItemIndex.value]
item.name = catalog.name
item.spec = catalog.spec || ''
item.unit = catalog.unit || ''
item.catalogId = catalog.id
}
closeCatalog()
}
//
function closeCatalog() {
showCatalogModal.value = false
catalogSearch.value = ''
currentItemIndex.value = -1
}
//
function validateForm() {
if (!formData.value.applicantPhone) {
uni.showToast({ title: '请输入联系电话', icon: 'none' })
return false
}
if (formData.value.applicantPhone.length < 3) {
uni.showToast({ title: '联系电话格式不正确', icon: 'none' })
return false
}
if (formData.value.items.length === 0) {
uni.showToast({ title: '请至少添加一项物料', icon: 'none' })
return false
}
for (let i = 0; i < formData.value.items.length; i++) {
const item = formData.value.items[i]
if (!item.name) {
uni.showToast({ title: `${i + 1}项物料名称不能为空`, icon: 'none' })
return false
}
if (!item.quantity || item.quantity <= 0) {
uni.showToast({ title: `${i + 1}项物料数量必须大于0`, icon: 'none' })
return false
}
}
return true
}
//
async function submitForm() {
if (!validateForm()) return
if (submitting.value) return
submitting.value = true
try {
const res = await consumableTempAPI.createRequest(formData.value)
if (res.statusCode === 201) {
uni.showToast({
title: '提交成功',
icon: 'success',
success: () => {
setTimeout(() => {
//
uni.$emit('tempRequest:changed')
uni.navigateBack()
}, 1500)
}
})
} else {
uni.showToast({ title: '提交失败', icon: 'none' })
}
} catch (err) {
console.error('提交失败:', err)
uni.showToast({ title: '提交失败', icon: 'none' })
} finally {
submitting.value = false
}
}
//
function goBack() {
if (formData.value.items.length > 0 || formData.value.applicantName) {
uni.showModal({
title: '提示',
content: '确定要放弃当前编辑吗?',
success: (res) => {
if (res.confirm) {
uni.navigateBack()
}
}
})
} else {
uni.navigateBack()
}
}
onMounted(() => {
loadCatalog()
//
const staff = uni.getStorageSync('staff')
if (staff) {
formData.value.applicantName = staff.name || ''
formData.value.applicantPhone = staff.phone || ''
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f7fb;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.content {
flex: 1;
padding: 24rpx 32rpx 140rpx;
box-sizing: border-box;
}
.section {
margin-bottom: 32rpx;
}
.section-title {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 12rpx 16rpx;
font-size: 32rpx;
font-weight: 600;
color: #111;
}
.btn-add {
padding: 8rpx 20rpx;
margin: 0;
height: 56rpx;
line-height: 40rpx;
background: #3a5ddd;
color: #fff;
border-radius: 8rpx;
font-size: 24rpx;
}
.form-card,
.item-card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.item-card {
margin-bottom: 24rpx;
position: relative;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 16rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.item-number {
font-size: 28rpx;
font-weight: 600;
color: #4b7aff;
}
.btn-delete {
padding: 8rpx 16rpx;
height: 48rpx;
line-height: 32rpx;
background: #ffebee;
color: #c62828;
border-radius: 8rpx;
font-size: 24rpx;
}
.form-item {
margin-bottom: 24rpx;
}
.form-item:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 12rpx;
}
.form-label.required::before {
content: '*';
color: #ff4d4f;
margin-right: 4rpx;
}
.form-input,
.form-picker {
width: 100%;
height: 80rpx;
padding: 0 24rpx;
background: #f5f7fb;
border-radius: 12rpx;
font-size: 28rpx;
color: #333;
}
.form-textarea {
width: 100%;
min-height: 120rpx;
padding: 16rpx 24rpx;
background: #f5f7fb;
border-radius: 12rpx;
font-size: 28rpx;
color: #333;
}
.form-picker {
display: flex;
align-items: center;
}
.form-row {
display: flex;
gap: 12rpx;
align-items: center;
}
.form-row .flex-1 {
flex: 1;
}
.btn-catalog {
padding: 0 20rpx;
height: 80rpx;
line-height: 80rpx;
background: #e3f2fd;
color: #1976d2;
border-radius: 12rpx;
font-size: 24rpx;
white-space: nowrap;
}
.form-row-group {
display: flex;
gap: 16rpx;
}
.form-row-group .form-item {
flex: 1;
}
.empty-items {
padding: 80rpx 0;
text-align: center;
font-size: 28rpx;
color: #999;
background: #fff;
border-radius: 20rpx;
}
.footer-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 24rpx 32rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.06);
display: flex;
gap: 16rpx;
}
.btn-secondary,
.btn-primary {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 16rpx;
font-size: 32rpx;
font-weight: 600;
}
.btn-secondary {
background: #f5f5f5;
color: #666;
}
.btn-primary {
background: #3a5ddd;
color: #fff;
}
.btn-primary[disabled] {
opacity: 0.6;
}
/* 弹窗样式 */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 1000;
}
.modal-content {
width: 100%;
max-height: 80vh;
background: #fff;
border-radius: 32rpx 32rpx 0 0;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 32rpx;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1rpx solid #f0f0f0;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #111;
}
.modal-close {
font-size: 40rpx;
color: #999;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-search {
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.search-input {
width: 100%;
height: 72rpx;
line-height: 72rpx;
padding: 0 24rpx;
background: #f5f7fb;
border-radius: 12rpx;
font-size: 28rpx;
}
.modal-list {
flex: 1;
padding: 12rpx 0;
}
.catalog-item {
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.catalog-name {
font-size: 30rpx;
color: #333;
margin-bottom: 8rpx;
}
.catalog-info {
font-size: 24rpx;
color: #999;
display: flex;
gap: 16rpx;
}
.empty {
padding: 80rpx 0;
text-align: center;
font-size: 28rpx;
color: #999;
}
</style>

View File

@ -0,0 +1,456 @@
<template>
<view class="page">
<scroll-view scroll-y class="content" v-if="!loading">
<!-- 状态卡片 -->
<view class="status-card">
<view class="status-badge" :class="`status-${detail.status?.toLowerCase()}`">
{{ getStatusText(detail.status) }}
</view>
<text class="status-time">{{ formatTime(detail.createdAt) }}</text>
</view>
<!-- 基本信息 -->
<view class="section">
<view class="section-title">基本信息</view>
<view class="info-card">
<view class="info-row">
<text class="label">申请人</text>
<text class="value">{{ detail.applicantName || '-' }}</text>
</view>
<view class="info-row">
<text class="label">联系电话</text>
<text class="value">{{ detail.applicantPhone }}</text>
</view>
<view class="info-row">
<text class="label">部门</text>
<text class="value">{{ detail.department || '-' }}</text>
</view>
<view class="info-row">
<text class="label">申请原因</text>
<text class="value">{{ detail.reason || '-' }}</text>
</view>
<view class="info-row">
<text class="label">需要时间</text>
<text class="value">{{ detail.neededAt || '-' }}</text>
</view>
<view class="info-row" v-if="detail.approverId">
<text class="label">审批人ID</text>
<text class="value">{{ detail.approverId }}</text>
</view>
<view class="info-row" v-if="detail.approvedAt">
<text class="label">审批时间</text>
<text class="value">{{ formatTime(detail.approvedAt) }}</text>
</view>
<view class="info-row" v-if="detail.remark">
<text class="label">备注</text>
<text class="value">{{ detail.remark }}</text>
</view>
</view>
</view>
<!-- 物料明细 -->
<view class="section">
<view class="section-title">物料明细{{ detail.items?.length || 0 }}</view>
<view v-for="(item, index) in detail.items" :key="item.id" class="item-card">
<view class="item-header">
<text class="item-number">#{{ index + 1 }}</text>
</view>
<view class="item-body">
<view class="item-name">{{ item.name }}</view>
<view class="item-details">
<view class="detail-item" v-if="item.spec">
<text class="detail-label">规格</text>
<text class="detail-value">{{ item.spec }}</text>
</view>
<view class="detail-item">
<text class="detail-label">数量</text>
<text class="detail-value highlight">{{ item.quantity }} {{ item.unit || '' }}</text>
</view>
<view class="detail-item" v-if="item.estimatedUnitCost">
<text class="detail-label">预估单价</text>
<text class="detail-value">¥{{ item.estimatedUnitCost }}</text>
</view>
<view class="detail-item" v-if="item.estimatedUnitCost && item.quantity">
<text class="detail-label">小计</text>
<text class="detail-value total">¥{{ (item.estimatedUnitCost * item.quantity).toFixed(2) }}</text>
</view>
<view class="detail-item full-width" v-if="item.remark">
<text class="detail-label">备注</text>
<text class="detail-value">{{ item.remark }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 合计 -->
<view class="section" v-if="totalCost > 0">
<view class="total-card">
<text class="total-label">预估总金额</text>
<text class="total-value">¥{{ totalCost.toFixed(2) }}</text>
</view>
</view>
</scroll-view>
<view v-if="loading" class="loading-container">
<text>加载中...</text>
</view>
<!-- 底部操作按钮 -->
<view class="footer-actions" v-if="!loading && detail.status === 'PENDING'">
<button class="btn-danger" @tap="handleCancel">取消申请</button>
<button class="btn-primary" @tap="handleEdit">编辑</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import consumableTempAPI, { type ConsumableRequest } from '@/pages/api/consumable-temp'
const detail = ref<ConsumableRequest>({
applicantPhone: '',
items: []
})
const loading = ref(true)
const requestId = ref('')
//
const statusOptions = [
{ value: 'PENDING', label: '待审批' },
{ value: 'APPROVED', label: '待发放' },
{ value: 'REJECTED', label: '已拒绝' },
{ value: 'CANCELLED', label: '已取消' },
{ value: 'COMPLETED', label: '已完成' }
]
//
const totalCost = computed(() => {
return detail.value.items?.reduce((sum, item) => {
const cost = (item.estimatedUnitCost || 0) * (item.quantity || 0)
return sum + cost
}, 0) || 0
})
//
async function loadDetail() {
if (!requestId.value) return
loading.value = true
try {
const res = await consumableTempAPI.getRequestDetail(requestId.value)
if (res.statusCode === 200 && (res.data as any)?.data) {
detail.value = (res.data as any).data
} else {
uni.showToast({ title: '加载失败', icon: 'none' })
}
} catch (err) {
console.error('加载详情失败:', err)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
//
function formatTime(time?: string) {
if (!time) return '-'
const date = new Date(time)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
//
function getStatusText(status?: string) {
const item = statusOptions.find(s => s.value === status)
return item?.label || '未知'
}
//
function handleCancel() {
uni.showModal({
title: '确认取消',
content: '确定要取消这个申请吗?取消后不可恢复。',
success: async (res) => {
if (res.confirm) {
try {
const result = await consumableTempAPI.updateRequestStatus(requestId.value, {
status: 'CANCELLED'
})
if (result.statusCode === 200) {
uni.showToast({
title: '已取消',
icon: 'success',
success: () => {
setTimeout(() => {
//
uni.$emit('tempRequest:changed')
uni.navigateBack()
}, 1500)
}
})
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
} catch (err) {
console.error('取消失败:', err)
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
}
})
}
//
function handleEdit() {
uni.navigateTo({
url: `/pages/consumables/temp-request-edit?id=${requestId.value}`
})
}
//
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1] as any
const options = currentPage.options || {}
if (options.id) {
requestId.value = options.id
loadDetail()
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f7fb;
display: flex;
flex-direction: column;
}
.content {
flex: 1;
padding: 24rpx 32rpx 140rpx;
box-sizing: border-box;
}
.loading-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #999;
}
.status-card {
background: #3a5ddd;
border-radius: 20rpx;
padding: 32rpx;
margin-bottom: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.status-badge {
padding: 12rpx 32rpx;
border-radius: 24rpx;
font-size: 32rpx;
font-weight: 600;
background: rgba(255, 255, 255, 0.9);
}
.status-pending {
color: #f57c00;
}
.status-approved {
color: #2e7d32;
}
.status-rejected {
color: #c62828;
}
.status-cancelled {
color: #757575;
}
.status-completed {
color: #1976d2;
}
.status-time {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
}
.section {
margin-bottom: 32rpx;
}
.section-title {
padding: 0 12rpx 16rpx;
font-size: 32rpx;
font-weight: 600;
color: #111;
}
.info-card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.info-row {
display: flex;
padding: 16rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.info-row:last-child {
border-bottom: none;
}
.info-row .label {
color: #666;
font-size: 28rpx;
width: 160rpx;
flex-shrink: 0;
}
.info-row .value {
color: #333;
font-size: 28rpx;
flex: 1;
}
.item-card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 16rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.item-header {
margin-bottom: 16rpx;
padding-bottom: 12rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.item-number {
font-size: 26rpx;
font-weight: 600;
color: #4b7aff;
}
.item-body {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.item-name {
font-size: 30rpx;
font-weight: 600;
color: #111;
margin-bottom: 8rpx;
}
.item-details {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12rpx;
}
.detail-item {
display: flex;
font-size: 26rpx;
}
.detail-item.full-width {
grid-column: 1 / -1;
}
.detail-label {
color: #666;
margin-right: 8rpx;
}
.detail-value {
color: #333;
flex: 1;
}
.detail-value.highlight {
color: #4b7aff;
font-weight: 600;
}
.detail-value.total {
color: #ff6b6b;
font-weight: 600;
}
.total-card {
background: #f9a825;
border-radius: 20rpx;
padding: 32rpx;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 8rpx 24rpx rgba(249, 168, 37, 0.2);
}
.total-label {
font-size: 32rpx;
font-weight: 600;
color: #fff;
}
.total-value {
font-size: 40rpx;
font-weight: 700;
color: #fff;
}
.footer-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 24rpx 32rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.06);
display: flex;
gap: 16rpx;
}
.btn-danger,
.btn-primary {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 16rpx;
font-size: 32rpx;
font-weight: 600;
}
.btn-danger {
background: #ef5350;
color: #fff;
}
.btn-primary {
background: #3a5ddd;
color: #fff;
}
</style>

View File

@ -0,0 +1,438 @@
<template>
<view class="page">
<!-- 顶部标题栏 -->
<view class="header">
<text class="title">临时易耗品申请</text>
<view class="actions">
<button class="btn-outline" @tap="goToCatalog">种类管理</button>
</view>
</view>
<!-- 筛选区 -->
<view class="filter-bar">
<picker mode="selector" :range="statusOptions" range-key="label" @change="onStatusChange">
<view class="filter-item">
<text>{{ currentStatus?.label || '全部状态' }}</text>
<text class="arrow"></text>
</view>
</picker>
</view>
<!-- 申请列表 -->
<scroll-view scroll-y class="list-container" @scrolltolower="loadMore">
<view v-if="list.length === 0 && !loading" class="empty">
<image class="empty-icon" src="/static/icons/asset-inventory.png" mode="aspectFit" />
<text class="empty-text">暂无申请记录</text>
</view>
<view v-for="item in list" :key="item.id" class="card" @tap="goToDetail(item.id)">
<view class="card-header">
<view class="status-badge" :class="`status-${item.status?.toLowerCase()}`">
{{ getStatusText(item.status) }}
</view>
<text class="time">{{ formatTime(item.createdAt) }}</text>
</view>
<view class="card-body">
<view class="info-row">
<text class="label">申请人</text>
<text class="value">{{ item.applicantName || '-' }}</text>
</view>
<view class="info-row">
<text class="label">电话</text>
<text class="value">{{ item.applicantPhone }}</text>
</view>
<view class="info-row">
<text class="label">部门</text>
<text class="value">{{ item.department || '-' }}</text>
</view>
<view class="info-row">
<text class="label">申请原因</text>
<text class="value reason">{{ item.reason || '-' }}</text>
</view>
<view class="info-row">
<text class="label">物料数量</text>
<text class="value highlight">{{ item.items?.length || 0 }} </text>
</view>
</view>
<view class="card-footer">
<text class="detail-link">查看详情 </text>
</view>
</view>
<view v-if="loading" class="loading">
<text>加载中...</text>
</view>
<view v-if="!hasMore && list.length > 0" class="no-more">
<text>没有更多了</text>
</view>
</scroll-view>
<!-- 底部操作按钮 -->
<view class="footer-actions">
<button class="btn-primary" @tap="goToCreate">
<text class="btn-icon">+</text>
新建申请
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import consumableTempAPI, { type ConsumableRequest } from '@/pages/api/consumable-temp'
//
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'PENDING', label: '待审批' },
{ value: 'APPROVED', label: '待发放' },
{ value: 'REJECTED', label: '已拒绝' },
{ value: 'CANCELLED', label: '已取消' },
{ value: 'COMPLETED', label: '已完成' }
]
//
const list = ref<ConsumableRequest[]>([])
const loading = ref(false)
const currentStatus = ref(statusOptions[0])
const page = ref(1)
const pageSize = ref(10)
const hasMore = ref(true)
const needRefresh = ref(false) //
//
async function loadList(reset = false) {
if (loading.value) return
if (reset) {
page.value = 1
hasMore.value = true
list.value = []
}
if (!hasMore.value) return
loading.value = true
try {
const res = await consumableTempAPI.getRequestList({
page: page.value,
pageSize: pageSize.value,
status: currentStatus.value.value || undefined
})
if (res.statusCode === 200 && (res.data as any)?.data) {
const newData = (res.data as any).data
list.value = reset ? newData : [...list.value, ...newData]
hasMore.value = newData.length >= pageSize.value
} else {
uni.showToast({ title: '加载失败', icon: 'none' })
}
} catch (err) {
console.error('加载列表失败:', err)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
//
function onStatusChange(e: any) {
const index = e.detail.value
currentStatus.value = statusOptions[index]
loadList(true)
}
//
function loadMore() {
if (!loading.value && hasMore.value) {
page.value++
loadList()
}
}
//
function formatTime(time?: string) {
if (!time) return '-'
const date = new Date(time)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
//
function getStatusText(status?: string) {
const item = statusOptions.find(s => s.value === status)
return item?.label || '未知'
}
//
function goToDetail(id?: string) {
if (!id) return
uni.navigateTo({
url: `/pages/consumables/temp-request-detail?id=${id}`
})
}
//
function goToCreate() {
uni.navigateTo({
url: '/pages/consumables/temp-request-create'
})
}
//
function goToCatalog() {
uni.navigateTo({
url: '/pages/consumables/temp-catalog'
})
}
//
onLoad(() => {
loadList(true)
//
uni.$on('tempRequest:changed', () => {
needRefresh.value = true
})
})
onShow(() => {
if (needRefresh.value) {
loadList(true)
needRefresh.value = false
}
})
onUnload(() => {
uni.$off('tempRequest:changed')
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f7fb;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.header {
background: #fff;
padding: 32rpx;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
.title {
font-size: 36rpx;
font-weight: 600;
color: #111;
}
.actions {
display: flex;
gap: 12rpx;
}
.btn-outline {
padding: 12rpx 24rpx;
background: #fff;
color: #4b7aff;
border: 2rpx solid #4b7aff;
border-radius: 8rpx;
font-size: 26rpx;
}
.filter-bar {
background: #fff;
padding: 24rpx 32rpx;
margin-bottom: 12rpx;
}
.filter-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 24rpx;
background: #f5f7fb;
border-radius: 12rpx;
font-size: 28rpx;
color: #333;
}
.arrow {
font-size: 20rpx;
color: #999;
}
.list-container {
flex: 1;
padding: 0 32rpx 120rpx;
}
.empty {
padding: 120rpx 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.empty-icon {
width: 160rpx;
height: 160rpx;
opacity: 0.3;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
.card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
padding-bottom: 16rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.status-badge {
padding: 8rpx 16rpx;
border-radius: 8rpx;
font-size: 24rpx;
font-weight: 500;
}
.status-pending {
background: #fff3e0;
color: #f57c00;
}
.status-approved {
background: #e8f5e9;
color: #2e7d32;
}
.status-rejected {
background: #ffebee;
color: #c62828;
}
.status-cancelled {
background: #f5f5f5;
color: #757575;
}
.status-completed {
background: #e3f2fd;
color: #1976d2;
}
.time {
font-size: 24rpx;
color: #999;
}
.card-body {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.info-row {
display: flex;
font-size: 28rpx;
}
.label {
color: #666;
width: 160rpx;
flex-shrink: 0;
}
.value {
color: #333;
flex: 1;
}
.value.reason {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
.value.highlight {
color: #4b7aff;
font-weight: 600;
}
.card-footer {
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid #f0f0f0;
display: flex;
justify-content: flex-end;
}
.detail-link {
font-size: 26rpx;
color: #4b7aff;
}
.loading,
.no-more {
padding: 32rpx;
text-align: center;
font-size: 26rpx;
color: #999;
}
.footer-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 24rpx 32rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.btn-primary {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: #3a5ddd;
color: #fff;
border-radius: 16rpx;
font-size: 32rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
}
.btn-icon {
font-size: 40rpx;
font-weight: 300;
}
</style>

View File

@ -74,11 +74,11 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
// @ts-ignore JS
import queryService from '@/service/queryService.js'
// @ts-ignore JS
import config from '@/config.js'
import config, { AssetRole } from '@/config'
// @ts-ignore JS
import * as staffRole from '@/constant/staffRole.js'
// @ts-ignore JS
@ -87,14 +87,21 @@ import util from '@/utils/util.js'
const assetOpen = ref(true)
//
const assetItems = [
{ key: 'fixed', text: '固定资产', icon: '/static/icons/fixed-assets.png' },
{ key: 'mine', text: '我的借用', icon: '/static/icons/my-loans.png' },
{ key: 'scan', text: '扫码查看信息', icon: '/static/icons/scan-to-view-info.png' },
{ key: 'inventory', text: '资产盘点', icon: '/static/icons/asset-inventory.png' },
{ key: 'plan', text: '易耗品领用计划', icon: '/static/icons/consumables-plan.png' },
{ key: 'consumable', text: '易耗品盘点', icon: '/static/icons/consumables-inventory.png' }
]
const assetItems = computed(() => {
const role = assetsRole.value || 0
return [
{ key: 'fixed', text: '固定资产', icon: '/static/icons/fixed-assets.png' },
{ key: 'mine', text: '我的借用', icon: '/static/icons/my-loans.png' },
{ key: 'scan', text: '扫码查看信息', icon: '/static/icons/scan-to-view-info.png' },
{ key: 'inventory', text: '资产盘点', icon: '/static/icons/asset-inventory.png' },
{ key: 'plan', text: '易耗品领用计划', icon: '/static/icons/consumables-plan.png' },
{ key: 'consumable', text: '易耗品盘点', icon: '/static/icons/consumables-inventory.png' },
{ key: 'lent', text: '出借资产管理', icon: '/static/icons/lent-assets-management.png', show: !!(role & AssetRole.TEAM_LEAD) },
{ key: 'request', text: '临时易耗品申请', icon: '/static/icons/consumables-temp-request.png', show: !!(role & AssetRole.TEAM_LEAD) },
{ key: 'approve', text: '临时易耗品申请审批', icon: '/static/icons/consumables-temp-approve.png', show: !!(role & AssetRole.CONSUMABLES_MANAGER) },
].filter(item => item.show !== false)
})
//
const swiperIndex = ref(0)
@ -142,6 +149,7 @@ onMounted(() => {
//
const isLoggedIn = ref(false)
const staff = ref<any | null>(null)
const assetsRole = ref<number | null>(null) //
const isWeixin = ref(false)
const isPatient = ref(true)
const isDoctor = ref(false)
@ -149,6 +157,7 @@ const isRider = ref(false)
function syncLoginState() {
staff.value = uni.getStorageSync('staff') || null
assetsRole.value = uni.getStorageSync('assetsRole') || null //
isLoggedIn.value = !!staff.value
//
// @ts-ignore
@ -192,7 +201,7 @@ function toggleAsset() {
assetOpen.value = !assetOpen.value
}
function handleAsset(key: string) {
const item = assetItems.find(i => i.key === key)
const item = assetItems.value.find((i: any) => i.key === key)
if (!item) return
if (key === 'fixed') {
uni.navigateTo({ url: '/pages/fixed-assets/index' })
@ -218,6 +227,18 @@ function handleAsset(key: string) {
uni.navigateTo({ url: '/pages/fixed-assets/scan' })
return
}
if (key === 'lent') {
uni.showToast({ title: `打开:${item.text}`, icon: 'none' })
return
}
if (key === 'request') {
uni.navigateTo({ url: '/pages/consumables/temp-request' })
return
}
if (key === 'approve') {
uni.navigateTo({ url: '/pages/consumables/temp-approve' })
return
}
//
uni.showToast({ title: `打开:${item.text}`, icon: 'none' })
}

View File

@ -7,7 +7,7 @@
<view class="card form">
<view class="field">
<text class="label">用户名/电话</text>
<text class="label">手机号</text>
<input class="input" v-model="loginRequest.username" placeholder="请输入用户名或电话" :focus="usernameFocus" />
</view>
<view class="field">
@ -25,12 +25,7 @@
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
// @ts-ignore
import md5 from '@/utils/md5.js'
// @ts-ignore
import util from '@/utils/util.js'
// @ts-ignore
import config from '@/config.js'
import { login } from '../api/user'
const usernameFocus = ref(true)
const loginRequest = reactive({
@ -66,7 +61,7 @@ async function onLogin() {
//
uni.setStorageSync('staff', null)
try {
const res = await util.request({
/* const res = await util.request({
url: config.urls.login,
method: 'POST',
data: {
@ -74,23 +69,26 @@ async function onLogin() {
password: md5.hexMD5(loginRequest.username + loginRequest.password),
typecode: loginRequest.typecode
}
})
if (res?.data?.code === 0) {
const staff = res.data.data.userLoginInfo
// role
uni.setStorageSync('staff', { ...staff, role: staff.role_ids })
uni.setStorageSync('token', res.data.data.token)
uni.showToast({ title: '登录成功', icon: 'success', duration: 1000 })
// tabBar
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' })
}, 300)
} else {
uni.showToast({ title: res?.data?.msg || '登录失败', icon: 'error', duration: 3000 })
}) */
const res = await login(loginRequest.username, loginRequest.password)
if (res.code != 0) {
uni.showToast({ title: '登录失败,' + res.msg, icon: 'success', duration: 1000 })
return
}
} catch (err: any) {
// util.request
console.warn('login error', err)
const staff = res.data.userLoginRequestVO
// role
uni.setStorageSync('token', res.data.token)
uni.showToast({ title: '登录成功', icon: 'success', duration: 1000 })
uni.setStorageSync('assetsRole', staff.assetsRole) //
// tabBar
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' })
}, 300)
} catch (e: any) {
console.error('登录失败', e)
const msg = e?.message || '登录失败,请稍后再试'
uni.showToast({ title: msg, icon: 'none', duration: 2000 })
}
}
@ -163,7 +161,7 @@ function onCancel() {
.btn {
flex: 1;
border-radius: 14rpx;
background: #eef2ff;
color: #3a5ddd;

View File

@ -1,32 +1,355 @@
<template>
<view class="page">
<view class="empty">
<image src="/static/icons/messages.png" class="icon" />
<text>暂无消息</text>
<!-- 头部标题 -->
<view class="header">
<text class="title">消息通知</text>
<text class="count" v-if="pendingCount > 0">{{ pendingCount }}条待处理</text>
</view>
<!-- 消息列表 -->
<scroll-view scroll-y class="content" @scrolltolower="loadMore">
<view v-if="list.length === 0 && !loading" class="empty">
<image src="/static/icons/messages.png" class="icon" />
<text class="empty-text">暂无消息</text>
</view>
<!-- 待审批申请列表 -->
<view v-for="item in list" :key="item.id" class="message-card" @tap="goToApprove(item.id)">
<view class="card-header">
<view class="type-badge">
<text class="badge-icon">📋</text>
<text class="badge-text">待审批</text>
</view>
<text class="time">{{ formatTime(item.createdAt) }}</text>
</view>
<view class="card-body">
<text class="message-title">临时易耗品申请</text>
<view class="message-content">
<view class="content-row">
<text class="label">申请人</text>
<text class="value">{{ item.applicantName || '未填写' }}</text>
</view>
<view class="content-row">
<text class="label">部门</text>
<text class="value">{{ item.department || '-' }}</text>
</view>
<view class="content-row">
<text class="label">物料</text>
<text class="value highlight">{{ item.items?.length || 0 }} </text>
</view>
<view class="content-row" v-if="item.reason">
<text class="label">原因</text>
<text class="value reason">{{ item.reason }}</text>
</view>
</view>
</view>
<view class="card-footer">
<text class="action-text">点击查看详情 </text>
</view>
</view>
<view v-if="loading" class="loading">
<text>加载中...</text>
</view>
<view v-if="!hasMore && list.length > 0" class="no-more">
<text>没有更多了</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import consumableTempAPI, { type ConsumableRequest } from '@/pages/api/consumable-temp'
import { getPendingCount, refreshNow } from '@/utils/messageManager'
const list = ref<ConsumableRequest[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const hasMore = ref(true)
const pendingCount = ref(0)
const needRefresh = ref(false) //
//
async function loadList(reset = false) {
if (loading.value) return
if (reset) {
page.value = 1
hasMore.value = true
list.value = []
}
if (!hasMore.value) return
loading.value = true
try {
const res = await consumableTempAPI.getRequestList({
page: page.value,
pageSize: pageSize.value,
status: 'PENDING' //
})
if (res.statusCode === 200 && (res.data as any)?.data) {
const newData = (res.data as any).data
list.value = reset ? newData : [...list.value, ...newData]
hasMore.value = newData.length >= pageSize.value
//
pendingCount.value = getPendingCount()
} else {
uni.showToast({ title: '加载失败', icon: 'none' })
}
} catch (err) {
console.error('加载消息失败:', err)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
//
function loadMore() {
if (!loading.value && hasMore.value) {
page.value++
loadList()
}
}
//
function formatTime(time?: string) {
if (!time) return '-'
const date = new Date(time)
const now = new Date()
const diff = now.getTime() - date.getTime()
//
if (diff < 60000) {
return '刚刚'
}
//
if (diff < 3600000) {
return `${Math.floor(diff / 60000)}分钟前`
}
//
if (diff < 86400000) {
return `${Math.floor(diff / 3600000)}小时前`
}
//
return `${date.getMonth() + 1}-${date.getDate()} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
//
function goToApprove(id?: string) {
if (!id) return
uni.navigateTo({
url: `/pages/consumables/temp-approve-detail?id=${id}`
})
}
//
onLoad(() => {
//
refreshNow().then(() => {
pendingCount.value = getPendingCount()
})
//
loadList(true)
//
uni.$on('tempMessage:changed', () => {
needRefresh.value = true
})
})
onShow(() => {
//
pendingCount.value = getPendingCount()
if (needRefresh.value) {
loadList(true)
needRefresh.value = false
}
})
onUnload(() => {
uni.$off('tempMessage:changed')
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f7f8fa;
background: #f5f7fb;
display: flex;
flex-direction: column;
}
.header {
background: #fff;
padding: 32rpx;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
.title {
font-size: 36rpx;
font-weight: 600;
color: #111;
}
.count {
font-size: 26rpx;
color: #f57c00;
background: #fff3e0;
padding: 8rpx 16rpx;
border-radius: 20rpx;
}
.content {
flex: 1;
padding: 24rpx 32rpx;
}
.empty {
height: 60vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #999;
font-size: 28rpx;
gap: 24rpx;
}
.icon {
width: 120rpx;
height: 120rpx;
width: 160rpx;
height: 160rpx;
opacity: 0.3;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
.message-card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
transition: transform 0.2s;
}
.message-card:active {
transform: scale(0.98);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
padding-bottom: 16rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.type-badge {
display: flex;
align-items: center;
gap: 8rpx;
background: #fff3e0;
padding: 8rpx 16rpx;
border-radius: 8rpx;
}
.badge-icon {
font-size: 24rpx;
}
.badge-text {
font-size: 24rpx;
color: #f57c00;
font-weight: 500;
}
.time {
font-size: 24rpx;
color: #999;
}
.card-body {
margin-bottom: 16rpx;
}
.message-title {
font-size: 32rpx;
font-weight: 600;
color: #111;
margin-bottom: 12rpx;
display: block;
}
.message-content {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.content-row {
display: flex;
font-size: 26rpx;
}
.label {
color: #666;
width: 140rpx;
flex-shrink: 0;
}
.value {
color: #333;
flex: 1;
}
.value.highlight {
color: #3a5ddd;
font-weight: 600;
}
.value.reason {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
.card-footer {
padding-top: 16rpx;
border-top: 1rpx solid #f0f0f0;
display: flex;
justify-content: flex-end;
}
.action-text {
font-size: 26rpx;
color: #3a5ddd;
}
.loading,
.no-more {
padding: 32rpx;
text-align: center;
font-size: 26rpx;
color: #999;
}
</style>

View File

@ -10,7 +10,12 @@
<view class="info" v-else>
<text class="name">{{ staff?.user_name || staff?.name || '已登录' }}</text>
<text class="sub">类型{{ userTypeText }}</text>
<text class="sub">角色{{ staff?.role_name || '-' }}</text>
<!-- <text class="sub">角色{{ staff?.role_name || '-' }}</text> -->
<text class="sub">角色{{ [
assetRole & AssetRole.CONSUMABLES_MANAGER ? '易耗品发放负责人' : undefined,
assetRole & AssetRole.DEVICE_MANAGER ? '设备管理人员' : undefined,
assetRole & AssetRole.TEAM_LEAD ? '小组负责人' : undefined
].filter(Boolean).join('、') || '普通用户'}}</text>
<text class="sub" v-if="staff?.job_no">工号{{ staff?.job_no }}</text>
<view class="actions">
<button class="btn btn-danger" @tap="logout">退出登录</button>
@ -19,12 +24,12 @@
</view>
<!-- 我的任务标题 -->
<view class="section-title">
<!-- <view class="section-title">
<text>我的任务</text>
</view>
</view> -->
<!-- 任务列表 -->
<view class="task-list">
<!-- <view class="task-list">
<view v-if="loading" class="empty">加载中...</view>
<view v-else-if="!taskList.length" class="empty">暂无任务</view>
<view v-else class="list-wrap">
@ -92,10 +97,10 @@
</view>
</view>
</view>
</view>
</view> -->
<!-- 详情弹层 -->
<view v-if="isDispDetail" class="modal">
<!-- <view v-if="isDispDetail" class="modal">
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">当前运单信息</text>
@ -127,10 +132,10 @@
<button class="btn" @tap="isDispDetail = false">关闭</button>
</view>
</view>
</view>
</view> -->
<!-- 评价弹层 -->
<view v-if="isEvaluate" class="modal">
<!-- <view v-if="isEvaluate" class="modal">
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">请对担架运送服务进行评价</text>
@ -156,9 +161,9 @@
<button class="btn" @tap="cancelEvaluate">取消</button>
</view>
</view>
</view>
</view> -->
</scroll-view>
</template>
<script setup lang="ts">
@ -169,12 +174,13 @@ import queryService from '@/service/queryService.js'
// @ts-ignore JS
import getService from '@/service/getService.js'
// @ts-ignore JS
import config from '@/config.js'
import config, { AssetRole } from '@/config'
type Task = Record<string, any>
const isLoggedIn = ref(false)
const staff = ref<any | null>(null)
const assetRole = ref<any | null>(null)
const isWeixin = ref(false)
const taskList = ref<Task[]>([])
const loading = ref(false)
@ -228,7 +234,7 @@ async function initEnv() {
// openid
const openid = uni.getStorageSync('openid')
if (!openid) {
try { await getService.getOpenId?.() } catch {}
try { await getService.getOpenId?.() } catch { }
}
}
}
@ -321,8 +327,9 @@ function cancelEvaluate() {
}
function sync() {
staff.value = uni.getStorageSync('staff') || null
isLoggedIn.value = !!staff.value
staff.value = uni.getStorageSync('staff') ?? null
assetRole.value = uni.getStorageSync('assetsRole') ?? null
isLoggedIn.value = !!staff.value || typeof assetRole.value === 'number'
}
onMounted(async () => {
@ -342,11 +349,13 @@ onShow(async () => {
min-height: 100vh;
background: #f5f7fb;
}
.card {
background: #fff;
border-radius: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.06);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
}
.profile {
margin: 32rpx;
padding: 32rpx;
@ -354,47 +363,211 @@ onShow(async () => {
align-items: center;
gap: 24rpx;
}
.avatar {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
}
.info { display: flex; flex-direction: column; }
.name { font-size: 32rpx; color: #333; }
.sub { font-size: 24rpx; color: #999; }
.actions { margin-top: 16rpx; }
.btn { padding: 12rpx 20rpx; background: #3a5ddd; color: #fff; border-radius: 10rpx; font-size: 24rpx; }
.btn-danger { background: #ef5350; }
.btn-primary { background: #3a5ddd; }
.btn-text { background: transparent; color: #3a5ddd; padding: 6rpx 10rpx; }
.btn-text.red { color: #ef5350; }
.btn-text.blue { color: #3a5ddd; }
.btn-text.yellow { color: #f39c12; }
.section-title { padding: 0 32rpx 12rpx; color: #666; font-size: 26rpx; }
.task-list { padding: 0 32rpx 32rpx; }
.empty { text-align: center; color: #9a9aa0; padding: 40rpx 0; }
.list-wrap { display: flex; flex-direction: column; gap: 20rpx; }
.task { padding: 20rpx; }
.row { display: flex; align-items: center; padding: 8rpx 0; flex-wrap: wrap; }
.two-cols { justify-content: space-between; }
.two-cols .col { width: 48%; display: flex; }
.label { color: #666; font-size: 24rpx; }
.value { color: #333; font-size: 26rpx; }
.actions-inline { display: flex; justify-content: flex-end; gap: 16rpx; padding-top: 8rpx; }
.actions-inline .btn{ flex: 1;}
.info {
display: flex;
flex-direction: column;
}
.name {
font-size: 32rpx;
color: #333;
}
.sub {
font-size: 24rpx;
color: #999;
}
.actions {
margin-top: 16rpx;
}
.btn {
padding: 12rpx 20rpx;
background: #3a5ddd;
color: #fff;
border-radius: 10rpx;
font-size: 24rpx;
}
.btn-danger {
background: #ef5350;
}
.btn-primary {
background: #3a5ddd;
}
.btn-text {
background: transparent;
color: #3a5ddd;
padding: 6rpx 10rpx;
}
.btn-text.red {
color: #ef5350;
}
.btn-text.blue {
color: #3a5ddd;
}
.btn-text.yellow {
color: #f39c12;
}
.section-title {
padding: 0 32rpx 12rpx;
color: #666;
font-size: 26rpx;
}
.task-list {
padding: 0 32rpx 32rpx;
}
.empty {
text-align: center;
color: #9a9aa0;
padding: 40rpx 0;
}
.list-wrap {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.task {
padding: 20rpx;
}
.row {
display: flex;
align-items: center;
padding: 8rpx 0;
flex-wrap: wrap;
}
.two-cols {
justify-content: space-between;
}
.two-cols .col {
width: 48%;
display: flex;
}
.label {
color: #666;
font-size: 24rpx;
}
.value {
color: #333;
font-size: 26rpx;
}
.actions-inline {
display: flex;
justify-content: flex-end;
gap: 16rpx;
padding-top: 8rpx;
}
.actions-inline .btn {
flex: 1;
}
/* 简易弹层样式 */
.modal { position: fixed; left: 0; top: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; z-index: 999; }
.modal-content { width: 86%; max-height: 76vh; background: #fff; border-radius: 16rpx; overflow: hidden; display: flex; flex-direction: column; }
.modal-header { padding: 20rpx 24rpx; display: flex; align-items: center; justify-content: space-between; border-bottom: 1rpx solid #eee; }
.modal-title { font-size: 30rpx; color: #222; font-weight: 600; }
.modal-close { font-size: 40rpx; color: #999; }
.modal-body { padding: 16rpx 24rpx; max-height: 60vh; }
.modal-footer { padding: 16rpx 24rpx; display: flex; justify-content: flex-end; gap: 16rpx; border-top: 1rpx solid #eee; }
.modal-footer .btn { flex: 1; }
.form-row { margin-bottom: 20rpx; }
.radio-group { display: flex; gap: 24rpx; flex-wrap: wrap; }
.radio-item { display: flex; align-items: center; gap: 8rpx; }
.textarea { width: 100%; min-height: 160rpx; border: 1rpx solid #e5e6eb; border-radius: 12rpx; padding: 12rpx; font-size: 26rpx; }
.modal {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.modal-content {
width: 86%;
max-height: 76vh;
background: #fff;
border-radius: 16rpx;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 20rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1rpx solid #eee;
}
.modal-title {
font-size: 30rpx;
color: #222;
font-weight: 600;
}
.modal-close {
font-size: 40rpx;
color: #999;
}
.modal-body {
padding: 16rpx 24rpx;
max-height: 60vh;
}
.modal-footer {
padding: 16rpx 24rpx;
display: flex;
justify-content: flex-end;
gap: 16rpx;
border-top: 1rpx solid #eee;
}
.modal-footer .btn {
flex: 1;
}
.form-row {
margin-bottom: 20rpx;
}
.radio-group {
display: flex;
gap: 24rpx;
flex-wrap: wrap;
}
.radio-item {
display: flex;
align-items: center;
gap: 8rpx;
}
.textarea {
width: 100%;
min-height: 160rpx;
border: 1rpx solid #e5e6eb;
border-radius: 12rpx;
padding: 12rpx;
font-size: 26rpx;
}
</style>

View File

@ -138,7 +138,7 @@ import queryService from '@/service/queryService.js'
// @ts-ignore
import getService from '@/service/getService.js'
// @ts-ignore
import config from '@/config.js'
import config from '@/config'
const showPayment = ref(true) // false

View File

@ -112,7 +112,7 @@ import queryService from '@/service/queryService.js'
// @ts-ignore
import getService from '@/service/getService.js'
// @ts-ignore
import config from '@/config.js'
import config from '@/config'
//
const form = reactive({

View File

@ -1,4 +1,4 @@
import config from '../config.js';
import config from '../config';
import util from '../utils/util.js';
import queryService from '@/service/queryService.js';

View File

@ -1,4 +1,4 @@
import config from '../config.js';
import config from '../config';
import util from '../utils/util.js';
import * as taskState from '../constant/taskState.js';
import taskType from '../constant/taskType.js';

View File

@ -1,5 +1,5 @@
import util from "../utils/util.js";
import config from "../config.js";
import config from "../config";
export default {
// 上传文件

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

103
src/utils/messageManager.ts Normal file
View File

@ -0,0 +1,103 @@
/**
*
* TabBar
*/
import consumableTempAPI from '@/pages/api/consumable-temp'
// 消息数量缓存
let pendingCount = 0
let pollingTimer: number | null = null
/**
*
*/
export async function fetchPendingCount(): Promise<number> {
try {
const res = await consumableTempAPI.getRequestList({
page: 1,
pageSize: 100, // 获取足够多的数据来计算总数
status: 'PENDING'
})
if (res.statusCode === 200 && (res.data as any)?.data) {
const data = (res.data as any).data
pendingCount = data.length || 0
// 更新 TabBar 小红点
updateTabBarBadge(pendingCount)
return pendingCount
}
} catch (err) {
console.error('获取待审批数量失败:', err)
}
return 0
}
/**
* TabBar
*/
function updateTabBarBadge(count: number) {
try {
if (count > 0) {
// 显示小红点如果数量大于0则显示数字
uni.setTabBarBadge({
index: 1, // 消息 tab 的索引从0开始
text: count > 99 ? '99+' : count.toString()
})
} else {
// 移除小红点
uni.removeTabBarBadge({
index: 1
})
}
} catch (err) {
// 某些页面没有 TabBar忽略错误
console.warn('更新 TabBar 角标失败(可能当前页面无 TabBar', err)
}
}
/**
*
*/
export function getPendingCount(): number {
return pendingCount
}
/**
*
* @param interval 30
*/
export function startPolling(interval: number = 30000) {
// 先立即执行一次
fetchPendingCount()
// 清除之前的定时器
if (pollingTimer) {
clearInterval(pollingTimer)
}
// 设置新的定时器
pollingTimer = setInterval(() => {
fetchPendingCount()
}, interval) as unknown as number
}
/**
*
*/
export function stopPolling() {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
}
/**
*
*/
export function refreshNow() {
return fetchPendingCount()
}

View File

@ -1,4 +1,4 @@
import config from '../config.js';
import config from '../config';
const getStateCreateTime = (stateList, type) => {
if(stateList && stateList.length != 0){