Files
cn-rdms-web/src/views/feedback/index.vue
dk bc5815416b fix(工作反馈、工作日志): 添加超大尺寸对话框预设并优化反馈搜索功能。
- 在 BusinessFormDialog 组件中添加 xl 尺寸预设,宽度设置为 1200px
- 移除 feedback-search.vue 中废弃的 useDict 钩子导入
- 将反馈搜索的状态选择器从普通下拉改为字典类型,统一值转换逻辑
- 更新反馈初始搜索参数中的状态值类型从数字改为字符串
- 任务工时对话框使用 xl 预设以获得更大显示区域
2026-07-03 17:34:48 +08:00

330 lines
10 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="tsx">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import { FEEDBACK_STATUS_DICT_CODE, FEEDBACK_TYPE_DICT_CODE } from '@/constants/dict';
import { getFeedbackStatusTagType } from '@/constants/status-tag';
import { fetchDeleteFeedback, fetchGetFeedbackPage, fetchGetFeedbackStat } from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useUIPaginatedTable } from '@/hooks/common/table';
import DictTag from '@/components/custom/dict-tag.vue';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import FeedbackFacet from './modules/feedback-facet.vue';
import FeedbackSearch from './modules/feedback-search.vue';
import FeedbackOperateDialog from './modules/feedback-operate-dialog.vue';
import FeedbackDetailDialog from './modules/feedback-detail-dialog.vue';
import FeedbackStatusDialog from './modules/feedback-status-dialog.vue';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiProgressCheck from '~icons/mdi/progress-check';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
defineOptions({ name: 'Feedback' });
const authStore = useAuthStore();
// 动态路由模式下后端不返回 R_SUPER超管按固定登录名 admin 识别(写操作放开依据)
const isSuper = computed(() => authStore.userInfo.userName === 'admin');
// 后端已移除反馈相关权限码:提交对所有登录用户开放;写操作纯前端按「归属 + 超管」控制
/** 是否本人提交 */
function isOwn(row: Api.Feedback.FeedbackItem) {
return authStore.userInfo.userId === row.creator;
}
/** 修改状态:仅超管 */
const canUpdateStatus = computed(() => isSuper.value);
/** 删除按钮逐行显隐:自己提交的,或超管 */
function canDeleteRow(row: Api.Feedback.FeedbackItem) {
return isOwn(row) || isSuper.value;
}
/** 编辑按钮逐行显隐:自己提交的,或超管 */
function canEditRow(row: Api.Feedback.FeedbackItem) {
return isOwn(row) || isSuper.value;
}
function getInitSearchParams(): Api.Feedback.FeedbackSearchParams {
return { pageNo: 1, pageSize: 20, type: undefined, status: '1', title: undefined, creator: undefined };
}
function transformPageResult(
response: Awaited<ReturnType<typeof fetchGetFeedbackPage>>,
pageNo: number,
pageSize: number
) {
if (!response.error) {
return { data: response.data.list, pageNum: pageNo, pageSize, total: response.data.total };
}
return { data: [], pageNum: pageNo, pageSize, total: 0 };
}
const searchParams = reactive(getInitSearchParams());
// 弹层 / 抽屉开关与上下文
const operateVisible = ref(false);
const operateMode = ref<'create' | 'edit'>('create');
const operateRow = ref<Api.Feedback.FeedbackItem | null>(null);
const detailVisible = ref(false);
const detailId = ref<string | null>(null);
const statusVisible = ref(false);
const statusId = ref<string | null>(null);
const statusCurrent = ref<number | null>(null);
function openCreate() {
operateMode.value = 'create';
operateRow.value = null;
operateVisible.value = true;
}
function openEdit(row: Api.Feedback.FeedbackItem) {
operateMode.value = 'edit';
operateRow.value = row;
operateVisible.value = true;
}
function openDetail(row: Api.Feedback.FeedbackItem) {
detailId.value = row.id;
detailVisible.value = true;
}
function openStatus(row: Api.Feedback.FeedbackItem) {
statusId.value = row.id;
statusCurrent.value = row.status;
statusVisible.value = true;
}
const { columns, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetFeedbackPage(searchParams),
transform: response => transformPageResult(response, searchParams.pageNo, searchParams.pageSize),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 20;
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64, align: 'center' },
{ prop: 'title', label: '标题', minWidth: 180, showOverflowTooltip: true },
{
prop: 'content',
label: '内容',
minWidth: 260,
showOverflowTooltip: true,
formatter: row => <span>{row.contentPreview}</span>
},
{
prop: 'type',
label: '分类',
width: 110,
align: 'center',
formatter: row => <DictTag dictCode={FEEDBACK_TYPE_DICT_CODE} value={row.type} />
},
{
prop: 'status',
label: '状态',
width: 110,
align: 'center',
formatter: row => (
<DictTag dictCode={FEEDBACK_STATUS_DICT_CODE} value={row.status} type={getFeedbackStatusTagType(row.status)} />
)
},
{
prop: 'creatorName',
label: '提交人',
width: 140,
formatter: row => <span>{row.creatorName || row.creator}</span>
},
{ prop: 'createTime', label: '提交时间', width: 180 },
{
prop: 'operate',
label: '操作',
width: 140,
align: 'center',
fixed: 'right',
formatter: row => <BusinessTableActionCell variant="icon" actions={getRowActions(row)} />
}
]
});
// 左侧分面统计(全量口径,不随筛选变化;仅在进入页面与写操作后刷新)
const facetStat = ref<Api.Feedback.FeedbackStat>({ total: 0, typeCounts: {}, statusCounts: {} });
const facetLoading = ref(false);
async function loadFacet() {
facetLoading.value = true;
try {
const { data: stat, error } = await fetchGetFeedbackStat();
if (!error && stat) {
facetStat.value = stat;
}
} finally {
// 统计接口异常(含 normalize 解引用空数据抛错)也要复位,避免分面面板 v-loading 永久转圈
facetLoading.value = false;
}
}
async function handleDelete(row: Api.Feedback.FeedbackItem) {
const confirmed = await ElMessageBox.confirm('确认删除该意见反馈?', '提示', { type: 'warning' })
.then(() => true)
.catch(() => false);
if (!confirmed) {
return;
}
const { error } = await fetchDeleteFeedback(row.id);
if (error) {
return;
}
window.$message?.success('删除成功');
await Promise.all([getData(), loadFacet()]);
}
function getRowActions(row: Api.Feedback.FeedbackItem): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
{ key: 'detail', label: '查看详情', buttonType: 'primary', icon: IconMdiEyeOutline, onClick: () => openDetail(row) }
];
if (canEditRow(row)) {
actions.push({
key: 'edit',
label: '编辑',
buttonType: 'primary',
icon: IconMdiPencilOutline,
onClick: () => openEdit(row)
});
}
if (canUpdateStatus.value) {
actions.push({
key: 'status',
label: '处理',
buttonType: 'warning',
icon: IconMdiProgressCheck,
onClick: () => openStatus(row)
});
}
if (canDeleteRow(row)) {
actions.push({
key: 'delete',
label: '删除',
buttonType: 'danger',
icon: IconMdiDeleteOutline,
onClick: () => handleDelete(row)
});
}
return actions;
}
function handleSearch() {
searchParams.pageNo = 1;
getDataByPage(1);
}
// 搜索区「重置」清搜索区自有字段(标题、提交人);左侧分面选中的 type/status 由分面单独控制,不在此连带清除
function resetSearchParams() {
searchParams.title = undefined;
searchParams.creator = undefined;
searchParams.pageNo = 1;
getDataByPage(1);
}
function handleSubmitted() {
// 新增回到首页看最新;编辑只刷新当前页,避免跳页
if (operateMode.value === 'edit') {
getData();
} else {
getDataByPage(1);
}
loadFacet();
}
function handleStatusUpdated() {
getData();
loadFacet();
}
// 左侧分面点击:单选切换 toggle 由面板内部处理,这里只接收最终值并刷新列表
function handleSelectType(value?: string | number) {
searchParams.type = value ?? undefined;
handleSearch();
}
function handleSelectStatus(value?: string | number) {
searchParams.status = value ?? undefined;
handleSearch();
}
function handleSelectAll() {
searchParams.type = undefined;
searchParams.status = undefined;
handleSearch();
}
onMounted(loadFacet);
</script>
<template>
<div class="h-full flex gap-16px overflow-hidden">
<FeedbackFacet
class="w-200px shrink-0"
:total="facetStat.total"
:type-counts="facetStat.typeCounts"
:status-counts="facetStat.statusCounts"
:selected-type="searchParams.type"
:selected-status="searchParams.status"
:loading="facetLoading"
@select-type="handleSelectType"
@select-status="handleSelectStatus"
@select-all="handleSelectAll"
/>
<div class="flex-col-stretch flex-1-hidden gap-16px">
<FeedbackSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
<template #header>
<div class="flex items-center justify-between gap-12px">
<div class="flex items-center gap-10px">
<p>意见反馈</p>
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
</div>
<ElButton plain type="primary" @click="openCreate">提交反馈</ElButton>
</div>
</template>
<div class="flex-1">
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
</ElTable>
</div>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="mobilePagination.total"
layout="total,prev,pager,next,sizes"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
</ElCard>
</div>
<FeedbackOperateDialog
v-model:visible="operateVisible"
:mode="operateMode"
:row-data="operateRow"
@submitted="handleSubmitted"
/>
<FeedbackDetailDialog :id="detailId" v-model:visible="detailVisible" />
<FeedbackStatusDialog
:id="statusId"
v-model:visible="statusVisible"
:current-status="statusCurrent"
@submitted="handleStatusUpdated"
/>
</div>
</template>
<style scoped></style>