Files
moc/src/views/changeLedger/modules/list.vue
T
2026-09-23 13:23:36 +08:00

597 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue';
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router';
import { tagTextColor, tagBgColor, tagBorderColor, getTimeRangeStamp, formatTimestamp } from '@/utils/common';
import { systemConfigApi } from '@/service/api/system';
import { orgListApi } from '@/service/api/user';
import { changeLedgerListApi } from '@/service/api/changeLedger';
const { routerPushByKey } = useRouterPush();
const themeStore = useThemeStore();
const props = defineProps(['self', 'keyValue'])
const loading = ref<boolean>(false);
// 当前选中时间范围
const period = ref<string>("year");
// 组织树
const orgTree = ref<any[]>([]);
// 筛选条件
const formInfo = ref<any>({
kw: '',
type: null,
level: null,
duration: null,
status: '',//英文逗号隔开
org_id: 1,
apply_time_start: getTimeRangeStamp(period.value)?.from ?? null,
apply_time_end: getTimeRangeStamp(period.value)?.to ?? null,
self: props.self,
});
const pagination = ref<{page: number, pageSize: number, itemCount: number}>({
page: 1,
pageSize: 20,
itemCount: 0,
})
// 变更类型列表
const typeList = ref<any[]>([{ label: "全部", value: null }]);
// 变更等级列表
const levelList = ref<any[]>([{ label: "全部", value: null }]);
// 状态选项
const STATUS_OPTIONS = [
{ label: "全部", value: "", type:'all' },
{ label: "审批中", value: "APPROVING", type:'warning' },
{ label: "已通过", value: "APPROVED", type:'success' },
{ label: "待验收", value: "IMPLEMENTED", type:'success' },
{ label: "待关闭", value: "ACCEPTED", type:'error' },
{ label: "已关闭", value: "CLOSED", type:'success' },
{ label: "已撤回", value: "WITHDRAWN", type:'error' },
{ label: "已驳回", value: "REJECTED", type:'error' },
];
// 排序选项
const SORT_OPTS = [
{ label: "申请时间 · 最新", value: "apply_time,desc" },
{ label: "申请时间 · 最早", value: "apply_time,asc" },
{ label: "计划投用 · 最新", value: "plan_use_date,desc" },
{ label: "计划投用 · 最早", value: "plan_use_date,asc" },
{ label: "状态更新 · 最新", value: "status_changed_at,desc" },
{ label: "状态更新 · 最早", value: "status_changed_at,asc" },
];
// 时限选项
const DUR_OPTIONS = [
{ label: "全部", value: null },
{ label: "永久", value: 1 },
{ label: "临时", value: 2 }
];
// 时间选项
const TIME_OPTIONS = [
{ label: "近一年", value: 'year' },
{ label: "近半年", value: 'half_year' },
{ label: "近三个月", value: 'quarter' },
{ label: "近一个月", value: 'month' },
{ label: "自定义", value: 'custom' },
];
// 是否显示全部
const isAll = ref<boolean>(true);
// 当前选中行
const currentRow = ref<any>(null);
// 进度抽屉
const progressDrawer = ref<boolean>(false);
// 撤回/未实施情况说明弹框
const revokeModal = ref<boolean>(false);
const modalType = ref<string>('');
const reason = ref<string>('');
const revokeSaving = ref<boolean>(false);
// 列表
const list = ref<any[]>([]);
// 导出台账
const exportChanges = () => {
window.$message?.info('还差接口');
}
// 自定义渲染展开图标
const renderSwitcherIcon = (node:any) => {
// 如果节点没有 children 或 children 为空数组,则不显示图标
if (!node.option.children || node.option.children.length === 0) {
return null; // 返回 null 表示不渲染任何图标
}
// 有子节点:返回 undefined 表示使用默认展开图标,也可自定义
return h(Icon,{
icon: node.option.expanded ? 'bi:caret-down-fill' : 'bi:caret-right-fill',
class: 'size-12px',
});
}
// 匹配对象
const matchObj= (list:any[],val:any) => {
const item = list.find((item:any) => item.value == val);
return item || null;
}
// 搜索
const searchHandle = () => {
pagination.value.page = 1;
getList();
}
// 重置
const reset = () => {
period.value = 'year';
formInfo.value.kw = '';
formInfo.value.type = null;
formInfo.value.level = null;
formInfo.value.duration = null;
formInfo.value.status = '';
formInfo.value.org_id = 1;
formInfo.value.sort = '';
formInfo.value.apply_time_start = getTimeRangeStamp(period.value)?.from ?? null;
formInfo.value.apply_time_end = getTimeRangeStamp(period.value)?.to ?? null;
isAll.value = true;
searchHandle();
};
// 组织选择改变
const orgChange = (val:any) => {
formInfo.value.org_id = val;
searchHandle();
}
// 显示全部改变
const showAll = () => {
isAll.value = !isAll.value;
if(isAll.value){
formInfo.value.status = '';
}else{
formInfo.value.status = 'APPROVING,APPROVED,IMPLEMENTED,ACCEPTED,WITHDRAWN,REJECTED';
}
searchHandle();
}
// 类型选择
const typeChoose = (val:any) => {
formInfo.value.type = val;
searchHandle();
}
// 时限选择
const durChoose = (val:any) => {
formInfo.value.duration = val;
searchHandle();
}
// 时间范围选择
const timeChoose = (val:string) => {
period.value = val;
if(val === 'custom'){
formInfo.value.apply_time_start = null;
formInfo.value.apply_time_end = null;
return
}else{
formInfo.value.apply_time_start = getTimeRangeStamp(val)?.from ?? null;
formInfo.value.apply_time_end = getTimeRangeStamp(val)?.to ?? null;
}
searchHandle();
}
// 自定义时间选择
const timeChange = (type:string,val:any) => {
if(type === 'start'){
formInfo.value.apply_time_start = val;
}else{
formInfo.value.apply_time_end = val;
}
if(formInfo.value.apply_time_start && formInfo.value.apply_time_end){
if(formInfo.value.apply_time_start > formInfo.value.apply_time_end){
return window.$message?.warning('开始时间不能大于结束时间');
}
searchHandle();
}
}
// 状态选择
const statusChoose = (val:string) => {
formInfo.value.status = val;
searchHandle();
}
// 排序选择
const sortChoose = (val:string) => {
formInfo.value.sort = val;
searchHandle();
}
// 跳转详情
const jumpTo = (r:any) => {
routerPushByKey("details_index",{ query: { id: r?.id?.toString(),status:r?.status?.toString() || '',key:props.keyValue } });
}
// 打开进度
const openProgress = (r: any) => {
currentRow.value = r;
progressDrawer.value = true;
};
// 撤回/未实施情况说明
const openModal = (r: any,type:string) => {
currentRow.value = r;
modalType.value = type;
revokeModal.value = true;
}
// 撤回/未实施情况说明提交
const revokeSave = async () => {
if (!reason.value) {
return window.$message?.warning(modalType.value === "withdraw" ? "请输入撤回理由" : "请输入未实施情况说明");
}
revokeSaving.value = true;
setTimeout(() => {
revokeSaving.value = false;
}, 1000);
}
// 下载文件
const downloadFiles = (r:any,type:string) => {
// 下载申请表
if(type==='apply'){
}
// 下载已验收待关闭
if(type==='accepted'){
}
// 下载归档包
if(type==='closed'){
}
window.$message?.info('还差接口');
}
// 催办
const urge = (r:any) => {
window.$message?.info('还差接口');
}
// 分页改变
const pageChange = (page: number) => {
pagination.value.page = page;
getList();
}
// 列表查询
const getList = async () => {
loading.value = true;
const {error, data} = await changeLedgerListApi({
kw: formInfo.value.kw,
type: formInfo.value.type,
level: formInfo.value.level,
duration: formInfo.value.duration,
status: formInfo.value.status,
org_id: formInfo.value.org_id,
apply_time_start: formatTimestamp(formInfo.value.apply_time_start),
apply_time_end: formatTimestamp(formInfo.value.apply_time_end),
self: formInfo.value.self,
page_num: pagination.value.page,
page_size: pagination.value.pageSize,
});
if (!error) {
list.value = data?.list ?? [];
pagination.value.itemCount = data?.total ?? 0;
}
loading.value = false;
}
// 获取配置
const getConfig = async () => {
const {data,error} = await systemConfigApi();
if(!error){
// 解析 变更类型设置
const changeTypeGroup = data.find((group:{group_key:string}) => group.group_key === 'change_type');
if (changeTypeGroup) {
typeList.value = typeList.value.concat(changeTypeGroup.items);
}
// 解析 变更等级设置
const changeLevelGroup = data.find((group:{group_key:string}) => group.group_key === 'change_level');
if (changeLevelGroup) {
levelList.value = levelList.value.concat(changeLevelGroup.items);
}
}
}
// 获取组织列表
const getOrgList = async () => {
loading.value = true;
const {data,error} = await orgListApi();
if(!error){
orgTree.value = data;
if(orgTree.value.length > 0){
getList();
}
}
loading.value = false;
}
onMounted(async () => {
await getConfig();
getOrgList();
});
</script>
<template>
<NSpace vertical :size="16">
<!-- 我发起的变更 -->
<NSpin :show="loading" size="small">
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<!-- 工具条 -->
<div
class="flex flex-wrap items-center gap-2 border px-3 py-2"
:style="{ borderRadius: `${themeStore.themeRadius}px` }"
>
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
<Icon icon="akar-icons:file" class="size-16px" :style="{ color: themeStore.themeColor }" /> 全部变更
<span class="rounded bg-slate-100 px-1.5 text-xs text-slate-500">{{ pagination.itemCount }}</span>
</span>
<NTreeSelect
class="!w-200px"
:value="formInfo.org_id"
:options="orgTree"
size="small"
default-expand-all
label-field="label"
key-field="id"
:render-switcher-icon="renderSwitcherIcon"
placeholder="请选择组织层级"
@update:value="orgChange"
/>
<NRadio
size="small"
:checked="isAll"
:value="true"
name="显示全部"
:on-update:checked="showAll"
>
显示全部
</NRadio>
<NRadio
size="small"
:checked="!isAll"
:value="false"
name="显示未关闭"
:on-update:checked="showAll"
>
显示未关闭
</NRadio>
<div class="relative ml-auto">
<NInput v-model:value="formInfo.kw" size="small" type="text" clearable placeholder="搜索变更名称 / 编号" @clear="formInfo.kw = ''; searchHandle()">
<template #suffix>
<Icon icon="akar-icons:search" class="size-14px text-slate-400 cursor-pointer" @click="searchHandle" />
</template>
</NInput>
</div>
<NButton size="small" class="text-sm" @click="reset">
<Icon icon="system-uicons:reset" class="size-12px" />重置
</NButton>
<NButton size="small" class="text-sm" @click="exportChanges">
<Icon icon="material-symbols:download" class="size-14px" />导出台账
</NButton>
</div>
<!-- 筛选与排序行 -->
<div
class="flex flex-col gap-y-2 border px-3 py-2 my-3"
:style="{ borderRadius: `${themeStore.themeRadius}px` }"
>
<div class="flex items-center gap-x-4">
<p class="flex items-center gap-1.5 min-h-28px">
<span class="text-xs text-slate-400">类型</span>
<NButton size="tiny" round v-for="t in typeList" :key="t.label" :ghost="formInfo.type === t.value ? false : true" :type="formInfo.type === t.value ? 'primary' : 'default'" @click="typeChoose(t.value)">{{ t.label }}</NButton>
</p>
<p class="flex items-center gap-1.5 min-h-28px">
<span class="text-xs text-slate-400">时限</span>
<NButton size="tiny" round v-for="d in DUR_OPTIONS" :key="d.label" :ghost="formInfo.duration === d.value ? false : true" :type="formInfo.duration === d.value ? 'primary' : 'default'" @click="durChoose(d.value)">{{ d.label }}</NButton>
</p>
<p class="flex flex-wrap items-center gap-1.5 min-h-28px">
<span class="text-xs text-slate-400">时间</span>
<NButton size="tiny" round v-for="p in TIME_OPTIONS" :key="p.value" :ghost="period === p.value ? false : true" :type="period === p.value ? 'primary' : 'default'" @click="timeChoose(p.value)">{{ p.label }}</NButton>
<span v-if="period === 'custom'" class="flex items-center gap-1">
<NDatePicker :value="formInfo.apply_time_start" type="date" size="small" class="w-130px" :on-update:value="(v) => timeChange('start',v)" />
<span class="text-xs text-slate-400">至</span>
<NDatePicker :value="formInfo.apply_time_end" type="date" size="small" class="w-130px" :on-update:value="(v) => timeChange('end',v)" />
</span>
</p>
</div>
<div class="flex items-center gap-x-4">
<p class="flex flex-wrap items-center gap-1.5 min-h-28px">
<span class="text-xs text-slate-400">状态</span>
<NButton size="tiny" round v-for="s in STATUS_OPTIONS" :key="s.value" :ghost="formInfo.status === s.value ? false : true" :type="formInfo.status === s.value ? 'primary' : 'default'" @click="statusChoose(s.value)">{{ s.label }}</NButton>
</p>
<p class="ml-auto flex items-center gap-1.5 min-h-28px">
<span class="text-xs text-slate-400">排序</span>
<NSelect size="small" class="!w-36" v-model:value="formInfo.sort" :options="SORT_OPTS" :on-update:value="sortChoose" />
</p>
</div>
</div>
<div class="space-y-3 scroll">
<!-- 列表 -->
<div
v-for="(r, i) in list" :key="r.id"
class="rounded-lg border bg-white px-4 py-3"
:style="{
'--theme-color': themeStore.themeColor
}"
>
<div class="flex flex-wrap items-start gap-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-slate-300">{{ Number(i)+1 }}</span>
<p class="cursor-pointer text-left text-sm font-semibold text-slate-800 hover:text-[var(--theme-color)] hover:underline" @click="jumpTo(r)">
{{ r.title }}
</p>
<NTag size="small" v-if="r.urgent" class="animate-breathe border-[#E15555] bg-[#E15555] text-white">紧急</NTag>
<span class="font-mono text-[11px] text-slate-400">{{ r.change_no }}</span>
</div>
<div class="mt-1.5 flex flex-wrap items-center gap-1.5">
<!-- 类型 -->
<NTag
size="small"
:color="{
color: tagBgColor(matchObj(typeList,r.change_type)?.color),
textColor: tagTextColor(matchObj(typeList,r.change_type)?.color),
borderColor: tagBorderColor(matchObj(typeList,r.change_type)?.color),
}"
>
{{ matchObj(typeList,r.change_type)?.label }}
</NTag>
<!-- 等级 -->
<NTag
size="small"
:color="{
color: tagBgColor(matchObj(levelList,r.change_level)?.color),
textColor: tagTextColor(matchObj(levelList,r.change_level)?.color),
borderColor: tagBorderColor(matchObj(levelList,r.change_level)?.color),
}"
>
{{ matchObj(levelList,r.change_level)?.label }}
</NTag>
<NTag v-if="r.duration_type===2" size="small" type="warning">临时</NTag>
<NTag v-if="r.duration_type===1" size="small" type="info">永久</NTag>
<NTag v-if="r.extend_count" size="small" type="warning">延期</NTag>
</div>
<div class="mt-1.5 flex flex-wrap gap-x-4 gap-y-0.5 text-[11px] text-slate-500">
<span v-if="r.apply_time">申请 {{ r.apply_time?.slice(0,16) }}</span>
<span>{{ r.org_name }} · {{ r.applicant_name }}</span>
<span v-if="r.plan_use_date">计划投用 {{ r.plan_use_date?.slice(0,16) }}</span>
<span v-if="r.use_time">实际投用 {{ r.use_time?.slice(0,16) }}</span>
<span v-if="r.restore_deadline">计划恢复 {{ r.restore_deadline?.slice(0,16) }}</span>
<span v-if="r.close_time">关闭 {{ r.close_time?.slice(0,16) }}</span>
<span v-if="r.status_changed_at">状态更新 {{ r.status_changed_at?.slice(0,16) }}</span>
</div>
</div>
<div class="flex shrink-0 flex-col items-end gap-1.5">
<div class="flex flex-wrap items-center justify-end gap-2">
<!-- 状态标签 -->
<NTag v-if="r.status==='APPROVING'" size="small" type="warning">审批中</NTag>
<NTag v-if="r.status==='APPROVED'" size="small" type="success">已通过</NTag>
<NTag v-if="r.status==='IMPLEMENTED'" size="small" type="success">待验收</NTag>
<NTag v-if="r.status==='ACCEPTED'" size="small" type="error">待关闭</NTag>
<NTag v-if="r.status==='CLOSED'" size="small" type="success">已关闭</NTag>
<NTag v-if="r.status==='WITHDRAWN'" size="small" type="error">已撤回</NTag>
<NTag v-if="r.status==='REJECTED'" size="small" type="error">已驳回</NTag>
<!-- 超期 -->
<span v-if="r.overdue_status===2" class="text-[11px] font-medium text-red-500">超期 {{ r.overdue_days }} 天</span>
</div>
<!-- 操作按钮 -->
<div class="flex flex-wrap items-center justify-end gap-1 pt-0.5">
<!-- 审批中 -->
<NButton v-if="r.status==='APPROVING'" size="small" ghost type="error" class="text-xs" @click="openModal(r,'withdraw')">撤回</NButton>
<!-- 已批准待实施 -->
<NButton v-if="r.status==='APPROVED'" size="small" ghost type="primary" class="text-xs" @click="openModal(r,'unimplemented')">未实施情况说明</NButton>
<NButton v-if="r.status==='APPROVED'" size="small" ghost type="success" class="text-xs" @click="downloadFiles(r,'apply')">下载申请表</NButton>
<!-- 已验收待关闭 -->
<NButton v-if="r.status==='ACCEPTED'" size="small" ghost type="success" class="text-xs" @click="downloadFiles(r,'accepted')">下载</NButton>
<!-- 已关闭 -->
<NButton v-if="r.status==='CLOSED'" size="small" ghost type="success" class="text-xs" @click="downloadFiles(r,'closed')">下载归档包</NButton>
<!-- 催办(超期) -->
<NButton v-if="r.overdue_days" size="small" ghost type="error" class="text-xs" @click="urge(r)">催办</NButton>
<NButton size="small" type="primary" class="text-xs" @click="jumpTo(r)">详情</NButton>
<NButton size="small" type="default" class="text-xs" @click="openProgress(r)">进度</NButton>
</div>
<!-- 临时变更 -->
<div v-if="r.duration_type === 2" class="text-right text-[10px] leading-4 text-slate-400">临时变更恢复 / 延期 / 转永久请在详情页中操作</div>
</div>
</div>
</div>
<div v-if="list.length === 0" class="rounded-lg border border-dashed bg-white py-12 text-center text-sm text-slate-400">当前筛选条件下暂无变更记录</div>
</div>
<div class="flex justify-end mt-3">
<NPagination
v-model:page="pagination.page"
:page-size="pagination.pageSize"
:item-count="pagination.itemCount"
:on-update:page="pageChange"
>
<template #prefix="{ itemCount }">
共 {{ itemCount }} 条
</template>
</NPagination>
</div>
<!-- 撤回/未实施情况说明/未投用情况说明弹框 -->
<NModal
v-model:show="revokeModal"
preset="card"
:auto-focus="false"
:style="{ width: '450px', height: 'auto' }"
:segmented="{ content: true, footer: true }"
>
<template #header>
<h3 class="text-sm font-bold text-slate-800">
<span v-if="modalType === 'withdraw'">撤回变更申请</span>
<span v-else-if="modalType === 'unuse'">未投用情况说明</span>
<span v-else>未实施情况说明</span>
{{currentRow?.change_no}}
</h3>
</template>
<template #default>
<div class="rounded-lg bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-500">
{{ modalType === 'withdraw'
? '撤回后申请退回草稿状态,已产生的审批记录保留可查;修改内容后可重新提交。仅本人发起的审批中变更可撤回。'
: '变更审批通过 / 投用后未实施或未投用的,由申请人填写情况说明,提交后变更转入关闭流程,由变更主管部门专责确认关闭并记录关闭时间。' }}
</div>
<NInput
class="mt-3"
v-model:value="reason"
type="textarea"
:rows="3"
:placeholder="modalType === 'withdraw' ? '撤回理由(必填)…' : '情况说明(必填):原因、现场当前状态、后续处置安排…'"
/>
</template>
<template #footer>
<div class="flex justify-end gap-[10px]">
<NButton size="small" @click="revokeModal = false">取消</NButton>
<NButton size="small" type="primary" :loading="revokeSaving" @click="revokeSave">提交</NButton>
</div>
</template>
</NModal>
<!-- 进度弹框 -->
<NDrawer v-model:show="progressDrawer" :width="502" placement="right">
<NDrawerContent title="变更进度">
<div class="rounded-lg border bg-slate-50/60 px-3 py-2.5">
<div class="text-sm font-semibold text-slate-800">{{ currentRow?.title }}</div>
<div class="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px] text-slate-400">
<span class="font-mono">{{ currentRow?.change_no }}</span>
<NTag v-if="currentRow?.status==='APPROVING'" size="small" type="warning">审批中</NTag>
<NTag v-if="currentRow?.status==='APPROVED'" size="small" type="success">已通过</NTag>
<NTag v-if="currentRow?.status==='IMPLEMENTED'" size="small" type="warning">待验收</NTag>
<NTag v-if="currentRow?.status==='ACCEPTED'" size="small" type="error">待关闭</NTag>
<NTag v-if="currentRow?.status==='CLOSED'" size="small" type="error">已关闭</NTag>
<NTag v-if="currentRow?.status==='WITHDRAWN'" size="small" type="error">已撤回</NTag>
<NTag v-if="currentRow?.status==='REJECTED'" size="small" type="error">已驳回</NTag>
<span v-if="currentRow?.overdue_status===2" class="font-medium text-red-500">超期 {{ currentRow?.overdue_days }} 天</span>
</div>
<div v-if="currentRow?.dept || currentRow?.applicant" class="mt-1 text-[11px] text-slate-400">{{ [currentRow?.dept, currentRow?.applicant].filter(Boolean).join(' · ') }}</div>
<div v-if="currentRow?.meta" class="mt-1 text-[11px] text-slate-400">{{ currentRow?.meta }}</div>
</div>
<div class="mt-4">
<div v-for="(s, i) in currentRow?.approval_records" :key="s.id" class="flex gap-3">
<div class="flex flex-col items-center">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 border-[#7CC242] bg-[#7CC242] text-white">
<Icon icon="ix:success" />
</span>
<span
v-if="Number(i) < Number(currentRow?.approval_records?.length) - 1"
class="w-0.5 flex-1"
:class="Number(i) < Number(currentRow?.approval_records?.length) ? 'bg-[#7CC242]' : 'bg-slate-200'"
></span>
</div>
<div class="pb-5">
<p class="text-sm">{{ s.action }}</p>
<span class="mt-0.5 text-[11px] text-slate-400">{{s.finished_at?.slice(0, 16)}}</span>
</div>
</div>
</div>
<div v-if="currentRow?.duration_type === 2" class="rounded-lg border border-amber-200 bg-amber-50/50 px-3 py-2 text-[11px] leading-5 text-amber-700">
临时变更:计划恢复 {{ currentRow?.restore_deadline ?? '—' }} · 恢复 / 延期 / 转永久请在详情页中操作
</div>
</NDrawerContent>
</NDrawer>
</NCard>
</NSpin>
</NSpace>
</template>
<style scoped>
</style>
<style scoped lang="scss">
.scroll {
height: calc(100vh - 338px);
overflow-y: auto;
}
</style>