Files
cn-rdms-web/src/views/feedback/index.vue

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: undefined, 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>