ArticlePracticeConfig.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. <template>
  2. <div class="article-practice-config">
  3. <!-- 滚动内容区域 -->
  4. <div class="config-scroll">
  5. <div class="config-card">
  6. <!-- 1. 文章标题 -->
  7. <div class="form-group">
  8. <label class="form-label">{{ lang.ssArticleTitle }}</label>
  9. <input
  10. type="text"
  11. :value="store.config.title"
  12. @input="store.config.title = ($event.target as HTMLInputElement).value"
  13. class="form-input"
  14. :placeholder="lang.ssArticleTitlePlaceholder as string"
  15. />
  16. </div>
  17. <!-- 2. 文章正文 -->
  18. <div class="form-group">
  19. <div class="label-row">
  20. <label class="form-label">{{ lang.ssArticleContent }}</label>
  21. <button
  22. class="btn-auto-format"
  23. :disabled="!store.config.content.trim() || isFormatting"
  24. @click="openOverwrite"
  25. >
  26. {{ lang.ssArticleAutoFormat }}
  27. </button>
  28. </div>
  29. <textarea
  30. ref="contentRef"
  31. :value="store.config.content"
  32. :disabled="isFormatting"
  33. @input="onContentInput(($event.target as HTMLTextAreaElement).value)"
  34. class="form-textarea"
  35. :placeholder="lang.ssArticleContentPlaceholder as string"
  36. ></textarea>
  37. </div>
  38. <!-- 3. 练习时长 -->
  39. <div class="form-group slider-group">
  40. <label class="sub-label">{{ lang.ssArticleTotalDuration }}</label>
  41. <input
  42. type="range"
  43. :value="store.config.practice.duration"
  44. min="1" max="60" step="1"
  45. class="config-slider"
  46. @input="store.config.practice.duration = Number(($event.target as HTMLInputElement).value)"
  47. />
  48. <div class="slider-labels">
  49. <span class="slider-min">1 {{ lang.ssMinute }}</span>
  50. <span class="slider-value">{{ store.config.practice.duration }} {{ lang.ssMinute }}</span>
  51. <span class="slider-max">60 {{ lang.ssMinute }}</span>
  52. </div>
  53. </div>
  54. <!-- 4. 展示学习报告 -->
  55. <div class="toggle-row">
  56. <span class="toggle-label">{{ lang.ssShowReport }}</span>
  57. <button
  58. class="toggle-switch"
  59. :class="{ on: store.config.evaluation.showReport }"
  60. @click="store.config.evaluation.showReport = !store.config.evaluation.showReport"
  61. >
  62. <span class="toggle-knob"></span>
  63. </button>
  64. </div>
  65. </div>
  66. <div class="bottom-spacer"></div>
  67. </div>
  68. <!-- 底部操作栏 —— 不置灰:有人在练也照常可按,后端 409 时弹确认框(spec §4.3 B) -->
  69. <div class="config-footer">
  70. <button
  71. class="btn-apply"
  72. :disabled="applying || isFormatting"
  73. @click="handleApply"
  74. >
  75. <!-- 保存会同步把整篇示范音频合成好,正文改过的那次要几秒。按钮必须说出
  76. 它在等什么 —— 只转圈或只置灰,老师看到的是「应用配置卡住了」。 -->
  77. <span v-if="applying" class="btn-apply__spinner"></span>
  78. {{ applying ? lang.ssArticleGeneratingDemo : lang.ssApplyConfig }}
  79. </button>
  80. </div>
  81. <ArticleFormatDialog
  82. :visible="dialogVisible"
  83. :mode="dialogMode"
  84. :loading="isFormatting"
  85. @confirm="confirmDialog"
  86. @cancel="handleDialogCancel"
  87. />
  88. </div>
  89. </template>
  90. <script lang="ts" setup>
  91. import { computed, nextTick, ref, watch } from 'vue'
  92. import { lang } from '@/main'
  93. import { useArticleReadingStore } from '@/store/articleReading'
  94. import useCreateElement from '@/hooks/useCreateElement'
  95. import { getSpeakingConfig } from '@/services/speaking'
  96. import { createArticleConfig, updateArticleConfig } from '../services/articleReading'
  97. import { articleActiveCountFromError, friendlyArticleError } from '../services/articleErrors'
  98. import { ARTICLE_READING_TOOL_TYPE } from '@/configs/englishSpeakingTools'
  99. import type { ArticleReadingConfig } from '@/types/articleReading'
  100. import message from '@/utils/message'
  101. import { showConfirmDialog } from '@/utils/confirmDialog'
  102. import ArticleFormatDialog from '../components/ArticleFormatDialog.vue'
  103. import { useArticleAutoFormat } from '../composables/useArticleAutoFormat'
  104. defineEmits<{
  105. (e: 'back'): void
  106. }>()
  107. const store = useArticleReadingStore()
  108. const { createFrameElement } = useCreateElement()
  109. const applying = ref(false)
  110. // 自动分段(spec §3.2):三种触发共用一个弹框状态机
  111. const contentRef = ref<HTMLTextAreaElement | null>(null)
  112. const content = computed({
  113. get: () => store.config.content,
  114. set: (value: string) => {
  115. store.config.content = value
  116. },
  117. })
  118. const {
  119. dialogVisible,
  120. dialogMode,
  121. isFormatting,
  122. onContentInput,
  123. openOverwrite,
  124. guardBeforeApply,
  125. confirmDialog,
  126. cancelDialog,
  127. } = useArticleAutoFormat(content)
  128. // 守卫框的「手动修改」= 关框 + 焦点回文本框;其余 mode 只关框
  129. const handleDialogCancel = () => {
  130. if (isFormatting.value) return
  131. const wasGuard = dialogMode.value === 'guard'
  132. cancelDialog()
  133. if (wasGuard) nextTick(() => contentRef.value?.focus())
  134. }
  135. // 载入已有配置:竞态用 token 作废旧响应,防慢请求覆盖新选中的元素
  136. let loadToken = 0
  137. watch(() => store.activeConfigId, async (configId) => {
  138. if (!configId) return
  139. const token = ++loadToken
  140. try {
  141. const { config } = await getSpeakingConfig<ArticleReadingConfig>(configId)
  142. if (token !== loadToken) return
  143. if (config?.type !== 'article') {
  144. console.warn('[article-reading] ignored non-article config:', configId)
  145. return
  146. }
  147. store.replaceConfig(config)
  148. } catch (err) {
  149. if (token !== loadToken) return
  150. console.error('[article-reading] load config failed:', err)
  151. }
  152. }, { immediate: true })
  153. /**
  154. * 保存到已有 config。返回 **false = 老师在确认框里选了取消**,一个字都没写。
  155. *
  156. * 409 是问句不是墙(spec §4.3 B,2026-07-24 定):detail 里带着按下那一刻的在练人数,
  157. * 用它填确认框把后果说清楚 —— 那 N 个学生刷新会回到开始页、这一轮成绩不进班级名单 ——
  158. * 老师选「仍要保存」就带 force 原样重发一次。绝不把 409 的原始 JSON 甩给老师
  159. * (承 §9.2.5 那次教训:`request()` 抛的是 `response.text()`)。
  160. */
  161. async function saveExistingConfig(configId: string): Promise<boolean> {
  162. try {
  163. await updateArticleConfig(configId, store.config)
  164. return true
  165. }
  166. catch (err: unknown) {
  167. const practising = articleActiveCountFromError(err)
  168. if (practising === null) throw err // 别的错误照旧交给 handleApply 的 catch
  169. const confirmed = await showConfirmDialog({
  170. title: lang.ssArticlePractisingOverwriteTitle as string,
  171. content: String(lang.ssArticlePractisingOverwriteMessage).replace('{n}', String(practising)),
  172. confirmText: lang.ssArticlePractisingOverwriteConfirm as string,
  173. cancelText: lang.ssArticlePractisingOverwriteCancel as string,
  174. })
  175. if (!confirmed) return false
  176. await updateArticleConfig(configId, store.config, true)
  177. return true
  178. }
  179. }
  180. const handleApply = async () => {
  181. if (applying.value) return
  182. if (!store.config.title.trim() || !store.config.content.trim()) {
  183. message.error(lang.ssArticleRequired as string)
  184. return
  185. }
  186. // 换行归一化回写 + 单段 300 词硬守卫(spec §3.2 step 4);被拦下时弹框已开
  187. if (!guardBeforeApply()) return
  188. applying.value = true
  189. try {
  190. // 写入目标 = **面板正在编辑的那个 config**。`activeConfigId` 由两个入口置上:双击画布上的
  191. // 文章朗读元素(`FrameElement`)、Layer2 插入新工具。面板的表单内容也是照它加载的
  192. // (见下方 watch),所以它才是唯一正确的写入目标。
  193. //
  194. // 曾经的写法是「在当前幻灯片上 find 第一个 article frame」,那是错的(2026-07-27 实测撞到):
  195. // 面板开着切页后,find 命中的是**另一个**元素 —— 把 A 的正文写进 B;一个都找不到时更糟,
  196. // 掉进下面的新建分支凭空多一个 config,老师看到「保存成功」,而学生手上那个 config 一个字
  197. // 都没变,服务端的在练检查也就只数了那个新 config 名下的 0 个人,怎么看都「拦不住」。
  198. if (store.activeConfigId) {
  199. // 文章专属的保存接口(spec §4.3 B):服务端在真正写入的那一刻再数一次在练人数 ——
  200. // 面板不预先问、也不置灰,挡不住的正是「老师想了两分钟、期间有学生按了开始」这种时间差。
  201. // 取消 = 什么都没发生:不重置预览、不弹「已保存」。
  202. if (!await saveExistingConfig(store.activeConfigId)) return
  203. } else {
  204. // 面板没有可编辑的 config(两个入口都会带 id,正常进不到这里)。当作全新建。
  205. // 走文章自己的 POST 而不是共用的那个:新建同样要把整篇示范音频合成好才写入,
  206. // 否则这条兜底路生出来的 config 是唯一一份「学生点示范要等」的配置。
  207. const { id } = await createArticleConfig(store.config)
  208. createFrameElement(id, ARTICLE_READING_TOOL_TYPE)
  209. store.setActiveConfigId(id)
  210. }
  211. store.requestResetPreview()
  212. message.success(lang.ssArticleSaved as string)
  213. } catch (err: unknown) {
  214. console.error('[article-reading] apply failed:', err)
  215. // 「有人在练」那一条已经在 saveExistingConfig 里变成确认框了,走到这里的都是真错误
  216. message.error(friendlyArticleError(err))
  217. } finally {
  218. applying.value = false
  219. }
  220. }
  221. </script>
  222. <style lang="scss" scoped>
  223. .article-practice-config {
  224. display: flex;
  225. flex-direction: column;
  226. height: 100%;
  227. }
  228. .config-scroll {
  229. flex: 1;
  230. overflow-y: auto;
  231. padding: 16px;
  232. }
  233. .config-card {
  234. background: #fff;
  235. border: 1px solid #e5e7eb;
  236. border-radius: 12px;
  237. padding: 16px;
  238. }
  239. .form-group {
  240. margin-bottom: 12px;
  241. &:last-child {
  242. margin-bottom: 0;
  243. }
  244. }
  245. .form-label {
  246. display: block;
  247. font-size: 12px;
  248. font-weight: 600;
  249. color: #374151;
  250. margin-bottom: 6px;
  251. }
  252. .label-row {
  253. display: flex;
  254. align-items: center;
  255. justify-content: space-between;
  256. margin-bottom: 6px;
  257. .form-label {
  258. margin-bottom: 0;
  259. }
  260. }
  261. /* enspeak: px-2.5 py-1 text-[11px] text-orange-600 bg-orange-50 hover:bg-orange-100
  262. disabled:bg-gray-100 disabled:text-gray-400 rounded-md font-medium */
  263. .btn-auto-format {
  264. padding: 4px 10px;
  265. border: none;
  266. border-radius: 6px;
  267. font-size: 11px;
  268. font-weight: 500;
  269. color: #ea580c;
  270. background: #fff7ed;
  271. cursor: pointer;
  272. transition: background 0.2s;
  273. &:hover:not(:disabled) {
  274. background: #ffedd5;
  275. }
  276. &:disabled {
  277. background: #f3f4f6;
  278. color: #9ca3af;
  279. cursor: not-allowed;
  280. }
  281. }
  282. .sub-label {
  283. display: block;
  284. font-size: 12px;
  285. color: #6b7280;
  286. margin-bottom: 6px;
  287. }
  288. .form-input {
  289. width: 100%;
  290. height: 36px;
  291. padding: 0 12px;
  292. border: 1px solid #e5e7eb;
  293. border-radius: 8px;
  294. font-size: 13px;
  295. color: #374151;
  296. background: #fff;
  297. transition: all 0.2s;
  298. box-sizing: border-box;
  299. &:focus {
  300. outline: none;
  301. border-color: #f97316;
  302. }
  303. &::placeholder {
  304. color: #9ca3af;
  305. }
  306. }
  307. .form-textarea {
  308. width: 100%;
  309. height: 128px;
  310. padding: 8px 12px;
  311. border: 1px solid #e5e7eb;
  312. border-radius: 8px;
  313. font-size: 13px;
  314. line-height: 1.6;
  315. color: #374151;
  316. background: #fff;
  317. font-family: inherit;
  318. resize: none;
  319. transition: all 0.2s;
  320. box-sizing: border-box;
  321. &:focus {
  322. outline: none;
  323. border-color: #f97316;
  324. }
  325. &::placeholder {
  326. color: #9ca3af;
  327. }
  328. &:disabled {
  329. background: #f3f4f6;
  330. color: #9ca3af;
  331. cursor: not-allowed;
  332. }
  333. }
  334. .config-slider {
  335. width: 100%;
  336. height: 6px;
  337. border-radius: 3px;
  338. appearance: none;
  339. background: #e5e7eb;
  340. cursor: pointer;
  341. accent-color: #f97316;
  342. &::-webkit-slider-thumb {
  343. appearance: none;
  344. width: 16px;
  345. height: 16px;
  346. border-radius: 50%;
  347. background: #f97316;
  348. cursor: pointer;
  349. border: 2px solid #fff;
  350. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
  351. }
  352. }
  353. .slider-labels {
  354. display: flex;
  355. justify-content: space-between;
  356. align-items: center;
  357. margin-top: 6px;
  358. font-size: 12px;
  359. }
  360. .slider-min, .slider-max {
  361. color: #9ca3af;
  362. }
  363. .slider-value {
  364. font-weight: 500;
  365. color: #374151;
  366. }
  367. .toggle-row {
  368. display: flex;
  369. align-items: center;
  370. justify-content: space-between;
  371. margin-top: 12px;
  372. }
  373. .toggle-label {
  374. font-size: 12px;
  375. color: #4b5563;
  376. }
  377. .toggle-switch {
  378. position: relative;
  379. width: 44px;
  380. height: 24px;
  381. border-radius: 12px;
  382. border: none;
  383. cursor: pointer;
  384. transition: background 0.2s;
  385. background: #d1d5db;
  386. &.on {
  387. background: #f97316;
  388. .toggle-knob {
  389. left: 22px;
  390. }
  391. }
  392. }
  393. .toggle-knob {
  394. position: absolute;
  395. top: 4px;
  396. left: 4px;
  397. width: 16px;
  398. height: 16px;
  399. border-radius: 50%;
  400. background: #fff;
  401. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
  402. transition: left 0.2s;
  403. }
  404. .bottom-spacer {
  405. height: 24px;
  406. }
  407. .config-footer {
  408. border-top: 1px solid #f3f4f6;
  409. padding: 12px 16px;
  410. background: #fff;
  411. flex-shrink: 0;
  412. }
  413. .btn-apply {
  414. display: inline-flex;
  415. align-items: center;
  416. justify-content: center;
  417. gap: 8px;
  418. width: 100%;
  419. height: 36px;
  420. border: none;
  421. border-radius: 8px;
  422. background: #f97316;
  423. color: #fff;
  424. font-size: 13px;
  425. font-weight: 500;
  426. cursor: pointer;
  427. transition: background 0.2s;
  428. &:hover:not(:disabled) {
  429. background: #ea580c;
  430. }
  431. &:disabled {
  432. opacity: 0.6;
  433. cursor: not-allowed;
  434. }
  435. }
  436. .btn-apply__spinner {
  437. width: 12px;
  438. height: 12px;
  439. flex-shrink: 0;
  440. border-radius: 50%;
  441. border: 1.5px solid currentColor;
  442. border-top-color: transparent;
  443. animation: article-apply-spin 0.7s linear infinite;
  444. }
  445. @keyframes article-apply-spin {
  446. to { transform: rotate(360deg); }
  447. }
  448. </style>