Files
cn-rdms-web/src/views/feedback/modules/feedback-facet.vue

304 lines
9.0 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="ts">
import { type Component, computed } from 'vue';
import { FEEDBACK_STATUS_DICT_CODE, FEEDBACK_TYPE_DICT_CODE } from '@/constants/dict';
import { getFeedbackStatusTagType } from '@/constants/status-tag';
import { useDict } from '@/hooks/business/dict';
import IconBugOutline from '~icons/mdi/bug-outline';
import IconEmoticonSadOutline from '~icons/mdi/emoticon-sad-outline';
import IconLightbulbOnOutline from '~icons/mdi/lightbulb-on-outline';
import IconTagOutline from '~icons/mdi/tag-outline';
defineOptions({ name: 'FeedbackFacet' });
interface Props {
/** 全部反馈总数 */
total: number;
/** 各分类计数key=分类码值字符串 */
typeCounts: Record<string, number>;
/** 各状态计数key=状态码值字符串 */
statusCounts: Record<string, number>;
/** 当前选中的分类码值(来自列表搜索参数) */
selectedType?: string | number | null;
/** 当前选中的状态码值 */
selectedStatus?: string | number | null;
/** 统计加载态 */
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
selectedType: undefined,
selectedStatus: undefined,
loading: false
});
const emit = defineEmits<{
/** 切换分类筛选(已处理单选 toggle传最终值或 undefined */
selectType: [value: string | number | undefined];
/** 切换状态筛选 */
selectStatus: [value: string | number | undefined];
/** 清空分类 + 状态筛选 */
selectAll: [];
}>();
const { dictData: typeDictData } = useDict(FEEDBACK_TYPE_DICT_CODE);
const { dictData: statusDictData } = useDict(FEEDBACK_STATUS_DICT_CODE);
// 统计接口按字典全集返回计数,分面也使用全集,避免停用码值的历史数据被隐藏。
const typeOptions = computed(() => typeDictData.value.map(item => ({ label: item.label, value: item.value })));
const statusOptions = computed(() => statusDictData.value.map(item => ({ label: item.label, value: item.value })));
// ElTag type → 主题色变量,用于状态色点(颜色语义来源仍是 status-tag.ts
// 注意:本主题把 --el-color-info 设成了蓝色settings.ts otherColor.info=#2080f0
// 与「处理中」的 primary 蓝撞色;故分面里 info已忽略/兜底)单独落真实灰,仅作用于左侧圆点,
// 右侧表格状态标签仍走主题色不受影响。
const TAG_TYPE_COLOR_VAR: Record<string, string> = {
primary: 'var(--el-color-primary)',
success: 'var(--el-color-success)',
warning: 'var(--el-color-warning)',
danger: 'var(--el-color-danger)',
info: 'var(--el-text-color-secondary)'
};
function statusDotColor(value: string | number) {
return TAG_TYPE_COLOR_VAR[getFeedbackStatusTagType(value)] ?? TAG_TYPE_COLOR_VAR.info;
}
// 分类图标按字典码值映射feedback_type1 缺陷 / 2 体验问题 / 3 功能建议,口径见 constants/dict.ts
const TYPE_ICON_MAP: Record<string, Component> = {
'1': IconBugOutline,
'2': IconEmoticonSadOutline,
'3': IconLightbulbOnOutline
};
/** 分类前导图标,未匹配码值回退通用标签图标 */
function typeIcon(value: string | number): Component {
return TYPE_ICON_MAP[String(value)] ?? IconTagOutline;
}
/** 未选任何分类 / 状态时,「全部」高亮 */
const isAllActive = computed(
() =>
(props.selectedType === undefined || props.selectedType === null) &&
(props.selectedStatus === undefined || props.selectedStatus === null)
);
/** 选中值与候选项是否同一码值(统一 String 比较,兼容字典 value 的 number/string 形态) */
function isSame(selected: string | number | null | undefined, value: string | number) {
return selected !== undefined && selected !== null && String(selected) === String(value);
}
function handleType(value: string | number) {
emit('selectType', isSame(props.selectedType, value) ? undefined : value);
}
function handleStatus(value: string | number) {
emit('selectStatus', isSame(props.selectedStatus, value) ? undefined : value);
}
function handleAll() {
emit('selectAll');
}
</script>
<template>
<ElCard class="feedback-facet card-wrapper">
<div v-loading="loading" class="feedback-facet__scroll">
<button
type="button"
class="facet-item facet-item--all"
:class="{ 'is-active': isAllActive }"
:aria-pressed="isAllActive"
@click="handleAll"
>
<span class="facet-item__lead" aria-hidden="true"><icon-mdi-inbox-multiple-outline /></span>
<span class="facet-item__label">全部</span>
<span class="facet-item__count" :class="{ 'is-zero': total === 0 }">{{ total }}</span>
</button>
<div class="facet-group">
<p class="facet-group__title">分类</p>
<button
v-for="opt in typeOptions"
:key="opt.value"
type="button"
class="facet-item"
:class="{ 'is-active': isSame(selectedType, opt.value) }"
:aria-pressed="isSame(selectedType, opt.value)"
@click="handleType(opt.value)"
>
<span class="facet-item__lead" aria-hidden="true"><component :is="typeIcon(opt.value)" /></span>
<span class="facet-item__label">{{ opt.label }}</span>
<span class="facet-item__count" :class="{ 'is-zero': (typeCounts[String(opt.value)] ?? 0) === 0 }">
{{ typeCounts[String(opt.value)] ?? 0 }}
</span>
</button>
<p v-if="!typeOptions.length" class="facet-empty">分类字典未加载</p>
</div>
<div class="facet-group">
<p class="facet-group__title">状态</p>
<button
v-for="opt in statusOptions"
:key="opt.value"
type="button"
class="facet-item"
:class="{ 'is-active': isSame(selectedStatus, opt.value) }"
:aria-pressed="isSame(selectedStatus, opt.value)"
@click="handleStatus(opt.value)"
>
<span class="facet-item__lead" aria-hidden="true">
<span class="facet-item__dot" :style="{ backgroundColor: statusDotColor(opt.value) }" />
</span>
<span class="facet-item__label">{{ opt.label }}</span>
<span class="facet-item__count" :class="{ 'is-zero': (statusCounts[String(opt.value)] ?? 0) === 0 }">
{{ statusCounts[String(opt.value)] ?? 0 }}
</span>
</button>
<p v-if="!statusOptions.length" class="facet-empty">状态字典未加载</p>
</div>
</div>
</ElCard>
</template>
<style scoped lang="scss">
.feedback-facet {
display: flex;
flex-direction: column;
overflow: hidden;
:deep(.el-card__body) {
flex: 1;
min-height: 0;
padding: 8px;
}
}
.feedback-facet__scroll {
height: 100%;
overflow-y: auto;
padding-right: 2px;
}
.facet-item--all {
margin-bottom: 2px;
}
.facet-group + .facet-group {
margin-top: 2px;
}
.facet-group__title {
margin: 14px 10px 4px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--el-text-color-secondary);
}
.facet-empty {
margin: 4px 10px;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.facet-item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--el-text-color-regular);
font-size: 13px;
line-height: 1.4;
text-align: left;
cursor: pointer;
transition: background-color 0.18s ease-out;
}
.facet-item:hover {
background: var(--el-fill-color-light);
}
// 键盘聚焦可见环offset 内缩,避免被圆角裁切)
.facet-item:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: -2px;
}
.facet-item.is-active {
font-weight: 600;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
// 选中项左侧主题色强调条
.facet-item.is-active::before {
content: '';
position: absolute;
top: 50%;
left: 3px;
width: 3px;
height: 14px;
border-radius: 2px;
background: var(--el-color-primary);
transform: translateY(-50%);
}
// 前导标记列:全部=收件箱图标 / 状态=语义色点,对齐同一宽度
.facet-item__lead {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 18px;
font-size: 16px;
color: var(--el-text-color-secondary);
}
.facet-item:hover .facet-item__lead,
.facet-item.is-active .facet-item__lead {
color: var(--el-color-primary);
}
.facet-item__dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.facet-item__label {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.facet-item__count {
flex-shrink: 0;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--el-text-color-secondary);
}
// 计数为 0 的项弱化,避免一排 0 抢视线
.facet-item__count.is-zero {
color: var(--el-text-color-placeholder);
}
.facet-item.is-active .facet-item__count {
color: var(--el-color-primary);
}
// 尊重系统「减少动态效果」偏好
@media (prefers-reduced-motion: reduce) {
.facet-item {
transition: none;
}
}
</style>