refactor(chat): split oversized chat modules
This commit is contained in:
@@ -0,0 +1,564 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
FormControlLabel,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
alpha,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
|
||||
import HelpOutlineRounded from "@mui/icons-material/HelpOutlineRounded";
|
||||
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
|
||||
|
||||
import type { Message } from "./GlobalChatbox.types";
|
||||
|
||||
const getQuestionStatusLabel = (
|
||||
status: NonNullable<Message["questions"]>[number]["status"],
|
||||
) => {
|
||||
if (status === "answered") return "已回答";
|
||||
if (status === "rejected") return "已跳过";
|
||||
if (status === "error") return "提交失败";
|
||||
if (status === "submitting") return "提交中";
|
||||
return "等待回答";
|
||||
};
|
||||
|
||||
const getQuestionStatusColor = (
|
||||
status: NonNullable<Message["questions"]>[number]["status"],
|
||||
theme: Theme,
|
||||
) => {
|
||||
if (status === "answered") return theme.palette.success.main;
|
||||
if (status === "rejected") return theme.palette.text.secondary;
|
||||
if (status === "error") return theme.palette.error.main;
|
||||
return "#0288d1";
|
||||
};
|
||||
|
||||
const QuestionRequestCard = ({
|
||||
questionRequest,
|
||||
onReply,
|
||||
onReject,
|
||||
}: {
|
||||
questionRequest: NonNullable<Message["questions"]>[number];
|
||||
onReply: (requestId: string, answers: string[][]) => void;
|
||||
onReject: (requestId: string) => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isEditable =
|
||||
questionRequest.status === "pending" || questionRequest.status === "error";
|
||||
const isSubmitting = questionRequest.status === "submitting";
|
||||
const statusColor = getQuestionStatusColor(questionRequest.status, theme);
|
||||
const [selected, setSelected] = React.useState<Record<number, string[]>>({});
|
||||
const [customSelected, setCustomSelected] = React.useState<Record<number, boolean>>({});
|
||||
const [custom, setCustom] = React.useState<Record<number, string>>({});
|
||||
|
||||
const answers = React.useMemo(
|
||||
() =>
|
||||
questionRequest.questions.map((question, index) => {
|
||||
const selectedAnswers = selected[index] ?? [];
|
||||
const isCustomSelected =
|
||||
customSelected[index] === true ||
|
||||
(question.custom !== false && question.options.length === 0);
|
||||
const customAnswer = custom[index]?.trim();
|
||||
return isCustomSelected && customAnswer
|
||||
? [...selectedAnswers, customAnswer]
|
||||
: selectedAnswers;
|
||||
}),
|
||||
[custom, customSelected, questionRequest.questions, selected],
|
||||
);
|
||||
|
||||
const canSubmit =
|
||||
isEditable &&
|
||||
questionRequest.questions.length > 0 &&
|
||||
questionRequest.questions.every((_, index) => {
|
||||
const answer = answers[index] ?? [];
|
||||
return answer.some((item) => item.trim().length > 0);
|
||||
});
|
||||
|
||||
const answerSummary = (questionRequest.answers ?? [])
|
||||
.map((answer) => answer.join("、"))
|
||||
.filter(Boolean)
|
||||
.join(";");
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||
bgcolor: alpha("#fff", 0.52),
|
||||
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
|
||||
backdropFilter: "blur(20px)",
|
||||
position: "relative",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
inset: "10px auto 10px 0",
|
||||
width: 3,
|
||||
borderRadius: "0 999px 999px 0",
|
||||
bgcolor: statusColor,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
alignItems="center"
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
pl: 1.75,
|
||||
borderBottom: `1px solid ${alpha("#000", 0.05)}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
flex: "0 0 auto",
|
||||
color: statusColor,
|
||||
bgcolor: alpha(statusColor, 0.1),
|
||||
border: `1px solid ${alpha(statusColor, 0.16)}`,
|
||||
}}
|
||||
>
|
||||
<HelpOutlineRounded sx={{ fontSize: 21 }} />
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||
需要补充信息
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
size="small"
|
||||
label={getQuestionStatusLabel(questionRequest.status)}
|
||||
sx={{
|
||||
height: 24,
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 800,
|
||||
borderRadius: "12px",
|
||||
bgcolor: alpha(statusColor, 0.12),
|
||||
color: statusColor,
|
||||
"& .MuiChip-label": { px: 1 },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1.3} sx={{ px: 1.5, py: 1.35, pl: 1.75 }}>
|
||||
{questionRequest.questions.map((question, index) => {
|
||||
const selectedAnswers = selected[index] ?? [];
|
||||
const isCustomEnabled = question.custom !== false;
|
||||
const isCustomSelected =
|
||||
customSelected[index] === true ||
|
||||
(isCustomEnabled && question.options.length === 0);
|
||||
const setQuestionAnswers = (nextAnswers: string[]) => {
|
||||
setSelected((current) => ({
|
||||
...current,
|
||||
[index]: nextAnswers,
|
||||
}));
|
||||
};
|
||||
const setQuestionCustomSelected = (checked: boolean) => {
|
||||
setCustomSelected((current) => ({
|
||||
...current,
|
||||
[index]: checked,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={`${question.header}-${index}`}
|
||||
sx={{
|
||||
px: 1.25,
|
||||
py: 1,
|
||||
borderRadius: 2.5,
|
||||
bgcolor: alpha("#000", 0.025),
|
||||
border: `1px solid ${alpha("#000", 0.045)}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={800}>
|
||||
{question.header || `问题 ${index + 1}`}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.primary"
|
||||
sx={{ mt: 0.35, lineHeight: 1.55, wordBreak: "break-word" }}
|
||||
>
|
||||
{question.question}
|
||||
</Typography>
|
||||
|
||||
{question.options.length ? (
|
||||
<Stack spacing={0.75} sx={{ mt: 1 }}>
|
||||
{question.options.map((option) => {
|
||||
const checked = selectedAnswers.includes(option.label);
|
||||
if (question.multiple) {
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={option.label}
|
||||
disabled={!isEditable || isSubmitting}
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={checked}
|
||||
onChange={(event) => {
|
||||
if (event.target.checked) {
|
||||
setQuestionAnswers([...selectedAnswers, option.label]);
|
||||
} else {
|
||||
setQuestionAnswers(
|
||||
selectedAnswers.filter((item) => item !== option.label),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={750}>
|
||||
{option.label}
|
||||
</Typography>
|
||||
{option.description ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{option.description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
}
|
||||
sx={{ alignItems: "flex-start", m: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={option.label}
|
||||
size="small"
|
||||
variant={checked ? "contained" : "outlined"}
|
||||
disabled={!isEditable || isSubmitting}
|
||||
onClick={() => {
|
||||
setQuestionAnswers([option.label]);
|
||||
setQuestionCustomSelected(false);
|
||||
}}
|
||||
startIcon={
|
||||
checked ? (
|
||||
<CheckCircleRounded fontSize="small" />
|
||||
) : (
|
||||
<RadioButtonUncheckedRounded fontSize="small" />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
justifyContent: "flex-start",
|
||||
minHeight: 38,
|
||||
borderRadius: 2,
|
||||
textTransform: "none",
|
||||
fontWeight: 800,
|
||||
bgcolor: checked ? "#0288d1" : alpha("#fff", 0.45),
|
||||
borderColor: checked ? "#0288d1" : alpha("#0288d1", 0.22),
|
||||
"&:hover": {
|
||||
bgcolor: checked ? "#0277bd" : alpha("#0288d1", 0.08),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ textAlign: "left", minWidth: 0 }}>
|
||||
<Typography variant="body2" fontWeight={800}>
|
||||
{option.label}
|
||||
</Typography>
|
||||
{option.description ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ display: "block", opacity: checked ? 0.86 : 0.72 }}
|
||||
>
|
||||
{option.description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{isCustomEnabled ? (
|
||||
question.multiple ? (
|
||||
<FormControlLabel
|
||||
disabled={!isEditable || isSubmitting}
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={isCustomSelected}
|
||||
onChange={(event) =>
|
||||
setQuestionCustomSelected(event.target.checked)
|
||||
}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
color: alpha("#0288d1", 0.55),
|
||||
"&.Mui-checked": { color: "#0288d1" },
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<EditNoteRounded sx={{ fontSize: 18, color: "#0288d1" }} />
|
||||
<Typography variant="body2" fontWeight={800}>
|
||||
自定义回答
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
minHeight: 38,
|
||||
m: 0,
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${
|
||||
isCustomSelected ? "#0288d1" : alpha("#0288d1", 0.18)
|
||||
}`,
|
||||
bgcolor: isCustomSelected
|
||||
? alpha("#0288d1", 0.1)
|
||||
: alpha("#fff", 0.45),
|
||||
transition: "background-color 0.18s ease, border-color 0.18s ease",
|
||||
"&:hover": {
|
||||
bgcolor: isCustomSelected
|
||||
? alpha("#0288d1", 0.13)
|
||||
: alpha("#0288d1", 0.07),
|
||||
},
|
||||
"& .MuiFormControlLabel-label": {
|
||||
color: isCustomSelected ? "#0277bd" : "text.primary",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant={isCustomSelected ? "contained" : "outlined"}
|
||||
disabled={!isEditable || isSubmitting}
|
||||
onClick={() => {
|
||||
setQuestionAnswers([]);
|
||||
setQuestionCustomSelected(true);
|
||||
}}
|
||||
startIcon={
|
||||
isCustomSelected ? (
|
||||
<CheckCircleRounded fontSize="small" />
|
||||
) : (
|
||||
<EditNoteRounded fontSize="small" />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
justifyContent: "flex-start",
|
||||
minHeight: 38,
|
||||
borderRadius: 2,
|
||||
textTransform: "none",
|
||||
fontWeight: 800,
|
||||
bgcolor: isCustomSelected ? "#0288d1" : alpha("#fff", 0.45),
|
||||
borderColor: isCustomSelected
|
||||
? "#0288d1"
|
||||
: alpha("#0288d1", 0.22),
|
||||
"&:hover": {
|
||||
bgcolor: isCustomSelected
|
||||
? "#0277bd"
|
||||
: alpha("#0288d1", 0.08),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ textAlign: "left", minWidth: 0 }}>
|
||||
<Typography variant="body2" fontWeight={800}>
|
||||
自定义回答
|
||||
</Typography>
|
||||
</Box>
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Collapse in={isCustomEnabled && isCustomSelected} timeout="auto" unmountOnExit>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 0.85,
|
||||
px: 1.15,
|
||||
py: 0.85,
|
||||
borderRadius: 2.5,
|
||||
bgcolor: alpha("#fff", 0.62),
|
||||
border: `1px solid ${alpha("#fff", 0.82)}`,
|
||||
boxShadow: `0 8px 22px ${alpha("#000", 0.045)}, 0 0 0 1px ${alpha("#0288d1", 0.05)} inset`,
|
||||
backdropFilter: "blur(18px)",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={5}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
disabled={!isEditable || isSubmitting}
|
||||
value={custom[index] ?? ""}
|
||||
onChange={(event) =>
|
||||
setCustom((current) => ({
|
||||
...current,
|
||||
[index]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="输入自定义回答"
|
||||
InputProps={{
|
||||
disableUnderline: true,
|
||||
sx: {
|
||||
alignItems: "flex-start",
|
||||
fontSize: "0.88rem",
|
||||
lineHeight: 1.55,
|
||||
fontWeight: 500,
|
||||
color: "text.primary",
|
||||
"& textarea::placeholder": {
|
||||
color: alpha(theme.palette.text.primary, 0.38),
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{questionRequest.status === "answered" ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="success.main"
|
||||
sx={{
|
||||
display: "block",
|
||||
px: 1.25,
|
||||
py: 0.75,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha(theme.palette.success.main, 0.07),
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
已回答{answerSummary ? `:${answerSummary}` : ""}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{questionRequest.status === "rejected" ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: "block",
|
||||
px: 1.25,
|
||||
py: 0.75,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha("#000", 0.035),
|
||||
}}
|
||||
>
|
||||
已跳过
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{questionRequest.error ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="error.main"
|
||||
sx={{
|
||||
display: "block",
|
||||
px: 1.25,
|
||||
py: 0.75,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha(theme.palette.error.main, 0.06),
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{questionRequest.error}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{isEditable || isSubmitting ? (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
flexWrap="wrap"
|
||||
useFlexGap
|
||||
sx={{ px: 1.5, pb: 1.35, pl: 1.75 }}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => onReject(questionRequest.requestId)}
|
||||
sx={{
|
||||
height: 34,
|
||||
borderRadius: "17px",
|
||||
px: 1.5,
|
||||
fontWeight: 800,
|
||||
fontSize: "0.78rem",
|
||||
textTransform: "none",
|
||||
color: "text.secondary",
|
||||
borderColor: alpha(theme.palette.text.secondary, 0.22),
|
||||
bgcolor: alpha("#fff", 0.45),
|
||||
}}
|
||||
>
|
||||
跳过
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disableElevation
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
onClick={() => onReply(questionRequest.requestId, answers)}
|
||||
startIcon={
|
||||
isSubmitting ? (
|
||||
<CircularProgress size={14} color="inherit" />
|
||||
) : (
|
||||
<CheckCircleRounded fontSize="small" />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
minWidth: 104,
|
||||
height: 34,
|
||||
borderRadius: "17px",
|
||||
bgcolor: "#0288d1",
|
||||
fontWeight: 800,
|
||||
fontSize: "0.78rem",
|
||||
textTransform: "none",
|
||||
boxShadow: `0 4px 12px ${alpha("#0288d1", 0.24)}`,
|
||||
"&:hover": {
|
||||
bgcolor: "#0277bd",
|
||||
boxShadow: `0 6px 16px ${alpha("#0288d1", 0.28)}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
提交回答
|
||||
</Button>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const QuestionRequestGroup = ({
|
||||
questions,
|
||||
onReply,
|
||||
onReject,
|
||||
}: {
|
||||
questions: NonNullable<Message["questions"]>;
|
||||
onReply: (requestId: string, answers: string[][]) => void;
|
||||
onReject: (requestId: string) => void;
|
||||
}) => (
|
||||
<Stack spacing={1}>
|
||||
{questions.map((question) => (
|
||||
<QuestionRequestCard
|
||||
key={question.requestId}
|
||||
questionRequest={question}
|
||||
onReply={onReply}
|
||||
onReject={onReject}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user