Files
labelmain/components/label/components/RightQATools.tsx
2026-02-09 18:20:33 +08:00

687 lines
26 KiB
TypeScript
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.

"use client"
import {
Accordion,
ActionIcon,
Box,
Button,
Flex,
Group,
NumberInput,
Radio,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core"
import { useForm } from "@mantine/form"
import { IconEdit } from "@tabler/icons-react"
import dayjs from "dayjs"
import parse from "html-react-parser"
import { ArrowUpIcon, ArrowUpToLineIcon } from "lucide-react"
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from "react"
import { getWrongWordList } from "../api/scheme"
import { Task } from "../api/task/typing"
import { useKeyEventStore } from "../store"
import { usePermissionStore } from "../store/auth"
import { useBottomToolsStore } from "../useBottomToolsStore"
import { useDescToolsStore } from "../useDescToolsStore"
import { useTopToolsStore } from "../useTopToolsStore"
import CustomModal from "./CustomModal"
import EditorContainer, { splitWord } from "./EditorContainer"
interface ComponentProps {
taskDetail?: Task.DataProps
// projectDetail?: Project.DetailResponse;
}
interface EditFormProps {
question_name: string
value?: string
round_id: number
}
// 处理转义字符
const escapeRegExp = (str: string) => {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
const RightQAContent = forwardRef(
({ taskDetail }: ComponentProps, ref: React.Ref<unknown>) => {
const { isView } = useTopToolsStore()
const { activeImage } = useBottomToolsStore()
const {
qaData,
setQaData,
countAllTurnsAndQaNumber,
updateDescDataFlag,
updateFlag,
} = useDescToolsStore()
const currentQaData = qaData.get(activeImage) ?? qaData.get("init") ?? {}
const isReview = [4, 6].includes(taskDetail?.label_status || 0)
const [selectedQuestion, setSelectedQuestion] = useState<any>(null)
const form = useForm({
initialValues: {
value: "",
round: 1,
tag: 0,
},
})
const textForm = useForm({
initialValues: {
question_name: "",
value: "",
round_id: 1,
},
})
const counts = useMemo(() => {
return countAllTurnsAndQaNumber(activeImage)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeImage, qaData, updateDescDataFlag, countAllTurnsAndQaNumber])
const [open, setOpen] = useState({
status: false,
label: "",
idx: -1,
isEdit: false,
})
// 新增问题
const onClickAdd = (label: string, index: number) => {
setOpen({ status: true, label, idx: index, isEdit: false })
}
// 编辑问题名称
const onClickEdit = (label: string, index: number, val: EditFormProps) => {
textForm.setValues({
question_name: val.question_name,
value: val.value || "",
round_id: val.round_id,
})
setOpen({ status: true, label, idx: index, isEdit: true })
}
type QaObj = Record<string, any>
const handleFormUpdate = (changeValues: any) => {
if (!selectedQuestion) return
const keys = Object.keys(changeValues)
const hasTagChange = keys.includes("tag")
const formData = form.values
const { label, type, category } = selectedQuestion
const currentQaDataArr = currentQaData[category] ?? []
let newArr: { [x: string]: any }[] = []
currentQaDataArr.forEach((qaObj: QaObj) => {
const [question_name, detail] = Object.entries(qaObj)[0]
if (question_name === label) {
newArr.push({
[question_name]: {
value: type === "value" ? formData.value : detail.value,
id: formData.round,
is_pre: detail.is_pre,
tag: formData.tag,
uid: detail.uid
? detail.uid
: usePermissionStore.getState().user_id,
create_timestamp: detail.create_timestamp
? detail.create_timestamp
: dayjs().unix(),
modify_uid: usePermissionStore.getState().user_id,
modify_timestamp: dayjs().unix(),
comment: type === "comment" ? formData.value : detail.comment,
flag: hasTagChange ? 0 : detail.tag === 2 ? 1 : 0,
},
})
} else {
newArr.push(qaObj)
}
})
try {
const finalData = structuredClone(currentQaData)
finalData[category] = newArr
qaData.set(activeImage, finalData)
setQaData(new Map(qaData)) // Force update
updateFlag(true) // Ensure flag is updated to trigger recalculations if needed
} catch (error) {
console.log(error)
}
}
const onFieldChange = (field: string, value: any) => {
form.setFieldValue(field, value)
handleFormUpdate({ [field]: value })
}
useEffect(() => {
if (!selectedQuestion) return
if (
selectedQuestion.type === "value" ||
selectedQuestion.type === "comment"
) {
const { value: v } = selectedQuestion
let htmlText = ""
if (selectedQuestion.type === "value") {
htmlText = v.value ? v.value.split(splitWord)[0] : ""
} else {
htmlText = v.comment ? v.comment.split(splitWord)[0] : ""
}
const obj = {
value: htmlText,
round: v.id,
tag: v.tag,
}
form.setValues(obj)
}
}, [selectedQuestion, form])
useEffect(() => {
if (updateDescDataFlag) {
updateFlag(false)
}
}, [updateDescDataFlag, updateFlag])
// 处理问题上移
const handleQuestionUp = (category: string, index: number) => {
const finalData = structuredClone(currentQaData)
let arr = finalData[category]
let temp = arr[index]
arr[index] = arr[index - 1]
arr[index - 1] = temp
finalData[category] = arr
qaData.set(activeImage, finalData)
setQaData(new Map(qaData))
updateFlag(true)
}
// 处理问题置顶
const handleQuestionUpToTop = (category: string, index: number) => {
const finalData = structuredClone(currentQaData)
let arr = finalData[category]
const element = arr.splice(index, 1)[0]
arr.unshift(element)
finalData[category] = arr
qaData.set(activeImage, finalData)
setQaData(new Map(qaData))
updateFlag(true)
}
// 错词检测
const checkWrongWords = useCallback(async () => {
if (useTopToolsStore.getState().isView) return
const res = await getWrongWordList()
const dictionary = res.data
const wrongWords = dictionary.map((item) => item.error)
const rightWords = dictionary.map((item) => item.correct)
// currentQaData
const currentImg = useBottomToolsStore.getState().activeImage
const qaData = useDescToolsStore.getState().qaData
const currentQaData = qaData.get(currentImg) ?? qaData.get("init") ?? {}
const newQaData = structuredClone(currentQaData)
const handleText = (v: string) => {
// 提取文本内容
const text = v.split(splitWord)[1]
let textContent = text.replace(/<[^>]+>/g, "")
// 替换错词
for (let i = 0; i < wrongWords.length; i++) {
const wrong = escapeRegExp(wrongWords[i])
const right = rightWords[i]
const regex = new RegExp(wrong, "g")
if (textContent.match(regex)) {
textContent = textContent.replace(
regex,
`<u style="color: red;" title="${right}">${wrong}</u>`
)
return textContent
}
}
return text
}
type QaObj = Record<string, any>
Object.values(newQaData).forEach((questionArr: QaObj[]) => {
questionArr.forEach((questionObj: QaObj) => {
const [name, questionData] = Object.entries(questionObj)[0]
const { value, comment } = questionData
const update = (v: string, key: "value" | "comment") => {
const checkValue = v.split(splitWord)[1]
const updatedValue = handleText(v)
if (checkValue !== updatedValue) {
let newValue = `<p>${updatedValue}</p>${splitWord}${updatedValue}`
questionData[key] = newValue
if (
selectedQuestion?.label === name &&
selectedQuestion?.type === key
)
form.setFieldValue("value", `<p>${updatedValue}</p>`)
}
}
if (value) update(value, "value")
if (comment) update(comment, "comment")
})
})
qaData.set(currentImg, newQaData)
setQaData(new Map(qaData)) // Force update
updateFlag(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedQuestion, qaData, setQaData, updateFlag])
useImperativeHandle(ref, () => ({
checkWrongWords,
}))
return (
<Box h="100%">
<Text
size="sm"
fw={600}
px="xs"
mb="xs">{`总轮数:${counts.turns} 总问题数:${counts.question_size},新增单轮数:${counts.new_single_turns},新增多轮数:${counts.new_multiple_turns}`}</Text>
<Box
className="qa-tools-container"
h="calc(100% - 300px)"
style={{ overflowY: "auto" }}>
<Accordion multiple>
{Object.entries(currentQaData).map(([qaCategory, qaDataArr]) => {
const counts = useDescToolsStore
.getState()
.countTurnsAndQaNumber(activeImage, qaCategory)
return (
<Accordion.Item key={qaCategory} value={qaCategory}>
<Accordion.Control>
<Text
fw={
600
}>{`${qaCategory} 轮数:${counts.turns},问题数:${counts.question_size}`}</Text>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{qaDataArr.map((qaObj, index) => {
const [question_name, detail] = Object.entries(qaObj)[0]
const htmlText = detail.value
? detail.value.split(splitWord)[0]
: ""
const commentHtmlText = detail.comment
? detail.comment.split(splitWord)[0]
: ""
return (
<Box
key={`${qaCategory}${question_name}`}
p="xs"
style={{
borderRadius: "var(--mantine-radius-md)",
border:
selectedQuestion?.label === question_name
? "1px solid var(--mantine-color-blue-filled)"
: "1px solid transparent",
}}>
<Flex gap="xs" mb="xs">
<Flex align="center">
<Text>{question_name}</Text>
{!detail.is_pre && !isView ? (
<ActionIcon
variant="subtle"
size="sm"
ml="xs"
onClick={() => {
onClickEdit(qaCategory, index, {
question_name,
value: htmlText,
round_id: detail.id,
})
}}>
<IconEdit size={16} />
</ActionIcon>
) : null}
</Flex>
<Box>
{detail.tag === 2 ? (
<Text c="red"></Text>
) : detail.tag === 1 ? (
<Text c="green"></Text>
) : null}
</Box>
</Flex>
<Flex
justify="space-between"
align="center"
mb="xs">
<Text size="sm">{detail.id}</Text>
{!isView ? (
<Flex gap="xs">
{!detail.is_pre ? (
<Button
variant="subtle"
color="red"
size="compact-xs"
onClick={() => {
const finalData =
structuredClone(currentQaData)
const updateData =
structuredClone(qaDataArr)
updateData.splice(index, 1)
finalData[qaCategory] = updateData
qaData.set(activeImage, finalData)
setQaData(new Map(qaData))
updateFlag(true)
}}>
</Button>
) : null}
<Button
variant="subtle"
size="compact-xs"
onClick={() => {
onClickAdd(qaCategory, index)
}}>
</Button>
{isReview && !detail.comment ? (
<Button
variant="subtle"
size="compact-xs"
onClick={() => {
const finalData =
structuredClone(currentQaData)
const updateData =
structuredClone(qaDataArr)
updateData[index] = {
[question_name]: {
...detail,
comment: "<p></p>",
},
}
finalData[qaCategory] = updateData
qaData.set(activeImage, finalData)
setQaData(new Map(qaData))
updateFlag(true)
// 更新下方选中
setSelectedQuestion({
label: question_name,
value: detail,
type: "comment",
category: qaCategory,
})
form.setValues({
value: "<p></p>",
round: detail.id,
tag: detail.tag,
})
}}>
</Button>
) : null}
{index !== 0 ? (
<>
<Tooltip label="上移">
<ActionIcon
variant="subtle"
size="sm"
onClick={() => {
handleQuestionUp(qaCategory, index)
}}>
<ArrowUpIcon size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label="置顶">
<ActionIcon
variant="subtle"
size="sm"
onClick={() => {
handleQuestionUpToTop(
qaCategory,
index
)
}}>
<ArrowUpToLineIcon size={14} />
</ActionIcon>
</Tooltip>
</>
) : null}
</Flex>
) : null}
</Flex>
<Box>
<Box
p="xs"
mb="xs"
style={{
border: !detail.is_pre
? "1px solid var(--mantine-color-green-filled)"
: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
cursor: "pointer",
}}
onClick={() => {
setSelectedQuestion({
label: question_name,
value: detail,
type: "value",
category: qaCategory,
})
console.log("点击批注", htmlText)
form.setValues({
value: htmlText,
round: detail.id,
tag: detail.tag,
})
}}>
{parse(htmlText)}
</Box>
{commentHtmlText ? (
<Box
p="xs"
style={{
border:
"1px solid var(--mantine-color-red-filled)",
borderRadius: "var(--mantine-radius-md)",
cursor: "pointer",
}}
onClick={() => {
setSelectedQuestion({
label: question_name,
value: detail,
type: "comment",
category: qaCategory,
})
form.setValues({
value: commentHtmlText,
round: detail.id,
tag: detail.tag,
})
}}>
{parse(commentHtmlText)}
</Box>
) : null}
</Box>
</Box>
)
})}
</Stack>
</Accordion.Panel>
</Accordion.Item>
)
})}
</Accordion>
</Box>
<Box
style={{ borderTop: "2px solid var(--mantine-color-gray-3)" }}
pt="sm"
px="sm">
<Stack
gap="xs"
opacity={!isView ? 1 : 0.5}
style={{ pointerEvents: !isView ? "auto" : "none" }}>
<Flex align="center" gap="xs">
<Text fw={600}></Text>
<Text>{selectedQuestion?.label || ""}</Text>
</Flex>
<Flex align="center" gap="xs">
<Text></Text>
<NumberInput
size="xs"
min={1}
{...form.getInputProps("round")}
onChange={(val) => onFieldChange("round", val)}
disabled={
!selectedQuestion ||
(selectedQuestion && selectedQuestion.value?.is_pre)
}
/>
<Radio.Group
size="xs"
{...form.getInputProps("tag")}
onChange={(val) => onFieldChange("tag", Number(val))}>
<Group gap="xs">
<Radio
label="正确"
value={1}
disabled={!isReview || isView}
/>
<Radio
label="错误"
value={2}
disabled={!isReview || isView}
/>
</Group>
</Radio.Group>
</Flex>
<EditorContainer
value={form.values.value}
onChange={(val: any) => onFieldChange("value", val)}
disabled={!selectedQuestion}
/>
</Stack>
</Box>
<CustomModal
title={open.isEdit ? "编辑问题" : "添加问题"}
centered
open={open.status}
onOk={async () => {
const currentOperation = currentQaData[open.label]
// Validate
const { hasErrors } = textForm.validate()
if (hasErrors) return
// Duplicate check
const formData = textForm.values
const arr = useDescToolsStore
.getState()
.getQuestionList(activeImage)
// If adding new, check duplicates
if (!open.isEdit && arr.includes(formData.question_name)) {
textForm.setFieldError("question_name", "问题名称不能重复!")
return
}
// If editing, if name changed and new name exists, error
if (open.isEdit) {
const originalName = Object.keys(currentOperation[open.idx])[0]
if (
originalName !== formData.question_name &&
arr.includes(formData.question_name)
) {
textForm.setFieldError("question_name", "问题名称不能重复!")
return
}
}
const finalData = structuredClone(currentQaData)
const updateData = structuredClone(currentOperation)
let obj = {
value: formData.value ?? "<p></p>",
id: formData.round_id,
is_pre: false,
tag: 0,
uid: 0,
create_timestamp: 0,
modify_uid: 0,
modify_timestamp: 0,
comment: "",
flag: 0,
}
if (open.isEdit) {
const prevData = Object.values(currentOperation[open.idx])[0]
obj.uid = prevData.uid
obj.create_timestamp = prevData.create_timestamp
obj.modify_uid = usePermissionStore.getState().user_id
obj.modify_timestamp = dayjs().unix()
obj.comment = prevData.comment
obj.flag = prevData.flag
updateData[open.idx] = { [formData.question_name]: obj }
} else {
obj.uid = usePermissionStore.getState().user_id
obj.create_timestamp = dayjs().unix()
updateData.splice(open.idx + 1, 0, {
[formData.question_name]: obj,
})
}
finalData[open.label] = updateData
qaData.set(activeImage, finalData)
setQaData(new Map(qaData))
updateFlag(true)
setOpen({ status: false, label: "", idx: -1, isEdit: false })
textForm.reset()
}}
onCancel={() => {
textForm.reset()
setOpen({ status: false, label: "", idx: -1, isEdit: false })
}}>
<Stack gap="sm">
<TextInput
label="问题名称"
required
{...textForm.getInputProps("question_name")}
onFocus={() => {
useKeyEventStore.getState().setFocusInput(true)
}}
onBlur={() => {
useKeyEventStore.getState().setFocusInput(false)
}}
/>
<Box>
<Text size="sm" fw={500}>
</Text>
<EditorContainer
value={textForm.values.value}
onChange={(val: any) => textForm.setFieldValue("value", val)}
/>
</Box>
<NumberInput
label="轮次"
required
min={1}
disabled={open.isEdit}
{...textForm.getInputProps("round_id")}
/>
</Stack>
</CustomModal>
</Box>
)
}
)
const RightQATools = (
{ taskDetail }: ComponentProps,
ref: React.Ref<unknown>
) => {
const { activeImage } = useBottomToolsStore()
return <RightQAContent key={activeImage} taskDetail={taskDetail} ref={ref} />
}
export default forwardRef(RightQATools)