TaskHintModal.vue 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. <template>
  2. <div v-if="visible" class="modal-mask" @click.self="emit('close')">
  3. <div
  4. ref="dialogRef"
  5. class="modal hint-modal scale-in"
  6. role="dialog"
  7. aria-modal="true"
  8. aria-labelledby="task-hint-modal-title"
  9. tabindex="-1"
  10. @keydown="handleKeydown"
  11. >
  12. <div class="modal-head">
  13. <h3 id="task-hint-modal-title" class="modal-title">
  14. <svg
  15. width="14"
  16. height="14"
  17. viewBox="0 0 24 24"
  18. fill="none"
  19. stroke="#f97316"
  20. stroke-width="2"
  21. stroke-linecap="round"
  22. stroke-linejoin="round"
  23. >
  24. <path d="M9 18h6" />
  25. <path d="M10 22h4" />
  26. <path
  27. d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"
  28. />
  29. </svg>
  30. <span class="modal-title-text">
  31. {{ aiName ? `${aiName} 的任务提示` : '任务提示' }}
  32. </span>
  33. </h3>
  34. <button class="close-btn" type="button" aria-label="关闭" @click="emit('close')">
  35. <svg
  36. width="14"
  37. height="14"
  38. viewBox="0 0 24 24"
  39. fill="none"
  40. stroke="currentColor"
  41. stroke-width="2"
  42. stroke-linecap="round"
  43. stroke-linejoin="round"
  44. >
  45. <line x1="18" y1="6" x2="6" y2="18" />
  46. <line x1="6" y1="6" x2="18" y2="18" />
  47. </svg>
  48. </button>
  49. </div>
  50. <div v-if="loading" class="state-panel">
  51. <p class="state-text">正在生成任务提示...</p>
  52. </div>
  53. <div v-else-if="error" class="state-panel error-panel">
  54. <p class="error-text">{{ error }}</p>
  55. <button class="retry-btn" type="button" @click="emit('retry')">
  56. 重试
  57. </button>
  58. </div>
  59. <template v-else-if="hint">
  60. <div class="hint-context">
  61. <p class="context-label">当前问题</p>
  62. <p class="context-body">{{ hint.current_question }}</p>
  63. </div>
  64. <div class="hint-section">
  65. <p class="section-label">参考句子</p>
  66. <div class="sentences">
  67. <div
  68. v-for="(sentence, index) in hint.example_sentences"
  69. :key="`${sentence.english}-${index}`"
  70. class="sentence-card"
  71. >
  72. <p class="sentence-en">{{ sentence.english }}</p>
  73. <p class="sentence-zh">{{ sentence.chinese }}</p>
  74. </div>
  75. </div>
  76. </div>
  77. <div class="hint-section">
  78. <p class="section-label">关键词汇</p>
  79. <div class="vocab-grid">
  80. <div
  81. v-for="(vocab, index) in hint.key_vocabulary"
  82. :key="`${vocab.word}-${index}`"
  83. class="vocab-item"
  84. >
  85. <p class="vocab-word">{{ vocab.word }}</p>
  86. <p class="vocab-meaning">{{ vocab.meaning }}</p>
  87. </div>
  88. </div>
  89. </div>
  90. <p class="hint-footer">用自己的话表达更棒哦</p>
  91. </template>
  92. </div>
  93. </div>
  94. </template>
  95. <script lang="ts" setup>
  96. import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
  97. import type { TaskHint } from '@/types/englishSpeaking'
  98. const props = defineProps<{
  99. visible: boolean
  100. loading: boolean
  101. error?: string | null
  102. hint?: TaskHint | null
  103. aiName?: string
  104. }>()
  105. const emit = defineEmits<{
  106. close: []
  107. retry: []
  108. }>()
  109. const dialogRef = ref<HTMLElement | null>(null)
  110. const previouslyFocusedElement = ref<HTMLElement | null>(null)
  111. const focusableSelector = [
  112. 'a[href]',
  113. 'button:not([disabled])',
  114. 'textarea:not([disabled])',
  115. 'input:not([disabled])',
  116. 'select:not([disabled])',
  117. '[tabindex]:not([tabindex="-1"])',
  118. ].join(',')
  119. const getFocusableElements = () => {
  120. const dialog = dialogRef.value
  121. if (!dialog) return []
  122. return Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector)).filter((el) => {
  123. if (el.hasAttribute('disabled') || el.getAttribute('aria-hidden') === 'true') return false
  124. return el.offsetParent !== null || el === document.activeElement
  125. })
  126. }
  127. const restorePreviousFocus = () => {
  128. const element = previouslyFocusedElement.value
  129. previouslyFocusedElement.value = null
  130. if (element && document.contains(element)) {
  131. element.focus()
  132. }
  133. }
  134. const focusDialog = async () => {
  135. previouslyFocusedElement.value = document.activeElement instanceof HTMLElement
  136. ? document.activeElement
  137. : null
  138. await nextTick()
  139. dialogRef.value?.focus()
  140. }
  141. const containTabFocus = (event: KeyboardEvent) => {
  142. const dialog = dialogRef.value
  143. if (!dialog) return
  144. const focusableElements = getFocusableElements()
  145. if (!focusableElements.length) {
  146. event.preventDefault()
  147. dialog.focus()
  148. return
  149. }
  150. const firstElement = focusableElements[0]
  151. const lastElement = focusableElements[focusableElements.length - 1]
  152. const activeElement = document.activeElement
  153. if (event.shiftKey) {
  154. if (activeElement === dialog || activeElement === firstElement || !dialog.contains(activeElement)) {
  155. event.preventDefault()
  156. lastElement.focus()
  157. }
  158. return
  159. }
  160. if (activeElement === dialog) {
  161. event.preventDefault()
  162. firstElement.focus()
  163. return
  164. }
  165. if (activeElement === lastElement) {
  166. event.preventDefault()
  167. firstElement.focus()
  168. }
  169. }
  170. const handleKeydown = (event: KeyboardEvent) => {
  171. if (event.key === 'Escape') {
  172. event.preventDefault()
  173. emit('close')
  174. return
  175. }
  176. if (event.key === 'Tab') {
  177. containTabFocus(event)
  178. }
  179. }
  180. watch(
  181. () => props.visible,
  182. (visible) => {
  183. if (visible) {
  184. void focusDialog()
  185. return
  186. }
  187. restorePreviousFocus()
  188. },
  189. { flush: 'post', immediate: true },
  190. )
  191. onBeforeUnmount(() => {
  192. restorePreviousFocus()
  193. })
  194. </script>
  195. <style lang="scss" scoped>
  196. .modal-mask {
  197. position: fixed;
  198. inset: 0;
  199. z-index: 50;
  200. display: flex;
  201. align-items: center;
  202. justify-content: center;
  203. padding: 16px;
  204. background: rgba(0, 0, 0, 0.3);
  205. backdrop-filter: blur(2px);
  206. }
  207. .modal {
  208. width: 100%;
  209. max-height: 80vh;
  210. overflow-y: auto;
  211. background: #fff;
  212. border-radius: 16px;
  213. box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
  214. }
  215. .hint-modal {
  216. max-width: 440px;
  217. min-width: 280px;
  218. padding: 20px;
  219. }
  220. .modal-head {
  221. display: flex;
  222. align-items: center;
  223. justify-content: space-between;
  224. gap: 12px;
  225. margin-bottom: 12px;
  226. }
  227. .modal-title {
  228. display: flex;
  229. min-width: 0;
  230. align-items: center;
  231. gap: 6px;
  232. margin: 0;
  233. color: #111827;
  234. font-size: 14px;
  235. font-weight: 600;
  236. line-height: 1.4;
  237. }
  238. .modal-title svg {
  239. flex: 0 0 auto;
  240. }
  241. .modal-title-text {
  242. min-width: 0;
  243. overflow: hidden;
  244. text-overflow: ellipsis;
  245. white-space: nowrap;
  246. }
  247. .close-btn {
  248. display: flex;
  249. flex: 0 0 auto;
  250. align-items: center;
  251. justify-content: center;
  252. width: 26px;
  253. height: 26px;
  254. color: #6b7280;
  255. cursor: pointer;
  256. background: #f3f4f6;
  257. border: none;
  258. border-radius: 8px;
  259. &:hover {
  260. background: #e5e7eb;
  261. }
  262. }
  263. .state-panel {
  264. display: flex;
  265. flex-direction: column;
  266. align-items: center;
  267. justify-content: center;
  268. min-height: 128px;
  269. padding: 16px;
  270. text-align: center;
  271. }
  272. .state-text,
  273. .error-text {
  274. margin: 0;
  275. font-size: 13px;
  276. line-height: 1.6;
  277. }
  278. .state-text {
  279. color: #6b7280;
  280. }
  281. .error-panel {
  282. gap: 12px;
  283. }
  284. .error-text {
  285. color: #ef4444;
  286. overflow-wrap: anywhere;
  287. }
  288. .retry-btn {
  289. min-width: 72px;
  290. height: 32px;
  291. padding: 0 14px;
  292. color: #fff;
  293. font-size: 12px;
  294. font-weight: 600;
  295. line-height: 1;
  296. cursor: pointer;
  297. background: #f97316;
  298. border: none;
  299. border-radius: 8px;
  300. &:hover {
  301. background: #ea580c;
  302. }
  303. }
  304. .hint-context {
  305. padding: 12px 14px;
  306. margin-bottom: 16px;
  307. background: #fff7ed;
  308. border: 1px solid #fed7aa;
  309. border-radius: 12px;
  310. }
  311. .context-label,
  312. .section-label {
  313. margin: 0 0 8px;
  314. font-size: 10px;
  315. font-weight: 500;
  316. line-height: 1.4;
  317. letter-spacing: 0.03em;
  318. text-transform: uppercase;
  319. }
  320. .context-label {
  321. margin-bottom: 4px;
  322. color: #f97316;
  323. }
  324. .context-body {
  325. margin: 0;
  326. color: #374151;
  327. font-size: 13px;
  328. line-height: 1.5;
  329. overflow-wrap: anywhere;
  330. }
  331. .hint-section {
  332. margin-bottom: 16px;
  333. }
  334. .section-label {
  335. color: #9ca3af;
  336. }
  337. .sentences {
  338. display: flex;
  339. flex-direction: column;
  340. gap: 8px;
  341. }
  342. .sentence-card {
  343. min-width: 0;
  344. padding: 10px 12px;
  345. background: #f9fafb;
  346. border: 1px solid #f3f4f6;
  347. border-radius: 12px;
  348. transition: border-color 0.2s;
  349. &:hover {
  350. border-color: #fed7aa;
  351. }
  352. }
  353. .sentence-en,
  354. .sentence-zh,
  355. .vocab-word,
  356. .vocab-meaning {
  357. overflow-wrap: anywhere;
  358. }
  359. .sentence-en {
  360. margin: 0;
  361. color: #1f2937;
  362. font-size: 12px;
  363. line-height: 1.5;
  364. }
  365. .sentence-zh {
  366. margin: 2px 0 0;
  367. color: #9ca3af;
  368. font-size: 11px;
  369. line-height: 1.5;
  370. }
  371. .vocab-grid {
  372. display: grid;
  373. grid-template-columns: repeat(2, minmax(0, 1fr));
  374. gap: 8px;
  375. }
  376. .vocab-item {
  377. min-width: 0;
  378. padding: 8px 10px;
  379. background: #f9fafb;
  380. border: 1px solid #f3f4f6;
  381. border-radius: 10px;
  382. }
  383. .vocab-word {
  384. margin: 0;
  385. color: #1f2937;
  386. font-size: 12px;
  387. font-weight: 500;
  388. line-height: 1.4;
  389. }
  390. .vocab-meaning {
  391. margin: 2px 0 0;
  392. color: #9ca3af;
  393. font-size: 10px;
  394. line-height: 1.5;
  395. }
  396. .hint-footer {
  397. margin: 16px 0 0;
  398. color: #d1d5db;
  399. font-size: 11px;
  400. line-height: 1.5;
  401. text-align: center;
  402. }
  403. .scale-in {
  404. animation: scale-in 0.18s ease-out;
  405. }
  406. @keyframes scale-in {
  407. from {
  408. opacity: 0;
  409. transform: scale(0.96);
  410. }
  411. to {
  412. opacity: 1;
  413. transform: scale(1);
  414. }
  415. }
  416. @media (max-width: 420px) {
  417. .hint-modal {
  418. padding: 16px;
  419. }
  420. .vocab-grid {
  421. grid-template-columns: 1fr;
  422. }
  423. }
  424. </style>