2026-06-27 08:55:33 +08:00
|
|
|
|
<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 {
|
2026-07-02 16:34:08 +08:00
|
|
|
|
return { pageNo: 1, pageSize: 20, type: undefined, status: 1, title: undefined, creator: undefined };
|
2026-06-27 08:55:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 09:53:24 +08:00
|
|
|
|
// 搜索区「重置」清搜索区自有字段(标题、提交人);左侧分面选中的 type/status 由分面单独控制,不在此连带清除
|
2026-06-27 08:55:33 +08:00
|
|
|
|
function resetSearchParams() {
|
|
|
|
|
|
searchParams.title = undefined;
|
2026-06-29 09:53:24 +08:00
|
|
|
|
searchParams.creator = undefined;
|
2026-06-27 08:55:33 +08:00
|
|
|
|
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>
|