feat: 表格展示优化

This commit is contained in:
2026-05-23 01:49:15 +08:00
parent cc7125e681
commit a3b1fba3ea
18 changed files with 417 additions and 18 deletions
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { useMediaQuery } from "@vueuse/core";
import type { HTMLAttributes } from "vue";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/components/ui/tooltip";
const props = defineProps<{
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
open?: boolean;
class?: HTMLAttributes["class"];
}>();
const isMobile = useMediaQuery("(max-width: 640px)");
</script>
<template>
<div>
<!-- 移动端 Popover -->
<Popover v-if="isMobile">
<PopoverTrigger as-child>
<slot name="trigger" />
</PopoverTrigger>
<PopoverContent
:side="side"
:align="align"
class="w-fit px-0 py-2 relative top-2! rounded-lg! border-none!"
:class="props.class"
>
<slot />
</PopoverContent>
</Popover>
<!-- PC Tooltip -->
<TooltipProvider v-else :delay-duration="150">
<Tooltip>
<TooltipTrigger as-child>
<slot name="trigger" />
</TooltipTrigger>
<TooltipContent
:side="side"
:align="align"
class="w-fit px-0 py-2 relative top-2! bg-popover text-popover-foreground border rounded-lg! [&>svg]:hidden [&>span>svg]:opacity-0!"
:class="props.class"
>
<slot />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</template>
+36 -6
View File
@@ -10,6 +10,7 @@ import {
DialogTitle
} from "@/components/ui/dialog";
import { useAnswerStore } from "@/stores/answer";
import { getOptionLines } from "@/utils";
const answerStore = useAnswerStore();
const { selectedRecord } = storeToRefs(answerStore);
@@ -26,6 +27,15 @@ const dialogOpen = computed({
if (!open) close();
}
});
/** 格式化后按行拆分选项,标记每行是否为正确答案 */
const formattedOptionLines = computed(() => {
if (!selectedRecord.value?.options) return null;
return getOptionLines(
selectedRecord.value.options,
selectedRecord.value.answer ?? ""
);
});
</script>
<template>
@@ -36,26 +46,46 @@ const dialogOpen = computed({
<DialogDescription>{{ selectedRecord?.time }}</DialogDescription>
</DialogHeader>
<div v-if="selectedRecord" class="grid max-h-[70vh] gap-4 overflow-auto pr-1">
<div
v-if="selectedRecord"
class="grid max-h-[70vh] gap-4 overflow-auto pr-1"
>
<div class="grid gap-2">
<span class="text-xs font-medium text-muted-foreground">题目</span>
<pre
class="whitespace-pre-wrap rounded-2xl bg-muted p-3 text-sm leading-6 text-foreground"
>{{ selectedRecord.question }}</pre>
>{{ selectedRecord.question }}</pre
>
</div>
<div class="grid gap-2">
<span class="text-xs font-medium text-muted-foreground">选项</span>
<pre
class="whitespace-pre-wrap rounded-2xl bg-muted p-3 text-sm leading-6 text-foreground"
>{{ selectedRecord.options || "-" }}</pre>
<div
class="whitespace-pre-wrap rounded-2xl bg-muted p-3 text-sm leading-6"
>
<template v-if="formattedOptionLines">
<div
v-for="(line, i) in formattedOptionLines"
:key="i"
:class="
line.isCorrect
? 'text-(--color-primary) font-medium'
: 'text-foreground'
"
>
{{ line.text }}
</div>
</template>
<span v-else class="text-foreground">-</span>
</div>
</div>
<div class="grid gap-2">
<span class="text-xs font-medium text-muted-foreground">答案</span>
<pre
class="whitespace-pre-wrap rounded-2xl bg-muted p-3 text-base font-semibold leading-7 text-foreground"
>{{ selectedRecord.answer }}</pre>
>{{ selectedRecord.answer }}</pre
>
</div>
</div>
</DialogContent>
+43 -10
View File
@@ -5,6 +5,7 @@ import { storeToRefs } from "pinia";
import TableCom from "@/components/Table/TableCom.vue";
import type { TableColumn } from "@/components/Table/type";
import TooltipCom from "@/components/TooltipCom.vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -21,12 +22,12 @@ import {
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import type { IQaRecord } from "@/interfaces";
import {
DASHBOARD_PAGE_SIZE_OPTIONS,
QUESTION_TYPE_LABELS,
useAnswerStore
} from "@/stores/answer";
import { getOptionLines } from "@/utils";
const answerStore = useAnswerStore();
const {
@@ -79,11 +80,6 @@ const columns = computed<TableColumn[]>(() => [
const getQuestionTypeLabel = (type: string) => {
return QUESTION_TYPE_LABELS[type] || type || "自动判断";
};
/** 空选项统一展示占位,不在表格中撑开大段空白 */
const displayOptions = (record: IQaRecord) => {
return record.options || "-";
};
</script>
<template>
@@ -148,13 +144,50 @@ const displayOptions = (record: IQaRecord) => {
</template>
<template #options="{ row }">
<div
class="max-w-md truncate text-muted-foreground"
:title="displayOptions(row)"
<TooltipCom side="top" align="start">
<template #trigger>
<div class="max-w-md cursor-default truncate">
<template v-if="row.options">
<template
v-for="(line, i) in getOptionLines(
row.options,
row.answer ?? ''
)"
:key="i"
>
{{ displayOptions(row) }}
<span v-if="i > 0" class="text-muted-foreground"
>&nbsp;</span
>
<span
:class="
line.isCorrect
? 'text-green-500 font-medium'
: 'text-muted-foreground'
"
>{{ line.text }}</span
>
</template>
</template>
<span v-else class="text-muted-foreground">-</span>
</div>
</template>
<div
v-if="row.options"
class="max-w-sm whitespace-pre-wrap px-3 py-1 text-xs leading-5"
>
<div
v-for="(line, i) in getOptionLines(
row.options,
row.answer ?? ''
)"
:key="i"
:class="line.isCorrect ? 'text-green-500 font-medium' : ''"
>
{{ line.text }}
</div>
</div>
</TooltipCom>
</template>
<template #answer="{ value }">
<div
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { PopoverRootEmits, PopoverRootProps } from 'reka-ui'
import { PopoverRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<PopoverRootProps>()
const emits = defineEmits<PopoverRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<PopoverRoot
v-slot="slotProps"
data-slot="popover"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</PopoverRoot>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { PopoverAnchorProps } from 'reka-ui'
import { PopoverAnchor } from 'reka-ui'
const props = defineProps<PopoverAnchorProps>()
</script>
<template>
<PopoverAnchor
data-slot="popover-anchor"
v-bind="props"
>
<slot />
</PopoverAnchor>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { PopoverContentEmits, PopoverContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
PopoverContent,
PopoverPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>(),
{
align: 'center',
sideOffset: 4,
},
)
const emits = defineEmits<PopoverContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PopoverPortal>
<PopoverContent
data-slot="popover-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 dark:ring-foreground/10 flex flex-col gap-4 rounded-3xl p-4 text-sm shadow-lg ring-1 duration-100 z-50 w-72 origin-(--reka-popover-content-transform-origin) outline-hidden',
props.class,
)
"
>
<slot />
</PopoverContent>
</PopoverPortal>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<p
data-slot="popover-description"
:class="cn('text-muted-foreground', props.class)"
>
<slot />
</p>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="popover-header"
:class="cn('flex flex-col gap-1 text-sm', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="popover-title"
:class="cn('text-base font-medium cn-font-heading', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { PopoverTriggerProps } from 'reka-ui'
import { PopoverTrigger } from 'reka-ui'
const props = defineProps<PopoverTriggerProps>()
</script>
<template>
<PopoverTrigger
data-slot="popover-trigger"
v-bind="props"
>
<slot />
</PopoverTrigger>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Popover } from './Popover.vue'
export { default as PopoverAnchor } from './PopoverAnchor.vue'
export { default as PopoverContent } from './PopoverContent.vue'
export { default as PopoverDescription } from './PopoverDescription.vue'
export { default as PopoverHeader } from './PopoverHeader.vue'
export { default as PopoverTitle } from './PopoverTitle.vue'
export { default as PopoverTrigger } from './PopoverTrigger.vue'
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<TooltipRootProps>()
const emits = defineEmits<TooltipRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TooltipRoot
v-slot="slotProps"
data-slot="tooltip"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</TooltipRoot>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(), {
sideOffset: 0,
})
const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TooltipPortal>
<TooltipContent
data-slot="tooltip-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="cn('data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 inline-flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-lg bg-foreground text-background z-50 w-fit max-w-xs origin-(--reka-tooltip-content-transform-origin)', props.class)"
>
<slot />
<TooltipArrow class="size-2.5 rotate-45 rounded-[2px] data-[side=left]:translate-x-[-1.5px] data-[side=right]:translate-x-[1.5px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]" />
</TooltipContent>
</TooltipPortal>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { TooltipProviderProps } from 'reka-ui'
import { TooltipProvider } from 'reka-ui'
const props = withDefaults(defineProps<TooltipProviderProps>(), {
delayDuration: 0,
})
</script>
<template>
<TooltipProvider v-bind="props">
<slot />
</TooltipProvider>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { TooltipTriggerProps } from 'reka-ui'
import { TooltipTrigger } from 'reka-ui'
const props = defineProps<TooltipTriggerProps>()
</script>
<template>
<TooltipTrigger
data-slot="tooltip-trigger"
v-bind="props"
>
<slot />
</TooltipTrigger>
</template>
+4
View File
@@ -0,0 +1,4 @@
export { default as Tooltip } from './Tooltip.vue'
export { default as TooltipContent } from './TooltipContent.vue'
export { default as TooltipProvider } from './TooltipProvider.vue'
export { default as TooltipTrigger } from './TooltipTrigger.vue'
+36
View File
@@ -0,0 +1,36 @@
// app/utils/format.ts - 文本格式化工具
/**
* 将选项字符串从"字母在上、内容在下"的换行格式合并为"字母. 内容"的单行格式。
* 例如:
* 输入:`A.\n错\nB.\n对`
* 输出:`A. 错\nB. 对`
*/
export function formatOptions(options: string): string {
// 匹配类似 "A." 开头的选项标记,将其后紧跟的换行内容合并到同一行
return options.replace(/^([A-Za-z]\.)\s*\n\s*/gm, "$1 ");
}
/**
* 将选项字符串拆分为逐行结构,标记哪些行是正确答案。
* answer 按 # 分隔(多选),与选项正文做等值匹配(大小写不敏感)。
*/
export function getOptionLines(
options: string,
answer: string
): { text: string; isCorrect: boolean }[] {
const segments = answer
.split("#")
.map((s) => s.trim().toUpperCase())
.filter(Boolean);
return formatOptions(options)
.split("\n")
.filter((line) => line.trim())
.map((line) => {
const match = line.match(/^[A-Za-z]\.\s*(.*)/);
const content = match?.[1]?.trim().toUpperCase() ?? "";
const isCorrect = content !== "" && segments.includes(content);
return { text: line, isCorrect };
});
}
+1
View File
@@ -1,2 +1,3 @@
export * from "./clipboard";
export * from "./format";
export * from "./sortableTable";