TopicDiscussionConfig.vue 27 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082
  1. <template>
  2. <div class="topic-discussion-config">
  3. <!-- 滚动内容区域 -->
  4. <div class="config-scroll">
  5. <!-- 主配置卡片 -->
  6. <div class="config-card">
  7. <!-- 1. 讨论话题 -->
  8. <div class="form-group">
  9. <label class="form-label">{{ lang.ssDiscussionTopic }}</label>
  10. <input
  11. type="text"
  12. :value="store.config.topic"
  13. @input="store.updateTopic(($event.target as HTMLInputElement).value)"
  14. class="form-input"
  15. :placeholder="lang.ssEnterTopic as string"
  16. />
  17. </div>
  18. <!-- 2. 学习目标 -->
  19. <div class="form-group">
  20. <button class="section-toggle" @click="learningGoalsExpanded = !learningGoalsExpanded">
  21. <span class="section-toggle-label">{{ lang.ssLearningGoals }}</span>
  22. <svg
  23. class="section-toggle-arrow"
  24. :class="{ collapsed: !learningGoalsExpanded }"
  25. width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
  26. >
  27. <path d="M6 9l6 6 6-6" />
  28. </svg>
  29. </button>
  30. <div v-if="learningGoalsExpanded" class="learning-goals-body">
  31. <!-- 练习词汇 -->
  32. <div class="sub-section">
  33. <div class="sub-section-header">
  34. <span class="sub-label">{{ lang.ssPracticeVocabulary }}</span>
  35. <div v-if="!isAddingVocab" class="sub-actions">
  36. <button class="icon-btn" @click="isAddingVocab = true" title="添加词汇">
  37. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  38. <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
  39. </svg>
  40. </button>
  41. <button class="icon-btn" @click="showBatchPaste = true" :title="lang.ssBatchPaste as string">
  42. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  43. <path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2" />
  44. <rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
  45. </svg>
  46. </button>
  47. </div>
  48. </div>
  49. <div class="vocab-tags">
  50. <span
  51. v-for="(word, index) in store.config.learningGoals.vocabulary"
  52. :key="index"
  53. class="vocab-tag"
  54. >
  55. {{ word }}
  56. <button class="tag-remove" @click="store.removeVocabulary(index)">
  57. <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  58. <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
  59. </svg>
  60. </button>
  61. </span>
  62. <input
  63. v-if="isAddingVocab"
  64. ref="vocabInputRef"
  65. v-model="newVocab"
  66. class="vocab-inline-input"
  67. :placeholder="lang.ssAddWord as string"
  68. @keydown.enter="handleAddVocab"
  69. @keydown.escape="cancelAddVocab"
  70. @blur="handleAddVocabBlur"
  71. />
  72. </div>
  73. </div>
  74. <!-- 练习句型 -->
  75. <div class="sub-section">
  76. <div class="sub-section-header">
  77. <span class="sub-label">{{ lang.ssPracticeSentences }}</span>
  78. <div v-if="!isAddingSentence" class="sub-actions">
  79. <button class="icon-btn" @click="isAddingSentence = true" title="添加句型">
  80. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  81. <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
  82. </svg>
  83. </button>
  84. <button class="icon-btn" @click="showBatchPaste = true" :title="lang.ssBatchPaste as string">
  85. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  86. <path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2" />
  87. <rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
  88. </svg>
  89. </button>
  90. </div>
  91. </div>
  92. <div class="sentence-list">
  93. <div
  94. v-for="(sentence, index) in store.config.learningGoals.sentences"
  95. :key="index"
  96. >
  97. <!-- 编辑模式 -->
  98. <input
  99. v-if="editingSentenceIndex === index"
  100. v-model="editingSentenceValue"
  101. class="sentence-edit-input"
  102. @keydown.enter="handleSaveEditSentence"
  103. @keydown.escape="cancelEditSentence"
  104. @blur="handleSaveEditSentenceBlur"
  105. />
  106. <!-- 展示模式 -->
  107. <div v-else class="sentence-item" @dblclick="handleEditSentence(index)">
  108. <span class="sentence-text">{{ sentence }}</span>
  109. <button class="sentence-remove" @click="store.removeSentence(index)">
  110. <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  111. <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
  112. </svg>
  113. </button>
  114. </div>
  115. </div>
  116. <input
  117. v-if="isAddingSentence"
  118. ref="sentenceInputRef"
  119. v-model="newSentence"
  120. class="sentence-edit-input"
  121. :placeholder="lang.ssAddSentence as string"
  122. @keydown.enter="handleAddSentence"
  123. @keydown.escape="cancelAddSentence"
  124. @blur="handleAddSentenceBlur"
  125. />
  126. </div>
  127. </div>
  128. </div>
  129. </div>
  130. <!-- 分割线 -->
  131. <div class="config-divider"></div>
  132. <!-- 3. 练习方式 -->
  133. <div class="form-group">
  134. <label class="sub-label">{{ lang.ssPracticeMode }}</label>
  135. <div class="mode-tabs">
  136. <button
  137. class="mode-tab"
  138. :class="{ active: store.config.practice.mode === 'time' }"
  139. @click="store.updatePracticeMode('time')"
  140. >
  141. {{ lang.ssTimeLimit }}
  142. </button>
  143. <button
  144. class="mode-tab"
  145. :class="{ active: store.config.practice.mode === 'rounds' }"
  146. @click="store.updatePracticeMode('rounds')"
  147. >
  148. {{ lang.ssRoundLimit }}
  149. </button>
  150. </div>
  151. </div>
  152. <!-- 滑块 -->
  153. <div class="form-group slider-group">
  154. <template v-if="store.config.practice.mode === 'time'">
  155. <input
  156. type="range"
  157. :value="store.config.practice.duration"
  158. min="1" max="60" step="1"
  159. class="config-slider"
  160. @input="store.updateDuration(Number(($event.target as HTMLInputElement).value))"
  161. />
  162. <div class="slider-labels">
  163. <span class="slider-min">1 {{ lang.ssMinute }}</span>
  164. <span class="slider-value">{{ store.config.practice.duration }} {{ lang.ssMinute }}</span>
  165. <span class="slider-max">60 {{ lang.ssMinute }}</span>
  166. </div>
  167. </template>
  168. <template v-else>
  169. <input
  170. type="range"
  171. :value="store.config.practice.rounds"
  172. min="1" max="60" step="1"
  173. class="config-slider"
  174. @input="store.updateRounds(Number(($event.target as HTMLInputElement).value))"
  175. />
  176. <div class="slider-labels">
  177. <span class="slider-min">1 {{ lang.ssRound }}</span>
  178. <span class="slider-value">{{ store.config.practice.rounds }} {{ lang.ssRound }}</span>
  179. <span class="slider-max">60 {{ lang.ssRound }}</span>
  180. </div>
  181. </template>
  182. </div>
  183. <!-- 展示学习报告 -->
  184. <div class="toggle-row">
  185. <span class="toggle-label">{{ lang.ssShowReport }}</span>
  186. <button
  187. class="toggle-switch"
  188. :class="{ on: store.config.evaluation.showReport }"
  189. @click="store.toggleShowReport()"
  190. >
  191. <span class="toggle-knob"></span>
  192. </button>
  193. </div>
  194. </div>
  195. <!-- 分割线 -->
  196. <div class="section-spacer"></div>
  197. <!-- 4. 高级配置 -->
  198. <div class="advanced-section">
  199. <button class="section-toggle" @click="advancedExpanded = !advancedExpanded">
  200. <span class="section-toggle-label">{{ lang.ssAdvancedConfig }}</span>
  201. <svg
  202. class="section-toggle-arrow"
  203. :class="{ collapsed: !advancedExpanded }"
  204. width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
  205. >
  206. <path d="M6 9l6 6 6-6" />
  207. </svg>
  208. </button>
  209. <div v-if="advancedExpanded" class="advanced-body">
  210. <!-- 辅助功能 -->
  211. <div class="config-card sub-card">
  212. <div class="sub-card-title">{{ lang.ssAssistance }}</div>
  213. <div class="checkbox-group">
  214. <label class="checkbox-item">
  215. <input type="checkbox" :checked="store.config.practice.showEnglish" @change="store.togglePracticeSetting('showEnglish')" />
  216. <span>{{ lang.ssShowEnglish }}</span>
  217. </label>
  218. <label class="checkbox-item">
  219. <input type="checkbox" :checked="store.config.practice.taskHint" @change="store.togglePracticeSetting('taskHint')" />
  220. <span>{{ lang.ssTaskHint }}</span>
  221. </label>
  222. <label class="checkbox-item">
  223. <input type="checkbox" :checked="store.config.practice.stutterHint" @change="store.togglePracticeSetting('stutterHint')" />
  224. <span>{{ lang.ssStutterHint }}</span>
  225. </label>
  226. </div>
  227. </div>
  228. <!-- 评估详情 -->
  229. <div class="config-card sub-card">
  230. <div class="sub-card-title">{{ lang.ssEvalDetails }}</div>
  231. <!-- 评估维度 -->
  232. <div class="eval-group">
  233. <div class="sub-label">{{ lang.ssEvalDimensions }}</div>
  234. <div class="checkbox-group">
  235. <label class="checkbox-item">
  236. <input type="checkbox" :checked="store.config.evaluation.dimensions.accuracy" @change="store.toggleEvalDimension('accuracy')" />
  237. <span>{{ lang.ssAccuracy }}</span>
  238. </label>
  239. <label class="checkbox-item">
  240. <input type="checkbox" :checked="store.config.evaluation.dimensions.fluency" @change="store.toggleEvalDimension('fluency')" />
  241. <span>{{ lang.ssFluency }}</span>
  242. </label>
  243. <label class="checkbox-item">
  244. <input type="checkbox" :checked="store.config.evaluation.dimensions.completeness" @change="store.toggleEvalDimension('completeness')" />
  245. <span>{{ lang.ssCompleteness }}</span>
  246. </label>
  247. <label class="checkbox-item">
  248. <input type="checkbox" :checked="store.config.evaluation.dimensions.rhythm" @change="store.toggleEvalDimension('rhythm')" />
  249. <span>{{ lang.ssRhythm }}</span>
  250. </label>
  251. </div>
  252. </div>
  253. <!-- 评分方式 -->
  254. <div class="eval-group">
  255. <div class="sub-label">{{ lang.ssScoreMode }}</div>
  256. <div class="radio-group">
  257. <label class="radio-item" @click="store.updateScoreMode('letter')">
  258. <span class="radio-circle" :class="{ active: store.config.evaluation.scoreMode === 'letter' }">
  259. <span v-if="store.config.evaluation.scoreMode === 'letter'" class="radio-dot"></span>
  260. </span>
  261. <span>{{ lang.ssLetterScore }}</span>
  262. </label>
  263. <label class="radio-item" @click="store.updateScoreMode('numeric')">
  264. <span class="radio-circle" :class="{ active: store.config.evaluation.scoreMode === 'numeric' }">
  265. <span v-if="store.config.evaluation.scoreMode === 'numeric'" class="radio-dot"></span>
  266. </span>
  267. <span>{{ lang.ssNumericScore }}</span>
  268. </label>
  269. <label class="radio-item" @click="store.updateScoreMode('comment_only')">
  270. <span class="radio-circle" :class="{ active: store.config.evaluation.scoreMode === 'comment_only' }">
  271. <span v-if="store.config.evaluation.scoreMode === 'comment_only'" class="radio-dot"></span>
  272. </span>
  273. <span>{{ lang.ssCommentOnly }}</span>
  274. </label>
  275. </div>
  276. </div>
  277. </div>
  278. </div>
  279. </div>
  280. <!-- 底部留白 -->
  281. <div class="bottom-spacer"></div>
  282. </div>
  283. <!-- 底部操作栏 -->
  284. <div class="config-footer">
  285. <button class="btn-apply" @click="handleApply">
  286. {{ lang.ssApplyConfig }}
  287. </button>
  288. </div>
  289. <!-- 批量粘贴弹窗 -->
  290. <div v-if="showBatchPaste" class="batch-paste-overlay" @click.self="showBatchPaste = false">
  291. <div class="batch-paste-dialog">
  292. <h3 class="dialog-title">{{ lang.ssBatchPasteTitle }}</h3>
  293. <div class="dialog-body">
  294. <div class="dialog-field">
  295. <label class="dialog-label">{{ lang.ssBatchPasteVocab }}</label>
  296. <textarea v-model="batchVocab" class="dialog-textarea" rows="4"></textarea>
  297. </div>
  298. <div class="dialog-field">
  299. <label class="dialog-label">{{ lang.ssBatchPasteSentences }}</label>
  300. <textarea v-model="batchSentences" class="dialog-textarea" rows="4"></textarea>
  301. </div>
  302. </div>
  303. <div class="dialog-footer">
  304. <button class="btn-cancel" @click="showBatchPaste = false">{{ lang.ssCancel }}</button>
  305. <button class="btn-confirm" @click="handleBatchPasteConfirm">{{ lang.ssConfirm }}</button>
  306. </div>
  307. </div>
  308. </div>
  309. </div>
  310. </template>
  311. <script lang="ts" setup>
  312. import { ref, nextTick } from 'vue'
  313. import { storeToRefs } from 'pinia'
  314. import { lang } from '@/main'
  315. import { useSpeakingStore } from '@/store/speaking'
  316. import { useSlidesStore } from '@/store'
  317. import useCreateElement from '@/hooks/useCreateElement'
  318. import { createSpeakingConfig, updateSpeakingConfig } from '@/services/speaking'
  319. import message from '@/utils/message'
  320. const emit = defineEmits<{
  321. (e: 'back'): void
  322. }>()
  323. const store = useSpeakingStore()
  324. // UI 状态
  325. const learningGoalsExpanded = ref(true)
  326. const advancedExpanded = ref(false)
  327. // 词汇新增
  328. const isAddingVocab = ref(false)
  329. const newVocab = ref('')
  330. const vocabInputRef = ref<HTMLInputElement | null>(null)
  331. // 句型新增 / 编辑
  332. const isAddingSentence = ref(false)
  333. const newSentence = ref('')
  334. const sentenceInputRef = ref<HTMLInputElement | null>(null)
  335. const editingSentenceIndex = ref<number | null>(null)
  336. const editingSentenceValue = ref('')
  337. // 批量粘贴
  338. const showBatchPaste = ref(false)
  339. const batchVocab = ref('')
  340. const batchSentences = ref('')
  341. // 词汇操作
  342. const handleAddVocab = () => {
  343. if (newVocab.value.trim()) {
  344. store.addVocabulary(newVocab.value.trim())
  345. newVocab.value = ''
  346. }
  347. }
  348. const handleAddVocabBlur = () => {
  349. if (newVocab.value.trim()) {
  350. handleAddVocab()
  351. }
  352. isAddingVocab.value = false
  353. }
  354. const cancelAddVocab = () => {
  355. isAddingVocab.value = false
  356. newVocab.value = ''
  357. }
  358. // 句型操作
  359. const handleAddSentence = () => {
  360. if (newSentence.value.trim()) {
  361. store.addSentence(newSentence.value.trim())
  362. newSentence.value = ''
  363. }
  364. }
  365. const handleAddSentenceBlur = () => {
  366. if (newSentence.value.trim()) {
  367. handleAddSentence()
  368. }
  369. isAddingSentence.value = false
  370. }
  371. const cancelAddSentence = () => {
  372. isAddingSentence.value = false
  373. newSentence.value = ''
  374. }
  375. const handleEditSentence = (index: number) => {
  376. editingSentenceIndex.value = index
  377. editingSentenceValue.value = store.config.learningGoals.sentences[index]
  378. }
  379. const handleSaveEditSentence = () => {
  380. if (editingSentenceIndex.value !== null && editingSentenceValue.value.trim()) {
  381. store.updateSentence(editingSentenceIndex.value, editingSentenceValue.value.trim())
  382. editingSentenceIndex.value = null
  383. editingSentenceValue.value = ''
  384. }
  385. }
  386. const handleSaveEditSentenceBlur = () => {
  387. if (editingSentenceValue.value.trim()) {
  388. handleSaveEditSentence()
  389. } else {
  390. cancelEditSentence()
  391. }
  392. }
  393. const cancelEditSentence = () => {
  394. editingSentenceIndex.value = null
  395. editingSentenceValue.value = ''
  396. }
  397. // 批量粘贴
  398. const handleBatchPasteConfirm = () => {
  399. const vocabItems = batchVocab.value.split('\n').map(s => s.trim()).filter(Boolean)
  400. const sentenceItems = batchSentences.value.split('\n').map(s => s.trim()).filter(Boolean)
  401. vocabItems.forEach(item => store.addVocabulary(item))
  402. sentenceItems.forEach(item => store.addSentence(item))
  403. batchVocab.value = ''
  404. batchSentences.value = ''
  405. showBatchPaste.value = false
  406. }
  407. // 应用配置 → 更新画布上已有 77 型 frame 关联的配置
  408. // 新交互流程下,元素由 Layer2 点卡片即创建;此处"应用"只负责 PUT 更新到后端
  409. const { createFrameElement } = useCreateElement()
  410. const { currentSlide } = storeToRefs(useSlidesStore())
  411. const applying = ref(false)
  412. const handleApply = async () => {
  413. if (applying.value) return
  414. applying.value = true
  415. try {
  416. const existing = currentSlide.value?.elements?.find(
  417. (el: any) => el.type === 'frame' && Number(el.toolType) === 77
  418. ) as { url?: string } | undefined
  419. if (existing?.url) {
  420. await updateSpeakingConfig(existing.url, store.config)
  421. message.success('配置已更新')
  422. } else {
  423. // 兜底:画布上没有 77 型 frame(用户删了或从未创建),此时先 POST + 插入
  424. const { id } = await createSpeakingConfig(store.config)
  425. createFrameElement(id, 77)
  426. message.success('已创建口语工具')
  427. }
  428. } catch (err: any) {
  429. console.error('[speaking] apply failed:', err)
  430. message.error(err?.message || '保存配置失败')
  431. } finally {
  432. applying.value = false
  433. }
  434. }
  435. </script>
  436. <style lang="scss" scoped>
  437. .topic-discussion-config {
  438. display: flex;
  439. flex-direction: column;
  440. height: 100%;
  441. }
  442. .config-scroll {
  443. flex: 1;
  444. overflow-y: auto;
  445. padding: 16px;
  446. }
  447. // 配置卡片
  448. .config-card {
  449. background: #fff;
  450. border: 1px solid #e5e7eb;
  451. border-radius: 12px;
  452. padding: 16px;
  453. }
  454. .config-divider {
  455. height: 1px;
  456. background: #f3f4f6;
  457. margin: 16px 0;
  458. }
  459. .section-spacer {
  460. height: 1px;
  461. background: #f3f4f6;
  462. margin: 16px 0;
  463. }
  464. .bottom-spacer {
  465. height: 24px;
  466. }
  467. // 表单元素
  468. .form-group {
  469. margin-bottom: 12px;
  470. &:last-child {
  471. margin-bottom: 0;
  472. }
  473. }
  474. .form-label {
  475. display: block;
  476. font-size: 12px;
  477. font-weight: 600;
  478. color: #374151;
  479. margin-bottom: 6px;
  480. }
  481. .form-input {
  482. width: 100%;
  483. height: 36px;
  484. padding: 0 12px;
  485. border: 1px solid #e5e7eb;
  486. border-radius: 8px;
  487. font-size: 13px;
  488. color: #374151;
  489. background: #fff;
  490. transition: all 0.2s;
  491. box-sizing: border-box;
  492. &:focus {
  493. outline: none;
  494. border-color: #f97316;
  495. }
  496. &::placeholder {
  497. color: #9ca3af;
  498. }
  499. }
  500. // 折叠切换
  501. .section-toggle {
  502. width: 100%;
  503. display: flex;
  504. align-items: center;
  505. justify-content: space-between;
  506. padding: 4px 0;
  507. background: none;
  508. border: none;
  509. cursor: pointer;
  510. transition: color 0.2s;
  511. &:hover {
  512. .section-toggle-label {
  513. color: #111827;
  514. }
  515. }
  516. }
  517. .section-toggle-label {
  518. font-size: 12px;
  519. font-weight: 600;
  520. color: #374151;
  521. }
  522. .section-toggle-arrow {
  523. color: #9ca3af;
  524. transition: transform 0.2s;
  525. &.collapsed {
  526. transform: rotate(-90deg);
  527. }
  528. }
  529. // 学习目标
  530. .learning-goals-body {
  531. margin-top: 12px;
  532. display: flex;
  533. flex-direction: column;
  534. gap: 12px;
  535. }
  536. .sub-section {
  537. display: flex;
  538. flex-direction: column;
  539. gap: 6px;
  540. }
  541. .sub-section-header {
  542. display: flex;
  543. align-items: center;
  544. justify-content: space-between;
  545. }
  546. .sub-label {
  547. font-size: 12px;
  548. color: #6b7280;
  549. }
  550. .sub-actions {
  551. display: flex;
  552. gap: 6px;
  553. }
  554. .icon-btn {
  555. width: 24px;
  556. height: 24px;
  557. display: flex;
  558. align-items: center;
  559. justify-content: center;
  560. border: 1px dashed #d1d5db;
  561. border-radius: 4px;
  562. background: none;
  563. color: #9ca3af;
  564. cursor: pointer;
  565. transition: all 0.2s;
  566. &:hover {
  567. border-color: #f97316;
  568. color: #f97316;
  569. background: #fff7ed;
  570. }
  571. }
  572. // 词汇标签
  573. .vocab-tags {
  574. display: flex;
  575. flex-wrap: wrap;
  576. gap: 6px;
  577. }
  578. .vocab-tag {
  579. display: inline-flex;
  580. align-items: center;
  581. gap: 4px;
  582. padding: 4px 10px;
  583. background: #f9fafb;
  584. border-radius: 6px;
  585. font-size: 12px;
  586. color: #374151;
  587. transition: background 0.2s;
  588. &:hover {
  589. background: #f3f4f6;
  590. .tag-remove {
  591. opacity: 1;
  592. }
  593. }
  594. }
  595. .tag-remove {
  596. display: flex;
  597. background: none;
  598. border: none;
  599. padding: 0;
  600. color: #9ca3af;
  601. cursor: pointer;
  602. opacity: 0;
  603. transition: all 0.2s;
  604. &:hover {
  605. color: #6b7280;
  606. }
  607. }
  608. .vocab-inline-input {
  609. padding: 4px 10px;
  610. border: 1px solid #d1d5db;
  611. border-radius: 6px;
  612. font-size: 12px;
  613. color: #374151;
  614. width: 96px;
  615. &:focus {
  616. outline: none;
  617. border-color: #9ca3af;
  618. }
  619. }
  620. // 句型列表
  621. .sentence-list {
  622. display: flex;
  623. flex-direction: column;
  624. gap: 6px;
  625. }
  626. .sentence-item {
  627. display: flex;
  628. align-items: center;
  629. justify-content: space-between;
  630. padding: 6px 10px;
  631. background: #f9fafb;
  632. border-radius: 8px;
  633. cursor: pointer;
  634. transition: background 0.2s;
  635. &:hover {
  636. background: #f3f4f6;
  637. .sentence-remove {
  638. opacity: 1;
  639. }
  640. }
  641. }
  642. .sentence-text {
  643. font-size: 12px;
  644. color: #374151;
  645. }
  646. .sentence-remove {
  647. display: flex;
  648. background: none;
  649. border: none;
  650. padding: 0;
  651. color: #9ca3af;
  652. cursor: pointer;
  653. opacity: 0;
  654. transition: all 0.2s;
  655. &:hover {
  656. color: #6b7280;
  657. }
  658. }
  659. .sentence-edit-input {
  660. width: 100%;
  661. padding: 6px 10px;
  662. border: 1px solid #d1d5db;
  663. border-radius: 8px;
  664. font-size: 12px;
  665. color: #374151;
  666. box-sizing: border-box;
  667. &:focus {
  668. outline: none;
  669. border-color: #9ca3af;
  670. }
  671. }
  672. // 练习方式 Tab
  673. .mode-tabs {
  674. display: flex;
  675. gap: 8px;
  676. }
  677. .mode-tab {
  678. flex: 1;
  679. height: 32px;
  680. border-radius: 8px;
  681. border: none;
  682. font-size: 13px;
  683. font-weight: 500;
  684. cursor: pointer;
  685. transition: all 0.2s;
  686. background: #f9fafb;
  687. color: #4b5563;
  688. &:hover:not(.active) {
  689. background: #f3f4f6;
  690. }
  691. &.active {
  692. background: #f97316;
  693. color: #fff;
  694. }
  695. }
  696. // 滑块
  697. .slider-group {
  698. margin-top: 4px;
  699. }
  700. .config-slider {
  701. width: 100%;
  702. height: 6px;
  703. border-radius: 3px;
  704. appearance: none;
  705. background: #e5e7eb;
  706. cursor: pointer;
  707. accent-color: #f97316;
  708. &::-webkit-slider-thumb {
  709. appearance: none;
  710. width: 16px;
  711. height: 16px;
  712. border-radius: 50%;
  713. background: #f97316;
  714. cursor: pointer;
  715. border: 2px solid #fff;
  716. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
  717. }
  718. }
  719. .slider-labels {
  720. display: flex;
  721. justify-content: space-between;
  722. align-items: center;
  723. margin-top: 6px;
  724. font-size: 12px;
  725. }
  726. .slider-min, .slider-max {
  727. color: #9ca3af;
  728. }
  729. .slider-value {
  730. font-weight: 500;
  731. color: #374151;
  732. }
  733. // Toggle 开关
  734. .toggle-row {
  735. display: flex;
  736. align-items: center;
  737. justify-content: space-between;
  738. margin-top: 12px;
  739. }
  740. .toggle-label {
  741. font-size: 12px;
  742. color: #4b5563;
  743. }
  744. .toggle-switch {
  745. position: relative;
  746. width: 44px;
  747. height: 24px;
  748. border-radius: 12px;
  749. border: none;
  750. cursor: pointer;
  751. transition: background 0.2s;
  752. background: #d1d5db;
  753. &.on {
  754. background: #f97316;
  755. .toggle-knob {
  756. left: 22px;
  757. }
  758. }
  759. }
  760. .toggle-knob {
  761. position: absolute;
  762. top: 4px;
  763. left: 4px;
  764. width: 16px;
  765. height: 16px;
  766. border-radius: 50%;
  767. background: #fff;
  768. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
  769. transition: left 0.2s;
  770. }
  771. // 高级配置
  772. .advanced-section {
  773. // 外层无需额外样式
  774. }
  775. .advanced-body {
  776. margin-top: 12px;
  777. display: flex;
  778. flex-direction: column;
  779. gap: 12px;
  780. }
  781. .sub-card {
  782. padding: 12px;
  783. }
  784. .sub-card-title {
  785. font-size: 12px;
  786. font-weight: 600;
  787. color: #374151;
  788. margin-bottom: 10px;
  789. }
  790. .checkbox-group {
  791. display: flex;
  792. flex-wrap: wrap;
  793. gap: 12px 16px;
  794. }
  795. .checkbox-item {
  796. display: flex;
  797. align-items: center;
  798. gap: 6px;
  799. cursor: pointer;
  800. font-size: 12px;
  801. color: #4b5563;
  802. input[type="checkbox"] {
  803. width: 14px;
  804. height: 14px;
  805. accent-color: #f97316;
  806. }
  807. }
  808. .eval-group {
  809. margin-top: 10px;
  810. }
  811. .radio-group {
  812. display: flex;
  813. flex-direction: column;
  814. gap: 4px;
  815. margin-top: 4px;
  816. }
  817. .radio-item {
  818. display: flex;
  819. align-items: center;
  820. gap: 8px;
  821. cursor: pointer;
  822. font-size: 12px;
  823. color: #374151;
  824. padding: 2px 0;
  825. user-select: none;
  826. }
  827. .radio-circle {
  828. width: 16px;
  829. height: 16px;
  830. border-radius: 50%;
  831. border: 2px solid #d1d5db;
  832. display: flex;
  833. align-items: center;
  834. justify-content: center;
  835. transition: border-color 0.2s;
  836. &.active {
  837. border-color: #f97316;
  838. }
  839. }
  840. .radio-dot {
  841. width: 8px;
  842. height: 8px;
  843. border-radius: 50%;
  844. background: #f97316;
  845. }
  846. // 底部操作栏
  847. .config-footer {
  848. border-top: 1px solid #f3f4f6;
  849. padding: 12px 16px;
  850. background: #fff;
  851. flex-shrink: 0;
  852. }
  853. .btn-apply {
  854. width: 100%;
  855. height: 36px;
  856. border: none;
  857. border-radius: 8px;
  858. background: #f97316;
  859. color: #fff;
  860. font-size: 13px;
  861. font-weight: 500;
  862. cursor: pointer;
  863. transition: background 0.2s;
  864. &:hover {
  865. background: #ea580c;
  866. }
  867. }
  868. // 批量粘贴弹窗
  869. .batch-paste-overlay {
  870. position: fixed;
  871. inset: 0;
  872. background: rgba(0, 0, 0, 0.3);
  873. display: flex;
  874. align-items: center;
  875. justify-content: center;
  876. z-index: 1000;
  877. }
  878. .batch-paste-dialog {
  879. width: 360px;
  880. background: #fff;
  881. border-radius: 16px;
  882. box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
  883. overflow: hidden;
  884. }
  885. .dialog-title {
  886. font-size: 15px;
  887. font-weight: 600;
  888. color: #111827;
  889. padding: 16px 20px;
  890. border-bottom: 1px solid #f3f4f6;
  891. margin: 0;
  892. }
  893. .dialog-body {
  894. padding: 16px 20px;
  895. display: flex;
  896. flex-direction: column;
  897. gap: 12px;
  898. }
  899. .dialog-field {
  900. display: flex;
  901. flex-direction: column;
  902. gap: 6px;
  903. }
  904. .dialog-label {
  905. font-size: 12px;
  906. font-weight: 500;
  907. color: #374151;
  908. }
  909. .dialog-textarea {
  910. width: 100%;
  911. padding: 8px 12px;
  912. border: 1px solid #e5e7eb;
  913. border-radius: 8px;
  914. font-size: 13px;
  915. color: #374151;
  916. resize: vertical;
  917. font-family: inherit;
  918. box-sizing: border-box;
  919. &:focus {
  920. outline: none;
  921. border-color: #f97316;
  922. }
  923. }
  924. .dialog-footer {
  925. display: flex;
  926. justify-content: flex-end;
  927. gap: 8px;
  928. padding: 12px 20px;
  929. border-top: 1px solid #f3f4f6;
  930. }
  931. .btn-cancel {
  932. padding: 6px 16px;
  933. border: 1px solid #e5e7eb;
  934. border-radius: 8px;
  935. background: #fff;
  936. font-size: 13px;
  937. color: #4b5563;
  938. cursor: pointer;
  939. transition: all 0.2s;
  940. &:hover {
  941. background: #f9fafb;
  942. }
  943. }
  944. .btn-confirm {
  945. padding: 6px 16px;
  946. border: none;
  947. border-radius: 8px;
  948. background: #f97316;
  949. font-size: 13px;
  950. color: #fff;
  951. cursor: pointer;
  952. transition: all 0.2s;
  953. &:hover {
  954. background: #ea580c;
  955. }
  956. }
  957. </style>