feat(projects): 增加意见反馈
This commit is contained in:
328
src/views/feedback/index.vue
Normal file
328
src/views/feedback/index.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<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 };
|
||||
}
|
||||
|
||||
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.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>
|
||||
Reference in New Issue
Block a user