|
|
@@ -1,19 +1,27 @@
|
|
|
import { useMemo, useState, type KeyboardEvent } from 'react'
|
|
|
import clsx from 'clsx'
|
|
|
import {
|
|
|
- Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
|
|
|
+ Button, IconCheckOutline14, IconChevronLeftOutline14, IconChevronRightOutline14,
|
|
|
IconCloseOutline16, IconEditOutline16,
|
|
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
|
+import type { LocaleSnapshot, Translate } from '@deepseek-ai/dsh-client-locale/client'
|
|
|
+import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
import { PendingQuestion, type QuestionAnswer, type QuestionComposerProps } from './contract/slots.ts'
|
|
|
import css from './QuestionComposer.module.css'
|
|
|
|
|
|
interface DraftAnswer {
|
|
|
selected: string[]
|
|
|
custom: string
|
|
|
- customOpen: boolean
|
|
|
skipped: boolean
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * Displayed feedback: validation feedback is stored as a dictionary key so a
|
|
|
+ * locale flip re-translates it; carrier failures arrive as raw (untranslated)
|
|
|
+ * messages and display verbatim.
|
|
|
+ */
|
|
|
+type Feedback = { key: 'error.incomplete' | 'error.empty' } | { message: string }
|
|
|
+
|
|
|
/**
|
|
|
* Split the conventional recommendation suffix without changing the answer value.
|
|
|
* @param label - Original option label returned if selected.
|
|
|
@@ -26,17 +34,8 @@ export function parseRecommendedLabel(label: string): { label: string; recommend
|
|
|
: { label, recommended: false }
|
|
|
}
|
|
|
|
|
|
-/**
|
|
|
- * Remove a conventional multi-select suffix so the hint can be styled separately.
|
|
|
- * @param title - Question title supplied by the interaction request.
|
|
|
- * @returns Question title without a trailing multi-select marker.
|
|
|
- */
|
|
|
-export function parseQuestionTitle(title: string): string {
|
|
|
- return title.replace(/\s*[((]可多选[))]\s*$/, '')
|
|
|
-}
|
|
|
-
|
|
|
-/** Return whether a textarea key event belongs to an active IME composition. */
|
|
|
-function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
|
|
|
+/** Return whether a text-field key event belongs to an active IME composition. */
|
|
|
+function isComposing(event: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>): boolean {
|
|
|
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
|
|
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
|
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
|
|
|
@@ -52,17 +51,24 @@ export function QuestionComposer(props: QuestionComposerProps) {
|
|
|
// Domain-face mint rides the carrier's stable identity (never minted in a
|
|
|
// select/render dispatch — per-dispatch minting would churn memo identity).
|
|
|
const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
|
|
|
- return <QuestionFlow key={question.key} pending={question} />
|
|
|
+ return <QuestionFlow key={question.key} pending={question} t={props.t} useLocale={props.useLocale} />
|
|
|
}
|
|
|
|
|
|
-function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
|
|
+function QuestionFlow({ pending, t, useLocale }: {
|
|
|
+ pending: PendingQuestion
|
|
|
+ t: Translate
|
|
|
+ useLocale: SnapshotSelectorHook<LocaleSnapshot>
|
|
|
+}) {
|
|
|
+ // Subscription only: t reads the active locale at call time, so the
|
|
|
+ // revision selector exists to re-render this tree on locale flips.
|
|
|
+ useLocale(snapshot => snapshot.revision)
|
|
|
const questions = pending.questions
|
|
|
const [index, setIndex] = useState(0)
|
|
|
- const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(question => ({
|
|
|
- selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
|
|
|
+ const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(() => ({
|
|
|
+ selected: [], custom: '', skipped: false,
|
|
|
})))
|
|
|
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
|
|
|
- const [error, setError] = useState<string | null>(null)
|
|
|
+ const [error, setError] = useState<Feedback | null>(null)
|
|
|
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
|
const question = questions[index]!
|
|
|
@@ -75,7 +81,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
|
|
setError(null)
|
|
|
void pending.cancel().catch((cause: unknown) => {
|
|
|
setBusy(null)
|
|
|
- setError(cause instanceof Error ? cause.message : String(cause))
|
|
|
+ setError({ message: cause instanceof Error ? cause.message : String(cause) })
|
|
|
})
|
|
|
}
|
|
|
|
|
|
@@ -91,17 +97,13 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
|
|
? current.selected.filter(item => item !== label)
|
|
|
: [...current.selected, label]
|
|
|
: [label]
|
|
|
- return { selected, custom: '', customOpen: false, skipped: false }
|
|
|
+ return { selected, custom: '', skipped: false }
|
|
|
})
|
|
|
if (question.multiSelect !== true && index < questions.length - 1) {
|
|
|
setIndex(current => current + 1)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- const openCustom = (): void => {
|
|
|
- updateDraft(current => ({ ...current, selected: [], customOpen: true, skipped: false }))
|
|
|
- }
|
|
|
-
|
|
|
const answered = (item: DraftAnswer): boolean =>
|
|
|
item.selected.length > 0 || item.custom.trim() !== ''
|
|
|
|
|
|
@@ -111,7 +113,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
|
|
const missing = values.findIndex(item => !completed(item))
|
|
|
if (missing >= 0) {
|
|
|
setIndex(missing)
|
|
|
- setError('请先完成这道问题。')
|
|
|
+ setError({ key: 'error.incomplete' })
|
|
|
return
|
|
|
}
|
|
|
const answer: QuestionAnswer = {
|
|
|
@@ -130,13 +132,13 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
|
|
setError(null)
|
|
|
void pending.answer(answer).catch((cause: unknown) => {
|
|
|
setBusy(null)
|
|
|
- setError(cause instanceof Error ? cause.message : String(cause))
|
|
|
+ setError({ message: cause instanceof Error ? cause.message : String(cause) })
|
|
|
})
|
|
|
}
|
|
|
|
|
|
const continueFlow = (): void => {
|
|
|
if (!answered(draft)) {
|
|
|
- setError('请选择一个选项或填写自定义答案。')
|
|
|
+ setError({ key: 'error.empty' })
|
|
|
return
|
|
|
}
|
|
|
if (index < questions.length - 1) {
|
|
|
@@ -149,11 +151,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
|
|
|
|
|
const skipQuestion = (): void => {
|
|
|
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
|
|
|
- ? {
|
|
|
- selected: [], custom: '',
|
|
|
- customOpen: (question.options?.length ?? 0) === 0,
|
|
|
- skipped: true,
|
|
|
- }
|
|
|
+ ? { selected: [], custom: '', skipped: true }
|
|
|
: item)
|
|
|
setDrafts(nextDrafts)
|
|
|
setError(null)
|
|
|
@@ -171,37 +169,17 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
|
|
<div className={css.headingBlock}>
|
|
|
{question.header !== undefined && <div className={css.eyebrow}>{question.header}</div>}
|
|
|
<h2 className={css.title} id={`question-${pending.key}-${String(index)}`}>
|
|
|
- <span>{question.multiSelect === true
|
|
|
- ? parseQuestionTitle(question.question)
|
|
|
- : question.question}</span>
|
|
|
- {question.multiSelect === true && <span className={css.multiSelectHint}>可多选</span>}
|
|
|
+ {question.question}
|
|
|
</h2>
|
|
|
{question.detail !== undefined && <p className={css.detail}>{question.detail}</p>}
|
|
|
</div>
|
|
|
- <div className={css.headerActions}>
|
|
|
- <span className={css.progress}>{index + 1} / {questions.length}</span>
|
|
|
- <button
|
|
|
- type="button" className={css.iconButton} aria-label="上一题"
|
|
|
- disabled={index === 0 || busy !== null}
|
|
|
- onClick={() => { setIndex(index - 1); setError(null) }}
|
|
|
- >
|
|
|
- <IconChevronLeftOutline14 />
|
|
|
- </button>
|
|
|
- <button
|
|
|
- type="button" className={css.iconButton} aria-label="下一题"
|
|
|
- disabled={index === questions.length - 1 || busy !== null}
|
|
|
- onClick={() => { setIndex(index + 1); setError(null) }}
|
|
|
- >
|
|
|
- <IconChevronRightOutline14 />
|
|
|
- </button>
|
|
|
- <button
|
|
|
- type="button" className={css.iconButton} aria-label="放弃整组问题"
|
|
|
- title="放弃整组问题"
|
|
|
- disabled={busy !== null} onClick={cancelFlow}
|
|
|
- >
|
|
|
- <IconCloseOutline16 />
|
|
|
- </button>
|
|
|
- </div>
|
|
|
+ <button
|
|
|
+ type="button" className={css.iconButton} aria-label={t('dismiss')}
|
|
|
+ title={t('dismiss')}
|
|
|
+ disabled={busy !== null} onClick={cancelFlow}
|
|
|
+ >
|
|
|
+ <IconCloseOutline16 />
|
|
|
+ </button>
|
|
|
</header>
|
|
|
|
|
|
<div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
|
|
|
@@ -211,7 +189,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
|
|
return (
|
|
|
<button
|
|
|
type="button" key={`${option.label}-${String(optionIndex)}`}
|
|
|
- className={clsx(css.option, selected && css.optionSelected)}
|
|
|
+ className={clsx(css.option, selected && question.multiSelect !== true && css.optionSelected)}
|
|
|
role={question.multiSelect === true ? 'checkbox' : 'radio'}
|
|
|
aria-checked={selected}
|
|
|
aria-label={display.label}
|
|
|
@@ -223,50 +201,76 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
|
|
submitDrafts(drafts)
|
|
|
}}
|
|
|
>
|
|
|
- <span className={css.number}>{optionIndex + 1}</span>
|
|
|
+ {question.multiSelect === true
|
|
|
+ ? (
|
|
|
+ <span className={clsx(css.checkbox, selected && css.checkboxChecked)} aria-hidden="true">
|
|
|
+ {selected && <IconCheckOutline14 size={12} />}
|
|
|
+ </span>
|
|
|
+ )
|
|
|
+ : <span className={css.number}>{optionIndex + 1}</span>}
|
|
|
<span className={css.optionCopy}>
|
|
|
<span className={css.optionLine}>
|
|
|
<span className={css.optionLabel}>{display.label}</span>
|
|
|
- {display.recommended && <span className={css.badge}>推荐</span>}
|
|
|
+ {display.recommended && <span className={css.badge}>{t('option.recommended')}</span>}
|
|
|
{option.description !== undefined && (
|
|
|
<span className={css.description}>{option.description}</span>
|
|
|
)}
|
|
|
</span>
|
|
|
</span>
|
|
|
- <span className={css.choiceIcon}>
|
|
|
- {selected ? <IconCheckOutline16 /> : <IconChevronRightOutline14 />}
|
|
|
- </span>
|
|
|
</button>
|
|
|
)
|
|
|
})}
|
|
|
|
|
|
- <div className={clsx(
|
|
|
- css.custom,
|
|
|
- draft.customOpen && css.customOpen,
|
|
|
- !hasOptions && css.customOptionless,
|
|
|
- )}>
|
|
|
- {hasOptions && (
|
|
|
- <button
|
|
|
- type="button" className={css.customTrigger}
|
|
|
- disabled={busy !== null} onClick={openCustom}
|
|
|
- aria-expanded={draft.customOpen}
|
|
|
- >
|
|
|
- <span className={css.number}><IconEditOutline16 /></span>
|
|
|
- <span>其他,请填写自定义答案</span>
|
|
|
- </button>
|
|
|
- )}
|
|
|
- {draft.customOpen && (
|
|
|
+ {hasOptions
|
|
|
+ ? (
|
|
|
+ <div className={clsx(css.customRow, draft.custom !== '' && css.customRowActive)}>
|
|
|
+ {question.multiSelect === true
|
|
|
+ ? (
|
|
|
+ <span
|
|
|
+ className={clsx(css.checkbox, draft.custom !== '' && css.checkboxChecked)}
|
|
|
+ aria-hidden="true"
|
|
|
+ >
|
|
|
+ {draft.custom !== '' && <IconCheckOutline14 size={12} />}
|
|
|
+ </span>
|
|
|
+ )
|
|
|
+ : (
|
|
|
+ <span className={css.number} aria-hidden="true">
|
|
|
+ <IconEditOutline16 size={12} />
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ className={css.customInput}
|
|
|
+ value={draft.custom}
|
|
|
+ disabled={busy !== null}
|
|
|
+ placeholder={t('custom.placeholder')}
|
|
|
+ onChange={(event) => {
|
|
|
+ const value = event.target.value
|
|
|
+ updateDraft(current => ({
|
|
|
+ ...current, selected: [], custom: value, skipped: false,
|
|
|
+ }))
|
|
|
+ }}
|
|
|
+ onKeyDown={(event) => {
|
|
|
+ if (event.key === 'Enter' && !isComposing(event)) {
|
|
|
+ event.preventDefault()
|
|
|
+ continueFlow()
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ : (
|
|
|
<textarea
|
|
|
autoFocus
|
|
|
- className={css.customInput}
|
|
|
+ className={css.customTextarea}
|
|
|
value={draft.custom}
|
|
|
disabled={busy !== null}
|
|
|
rows={2}
|
|
|
- placeholder="输入你的答案"
|
|
|
+ placeholder={t('custom.placeholder')}
|
|
|
onChange={(event) => {
|
|
|
const value = event.target.value
|
|
|
updateDraft(current => ({
|
|
|
- ...current, selected: [], custom: value, customOpen: true, skipped: false,
|
|
|
+ ...current, selected: [], custom: value, skipped: false,
|
|
|
}))
|
|
|
}}
|
|
|
onKeyDown={(event) => {
|
|
|
@@ -277,22 +281,40 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
|
|
}}
|
|
|
/>
|
|
|
)}
|
|
|
- </div>
|
|
|
</div>
|
|
|
|
|
|
<footer className={css.footer}>
|
|
|
- <div className={css.feedback} role="status">{error}</div>
|
|
|
+ <div className={css.pager}>
|
|
|
+ <button
|
|
|
+ type="button" className={css.iconButton} aria-label={t('pager.prev')}
|
|
|
+ disabled={index === 0 || busy !== null}
|
|
|
+ onClick={() => { setIndex(index - 1); setError(null) }}
|
|
|
+ >
|
|
|
+ <IconChevronLeftOutline14 />
|
|
|
+ </button>
|
|
|
+ <span className={css.progress}>{index + 1} / {questions.length}</span>
|
|
|
+ <button
|
|
|
+ type="button" className={css.iconButton} aria-label={t('pager.next')}
|
|
|
+ disabled={index === questions.length - 1 || busy !== null}
|
|
|
+ onClick={() => { setIndex(index + 1); setError(null) }}
|
|
|
+ >
|
|
|
+ <IconChevronRightOutline14 />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div className={css.feedback} role="status">
|
|
|
+ {error === null ? null : 'key' in error ? t(error.key) : error.message}
|
|
|
+ </div>
|
|
|
<div className={css.footerActions}>
|
|
|
- <Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
|
|
|
- 跳过本题
|
|
|
+ <Button variant="outline" disabled={busy !== null} onClick={skipQuestion}>
|
|
|
+ {t('action.skip')}
|
|
|
</Button>
|
|
|
<Button
|
|
|
- variant="primary" size="sm"
|
|
|
+ variant="primary"
|
|
|
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
|
|
|
>
|
|
|
{busy === 'answer'
|
|
|
- ? '正在提交…'
|
|
|
- : index === questions.length - 1 ? '提交' : '下一题'}
|
|
|
+ ? t('action.submitting')
|
|
|
+ : t(index === questions.length - 1 ? 'action.submit' : 'action.next')}
|
|
|
</Button>
|
|
|
</div>
|
|
|
</footer>
|