| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505 |
- <template>
- <div class="article-practice-config">
- <!-- 滚动内容区域 -->
- <div class="config-scroll">
- <div class="config-card">
- <!-- 1. 文章标题 -->
- <div class="form-group">
- <label class="form-label">{{ lang.ssArticleTitle }}</label>
- <input
- type="text"
- :value="store.config.title"
- @input="store.config.title = ($event.target as HTMLInputElement).value"
- class="form-input"
- :placeholder="lang.ssArticleTitlePlaceholder as string"
- />
- </div>
- <!-- 2. 文章正文 -->
- <div class="form-group">
- <div class="label-row">
- <label class="form-label">{{ lang.ssArticleContent }}</label>
- <button
- class="btn-auto-format"
- :disabled="!store.config.content.trim() || isFormatting"
- @click="openOverwrite"
- >
- {{ lang.ssArticleAutoFormat }}
- </button>
- </div>
- <textarea
- ref="contentRef"
- :value="store.config.content"
- :disabled="isFormatting"
- @input="onContentInput(($event.target as HTMLTextAreaElement).value)"
- class="form-textarea"
- :placeholder="lang.ssArticleContentPlaceholder as string"
- ></textarea>
- </div>
- <!-- 3. 练习时长 -->
- <div class="form-group slider-group">
- <label class="sub-label">{{ lang.ssArticleTotalDuration }}</label>
- <input
- type="range"
- :value="store.config.practice.duration"
- min="1" max="60" step="1"
- class="config-slider"
- @input="store.config.practice.duration = Number(($event.target as HTMLInputElement).value)"
- />
- <div class="slider-labels">
- <span class="slider-min">1 {{ lang.ssMinute }}</span>
- <span class="slider-value">{{ store.config.practice.duration }} {{ lang.ssMinute }}</span>
- <span class="slider-max">60 {{ lang.ssMinute }}</span>
- </div>
- </div>
- <!-- 4. 展示学习报告 -->
- <div class="toggle-row">
- <span class="toggle-label">{{ lang.ssShowReport }}</span>
- <button
- class="toggle-switch"
- :class="{ on: store.config.evaluation.showReport }"
- @click="store.config.evaluation.showReport = !store.config.evaluation.showReport"
- >
- <span class="toggle-knob"></span>
- </button>
- </div>
- </div>
- <div class="bottom-spacer"></div>
- </div>
- <!-- 底部操作栏 —— 不置灰:有人在练也照常可按,后端 409 时弹确认框(spec §4.3 B) -->
- <div class="config-footer">
- <button
- class="btn-apply"
- :disabled="applying || isFormatting"
- @click="handleApply"
- >
- <!-- 保存会同步把整篇示范音频合成好,正文改过的那次要几秒。按钮必须说出
- 它在等什么 —— 只转圈或只置灰,老师看到的是「应用配置卡住了」。 -->
- <span v-if="applying" class="btn-apply__spinner"></span>
- {{ applying ? lang.ssArticleGeneratingDemo : lang.ssApplyConfig }}
- </button>
- </div>
- <ArticleFormatDialog
- :visible="dialogVisible"
- :mode="dialogMode"
- :loading="isFormatting"
- @confirm="confirmDialog"
- @cancel="handleDialogCancel"
- />
- </div>
- </template>
- <script lang="ts" setup>
- import { computed, nextTick, ref, watch } from 'vue'
- import { lang } from '@/main'
- import { useArticleReadingStore } from '@/store/articleReading'
- import useCreateElement from '@/hooks/useCreateElement'
- import { getSpeakingConfig } from '@/services/speaking'
- import { createArticleConfig, updateArticleConfig } from '../services/articleReading'
- import { articleActiveCountFromError, friendlyArticleError } from '../services/articleErrors'
- import { ARTICLE_READING_TOOL_TYPE } from '@/configs/englishSpeakingTools'
- import type { ArticleReadingConfig } from '@/types/articleReading'
- import message from '@/utils/message'
- import { showConfirmDialog } from '@/utils/confirmDialog'
- import ArticleFormatDialog from '../components/ArticleFormatDialog.vue'
- import { useArticleAutoFormat } from '../composables/useArticleAutoFormat'
- defineEmits<{
- (e: 'back'): void
- }>()
- const store = useArticleReadingStore()
- const { createFrameElement } = useCreateElement()
- const applying = ref(false)
- // 自动分段(spec §3.2):三种触发共用一个弹框状态机
- const contentRef = ref<HTMLTextAreaElement | null>(null)
- const content = computed({
- get: () => store.config.content,
- set: (value: string) => {
- store.config.content = value
- },
- })
- const {
- dialogVisible,
- dialogMode,
- isFormatting,
- onContentInput,
- openOverwrite,
- guardBeforeApply,
- confirmDialog,
- cancelDialog,
- } = useArticleAutoFormat(content)
- // 守卫框的「手动修改」= 关框 + 焦点回文本框;其余 mode 只关框
- const handleDialogCancel = () => {
- if (isFormatting.value) return
- const wasGuard = dialogMode.value === 'guard'
- cancelDialog()
- if (wasGuard) nextTick(() => contentRef.value?.focus())
- }
- // 载入已有配置:竞态用 token 作废旧响应,防慢请求覆盖新选中的元素
- let loadToken = 0
- watch(() => store.activeConfigId, async (configId) => {
- if (!configId) return
- const token = ++loadToken
- try {
- const { config } = await getSpeakingConfig<ArticleReadingConfig>(configId)
- if (token !== loadToken) return
- if (config?.type !== 'article') {
- console.warn('[article-reading] ignored non-article config:', configId)
- return
- }
- store.replaceConfig(config)
- } catch (err) {
- if (token !== loadToken) return
- console.error('[article-reading] load config failed:', err)
- }
- }, { immediate: true })
- /**
- * 保存到已有 config。返回 **false = 老师在确认框里选了取消**,一个字都没写。
- *
- * 409 是问句不是墙(spec §4.3 B,2026-07-24 定):detail 里带着按下那一刻的在练人数,
- * 用它填确认框把后果说清楚 —— 那 N 个学生刷新会回到开始页、这一轮成绩不进班级名单 ——
- * 老师选「仍要保存」就带 force 原样重发一次。绝不把 409 的原始 JSON 甩给老师
- * (承 §9.2.5 那次教训:`request()` 抛的是 `response.text()`)。
- */
- async function saveExistingConfig(configId: string): Promise<boolean> {
- try {
- await updateArticleConfig(configId, store.config)
- return true
- }
- catch (err: unknown) {
- const practising = articleActiveCountFromError(err)
- if (practising === null) throw err // 别的错误照旧交给 handleApply 的 catch
- const confirmed = await showConfirmDialog({
- title: lang.ssArticlePractisingOverwriteTitle as string,
- content: String(lang.ssArticlePractisingOverwriteMessage).replace('{n}', String(practising)),
- confirmText: lang.ssArticlePractisingOverwriteConfirm as string,
- cancelText: lang.ssArticlePractisingOverwriteCancel as string,
- })
- if (!confirmed) return false
- await updateArticleConfig(configId, store.config, true)
- return true
- }
- }
- const handleApply = async () => {
- if (applying.value) return
- if (!store.config.title.trim() || !store.config.content.trim()) {
- message.error(lang.ssArticleRequired as string)
- return
- }
- // 换行归一化回写 + 单段 300 词硬守卫(spec §3.2 step 4);被拦下时弹框已开
- if (!guardBeforeApply()) return
- applying.value = true
- try {
- // 写入目标 = **面板正在编辑的那个 config**。`activeConfigId` 由两个入口置上:双击画布上的
- // 文章朗读元素(`FrameElement`)、Layer2 插入新工具。面板的表单内容也是照它加载的
- // (见下方 watch),所以它才是唯一正确的写入目标。
- //
- // 曾经的写法是「在当前幻灯片上 find 第一个 article frame」,那是错的(2026-07-27 实测撞到):
- // 面板开着切页后,find 命中的是**另一个**元素 —— 把 A 的正文写进 B;一个都找不到时更糟,
- // 掉进下面的新建分支凭空多一个 config,老师看到「保存成功」,而学生手上那个 config 一个字
- // 都没变,服务端的在练检查也就只数了那个新 config 名下的 0 个人,怎么看都「拦不住」。
- if (store.activeConfigId) {
- // 文章专属的保存接口(spec §4.3 B):服务端在真正写入的那一刻再数一次在练人数 ——
- // 面板不预先问、也不置灰,挡不住的正是「老师想了两分钟、期间有学生按了开始」这种时间差。
- // 取消 = 什么都没发生:不重置预览、不弹「已保存」。
- if (!await saveExistingConfig(store.activeConfigId)) return
- } else {
- // 面板没有可编辑的 config(两个入口都会带 id,正常进不到这里)。当作全新建。
- // 走文章自己的 POST 而不是共用的那个:新建同样要把整篇示范音频合成好才写入,
- // 否则这条兜底路生出来的 config 是唯一一份「学生点示范要等」的配置。
- const { id } = await createArticleConfig(store.config)
- createFrameElement(id, ARTICLE_READING_TOOL_TYPE)
- store.setActiveConfigId(id)
- }
- store.requestResetPreview()
- message.success(lang.ssArticleSaved as string)
- } catch (err: unknown) {
- console.error('[article-reading] apply failed:', err)
- // 「有人在练」那一条已经在 saveExistingConfig 里变成确认框了,走到这里的都是真错误
- message.error(friendlyArticleError(err))
- } finally {
- applying.value = false
- }
- }
- </script>
- <style lang="scss" scoped>
- .article-practice-config {
- display: flex;
- flex-direction: column;
- height: 100%;
- }
- .config-scroll {
- flex: 1;
- overflow-y: auto;
- padding: 16px;
- }
- .config-card {
- background: #fff;
- border: 1px solid #e5e7eb;
- border-radius: 12px;
- padding: 16px;
- }
- .form-group {
- margin-bottom: 12px;
- &:last-child {
- margin-bottom: 0;
- }
- }
- .form-label {
- display: block;
- font-size: 12px;
- font-weight: 600;
- color: #374151;
- margin-bottom: 6px;
- }
- .label-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 6px;
- .form-label {
- margin-bottom: 0;
- }
- }
- /* enspeak: px-2.5 py-1 text-[11px] text-orange-600 bg-orange-50 hover:bg-orange-100
- disabled:bg-gray-100 disabled:text-gray-400 rounded-md font-medium */
- .btn-auto-format {
- padding: 4px 10px;
- border: none;
- border-radius: 6px;
- font-size: 11px;
- font-weight: 500;
- color: #ea580c;
- background: #fff7ed;
- cursor: pointer;
- transition: background 0.2s;
- &:hover:not(:disabled) {
- background: #ffedd5;
- }
- &:disabled {
- background: #f3f4f6;
- color: #9ca3af;
- cursor: not-allowed;
- }
- }
- .sub-label {
- display: block;
- font-size: 12px;
- color: #6b7280;
- margin-bottom: 6px;
- }
- .form-input {
- width: 100%;
- height: 36px;
- padding: 0 12px;
- border: 1px solid #e5e7eb;
- border-radius: 8px;
- font-size: 13px;
- color: #374151;
- background: #fff;
- transition: all 0.2s;
- box-sizing: border-box;
- &:focus {
- outline: none;
- border-color: #f97316;
- }
- &::placeholder {
- color: #9ca3af;
- }
- }
- .form-textarea {
- width: 100%;
- height: 128px;
- padding: 8px 12px;
- border: 1px solid #e5e7eb;
- border-radius: 8px;
- font-size: 13px;
- line-height: 1.6;
- color: #374151;
- background: #fff;
- font-family: inherit;
- resize: none;
- transition: all 0.2s;
- box-sizing: border-box;
- &:focus {
- outline: none;
- border-color: #f97316;
- }
- &::placeholder {
- color: #9ca3af;
- }
- &:disabled {
- background: #f3f4f6;
- color: #9ca3af;
- cursor: not-allowed;
- }
- }
- .config-slider {
- width: 100%;
- height: 6px;
- border-radius: 3px;
- appearance: none;
- background: #e5e7eb;
- cursor: pointer;
- accent-color: #f97316;
- &::-webkit-slider-thumb {
- appearance: none;
- width: 16px;
- height: 16px;
- border-radius: 50%;
- background: #f97316;
- cursor: pointer;
- border: 2px solid #fff;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
- }
- }
- .slider-labels {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-top: 6px;
- font-size: 12px;
- }
- .slider-min, .slider-max {
- color: #9ca3af;
- }
- .slider-value {
- font-weight: 500;
- color: #374151;
- }
- .toggle-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-top: 12px;
- }
- .toggle-label {
- font-size: 12px;
- color: #4b5563;
- }
- .toggle-switch {
- position: relative;
- width: 44px;
- height: 24px;
- border-radius: 12px;
- border: none;
- cursor: pointer;
- transition: background 0.2s;
- background: #d1d5db;
- &.on {
- background: #f97316;
- .toggle-knob {
- left: 22px;
- }
- }
- }
- .toggle-knob {
- position: absolute;
- top: 4px;
- left: 4px;
- width: 16px;
- height: 16px;
- border-radius: 50%;
- background: #fff;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
- transition: left 0.2s;
- }
- .bottom-spacer {
- height: 24px;
- }
- .config-footer {
- border-top: 1px solid #f3f4f6;
- padding: 12px 16px;
- background: #fff;
- flex-shrink: 0;
- }
- .btn-apply {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- width: 100%;
- height: 36px;
- border: none;
- border-radius: 8px;
- background: #f97316;
- color: #fff;
- font-size: 13px;
- font-weight: 500;
- cursor: pointer;
- transition: background 0.2s;
- &:hover:not(:disabled) {
- background: #ea580c;
- }
- &:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- }
- }
- .btn-apply__spinner {
- width: 12px;
- height: 12px;
- flex-shrink: 0;
- border-radius: 50%;
- border: 1.5px solid currentColor;
- border-top-color: transparent;
- animation: article-apply-spin 0.7s linear infinite;
- }
- @keyframes article-apply-spin {
- to { transform: rotate(360deg); }
- }
- </style>
|