- 在 BusinessFormDialog 组件中添加 xl 尺寸预设,宽度设置为 1200px - 移除 feedback-search.vue 中废弃的 useDict 钩子导入 - 将反馈搜索的状态选择器从普通下拉改为字典类型,统一值转换逻辑 - 更新反馈初始搜索参数中的状态值类型从数字改为字符串 - 任务工时对话框使用 xl 预设以获得更大显示区域
330 lines
10 KiB
Vue
330 lines
10 KiB
Vue
<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>
|