AiCoursewareDialog.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729
  1. <template>
  2. <Modal
  3. :visible="visible"
  4. :width="1120"
  5. :close-button="true"
  6. :close-on-click-mask="!working"
  7. @update:visible="value => emit('update:visible', value)"
  8. >
  9. <div class="ai-dialog">
  10. <div class="header">
  11. <div>
  12. <div class="title">{{ pageTitle }}</div>
  13. <div class="sub">{{ pageMeta }}</div>
  14. </div>
  15. </div>
  16. <div v-if="outlineLoading" class="loading-strip">
  17. <span class="loading-dot"></span>
  18. <span>正在生成大纲,请稍候</span>
  19. </div>
  20. <div v-else-if="taskInfo" class="progress-strip">
  21. <div class="current-step">{{ currentProgressLabel }}</div>
  22. <div class="progress-line">
  23. <div class="progress-bar" :style="{ width: `${progressValue}%` }"></div>
  24. </div>
  25. <div class="progress-value">{{ progressValue }}%</div>
  26. </div>
  27. <div class="extra">
  28. <div class="label">补充要求</div>
  29. <TextArea v-model:value="extraPrompt" :rows="2" placeholder="可选,例如:多一些节奏互动,语言更适合小学低年级" :disabled="working" />
  30. </div>
  31. <div v-if="outline" class="outline">
  32. <div class="page-nav">
  33. <button
  34. v-for="page in outline.pages"
  35. :key="page.pageNo"
  36. class="nav-item"
  37. :class="{ active: selectedPageNo === page.pageNo }"
  38. type="button"
  39. @click="selectedPageNo = page.pageNo"
  40. >
  41. <span class="nav-no">{{ formatPageNo(page.pageNo) }}</span>
  42. <span class="nav-title">{{ page.title || "未命名页面" }}</span>
  43. </button>
  44. </div>
  45. <div v-if="selectedPage" class="page-editor">
  46. <div class="editor-head">
  47. <div class="title-editor">
  48. <input v-model="selectedPage.title" class="page-title" :disabled="working" />
  49. </div>
  50. </div>
  51. <div class="purpose-card">
  52. <div class="field-title">教学目的</div>
  53. <input
  54. v-model="selectedPage.teachingPurpose"
  55. :disabled="working"
  56. class="purpose-input"
  57. placeholder="这一页在课堂中要解决的教学目标"
  58. />
  59. </div>
  60. <div class="field-grid">
  61. <div class="field-card">
  62. <div class="field-title">学生看到的内容</div>
  63. <input
  64. :value="studentHeadline"
  65. :disabled="working"
  66. class="headline-input"
  67. placeholder="这一页给学生看的核心标题"
  68. @input="event => updateStudentHeadline(selectedPage!, event)"
  69. />
  70. <textarea
  71. :value="studentPoints.join('\n')"
  72. :disabled="working"
  73. class="page-bullets"
  74. placeholder="每行一条学生可见要点,避免写老师操作步骤"
  75. @input="event => updateStudentPoints(selectedPage!, event)"
  76. ></textarea>
  77. </div>
  78. <div class="field-card">
  79. <div class="field-title">课堂活动</div>
  80. <input
  81. :value="activityTask"
  82. :disabled="working"
  83. class="headline-input"
  84. placeholder="课堂任务,例如:听辨山谷和小鸟的声音"
  85. @input="event => updateActivityTask(selectedPage!, event)"
  86. />
  87. <textarea
  88. :value="(selectedPage.activity?.steps || []).join('\n')"
  89. :disabled="working"
  90. class="page-bullets"
  91. placeholder="每行一步课堂操作,供生成活动页或备注使用"
  92. @input="event => updateActivitySteps(selectedPage!, event)"
  93. ></textarea>
  94. </div>
  95. </div>
  96. <div class="field-card visual full-field">
  97. <div class="field-title">设计要求</div>
  98. <textarea
  99. v-model="selectedPage.designRequirement"
  100. :disabled="working"
  101. class="page-design"
  102. placeholder="例如:左文右图,右侧放卡通山谷和音符,整体暖色活泼,文字保持大字号。"
  103. ></textarea>
  104. </div>
  105. <div v-if="selectedPage.resourceBindings?.length" class="resource-card">
  106. <div class="resource-head">
  107. <div class="field-title">课堂资源</div>
  108. <div class="resource-hint">生成后可直接播放或展示</div>
  109. </div>
  110. <div class="resource-list">
  111. <div v-for="resource in selectedPage.resourceBindings" :key="`${resource.resourceType}-${resource.resourceId}`" class="resource-item">
  112. <span class="resource-type">{{ resourceTypeName(resource.resourceType) }}</span>
  113. <span class="resource-name">{{ resource.name || "已匹配资源" }}</span>
  114. </div>
  115. </div>
  116. </div>
  117. <div class="keyword-card">
  118. <div class="keyword-head">
  119. <div class="keyword-label">图片关键词</div>
  120. <div class="keyword-hint">用于匹配课件图片,可手动补充或删减</div>
  121. </div>
  122. <input
  123. :value="selectedPage.imageKeywords.join('、')"
  124. class="keyword-input"
  125. :disabled="working"
  126. placeholder="用于检索网络图片,例如:山谷、音乐、卡通、小鸟"
  127. @input="event => updateImageKeywords(selectedPage!, event)"
  128. />
  129. </div>
  130. <div class="remark-card">
  131. <div class="remark-head">
  132. <div class="remark-title">PPT备注</div>
  133. <div class="remark-hint">仅老师授课时参考,不会显示在幻灯片画面中</div>
  134. </div>
  135. <textarea v-model="selectedPage.teacherTips" :disabled="working" class="remark-input"></textarea>
  136. </div>
  137. </div>
  138. </div>
  139. <div v-else class="empty">生成大纲后,可在这里确认15页结构、页面内容和设计要求。</div>
  140. <div class="footer">
  141. <Button @click="emit('update:visible', false)" :disabled="working">取消</Button>
  142. <Button type="primary" @click="handleOutline" :disabled="working">{{ outline ? "重新生成大纲" : "生成大纲" }}</Button>
  143. <Button type="primary" @click="handleGenerate" :disabled="!outline || working">确认并生成PPT</Button>
  144. </div>
  145. </div>
  146. </Modal>
  147. </template>
  148. <script lang="ts" setup>
  149. import { ref, computed, watch } from "vue"
  150. import { ElMessage, ElMessageBox } from "element-plus"
  151. import Modal from "@/components/Modal.vue"
  152. import Button from "@/components/Button.vue"
  153. import TextArea from "@/components/TextArea.vue"
  154. import queryParams from "@/queryParams"
  155. import usePptWork from "@/store/pptWork"
  156. import { useSlidesStore } from "@/store"
  157. import useHistorySnapshot from "@/hooks/useHistorySnapshot"
  158. import { httpAjaxErrMsg } from "@/plugins/httpAjax"
  159. import {
  160. aiCoursewareGenerateApi,
  161. aiCoursewareOutlineApi,
  162. aiCoursewareResultApi,
  163. aiCoursewareTaskApi,
  164. type AiCoursewareOutline,
  165. type AiCoursewarePage,
  166. type AiCoursewareTaskInfo,
  167. type AiCoursewareGenerateResult
  168. } from "@/api/aiCourseware"
  169. import { aiCoursewareToSlides } from "@/utils/aiCoursewareToSlides"
  170. const props = defineProps<{
  171. visible: boolean
  172. }>()
  173. const emit = defineEmits<{
  174. (event: "update:visible", payload: boolean): void
  175. }>()
  176. const pptWork = usePptWork()
  177. const slidesStore = useSlidesStore()
  178. const { addHistorySnapshot } = useHistorySnapshot()
  179. const extraPrompt = ref("")
  180. const outline = ref<AiCoursewareOutline>()
  181. const taskInfo = ref<AiCoursewareTaskInfo>()
  182. const pollTimer = ref<number>()
  183. const selectedPageNo = ref(1)
  184. const outlineLoading = ref(false)
  185. const working = computed(() => outlineLoading.value || taskInfo.value?.status === "PENDING" || taskInfo.value?.status === "RUNNING")
  186. const progressValue = computed(() => taskInfo.value?.progress || 0)
  187. const progressSteps = [
  188. { threshold: 0, label: "准备中" },
  189. { threshold: 25, label: "生成内容" },
  190. { threshold: 50, label: "优化设计" },
  191. { threshold: 65, label: "匹配图片" },
  192. { threshold: 100, label: "生成完成" }
  193. ]
  194. const currentProgressLabel = computed(() => {
  195. const matched = [...progressSteps].reverse().find(step => progressValue.value >= step.threshold)
  196. return shortProgressLabel(taskInfo.value?.message) || matched?.label || "处理中"
  197. })
  198. const pageTitle = computed(() => outline.value?.chapterName || outline.value?.title || "课件大纲")
  199. const pageMeta = computed(() => {
  200. if (!outline.value) return "根据当前教材、单元和章节生成15页音乐课件"
  201. return [...[outline.value.gradeName, outline.value.unitName].filter(Boolean), `共${outline.value.pages.length}页`].join(" · ")
  202. })
  203. const selectedPage = computed(() => {
  204. const pages = outline.value?.pages || []
  205. return pages.find(page => page.pageNo === selectedPageNo.value) || pages[0]
  206. })
  207. const studentHeadline = computed(() => selectedPage.value?.studentContent?.headline || selectedPage.value?.subtitle || "")
  208. const studentPoints = computed(() => {
  209. if (!selectedPage.value) return []
  210. return selectedPage.value.studentContent?.points?.length ? selectedPage.value.studentContent.points : selectedPage.value.bullets || []
  211. })
  212. const activityTask = computed(() => selectedPage.value?.classroomAction?.studentTask || selectedPage.value?.activity?.task || "")
  213. watch(
  214. () => props.visible,
  215. visible => {
  216. if (!visible) clearTask()
  217. }
  218. )
  219. async function handleOutline() {
  220. if (!pptWork.id) {
  221. ElMessage.error("当前课件ID为空")
  222. return
  223. }
  224. outlineLoading.value = true
  225. const res = await httpAjaxErrMsg(aiCoursewareOutlineApi, {
  226. id: pptWork.id,
  227. fromType: queryParams.pptResourcesType || queryParams.fromType,
  228. extraPrompt: buildExtraPrompt()
  229. }).finally(() => {
  230. outlineLoading.value = false
  231. })
  232. if (res.code === 200) {
  233. outline.value = res.data as AiCoursewareOutline
  234. selectedPageNo.value = outline.value.pages[0]?.pageNo || 1
  235. }
  236. }
  237. async function handleGenerate() {
  238. if (!outline.value || !pptWork.id) return
  239. const confirmed = await ElMessageBox.confirm("生成后会替换当前编辑器中的课件内容,未保存内容将丢失。确认继续?", "确认生成", {
  240. confirmButtonText: "确认生成",
  241. cancelButtonText: "取消",
  242. type: "warning"
  243. })
  244. .then(() => true)
  245. .catch(() => false)
  246. if (!confirmed) return
  247. const res = await httpAjaxErrMsg(aiCoursewareGenerateApi, {
  248. id: pptWork.id,
  249. fromType: queryParams.pptResourcesType || queryParams.fromType,
  250. outline: outline.value
  251. })
  252. if (res.code !== 200) return
  253. taskInfo.value = res.data as AiCoursewareTaskInfo
  254. pollTask(taskInfo.value.taskId)
  255. }
  256. async function pollTask(taskId: string) {
  257. if (pollTimer.value) window.clearInterval(pollTimer.value)
  258. await fetchTask(taskId)
  259. pollTimer.value = window.setInterval(() => fetchTask(taskId), 2000)
  260. }
  261. async function fetchTask(taskId: string) {
  262. const res = await httpAjaxErrMsg(aiCoursewareTaskApi, taskId)
  263. if (res.code !== 200) {
  264. clearTask()
  265. return
  266. }
  267. taskInfo.value = res.data as AiCoursewareTaskInfo
  268. if (taskInfo.value.status === "DONE") {
  269. clearPollTimer()
  270. await applyResult(taskId)
  271. } else if (taskInfo.value.status === "FAILED") {
  272. clearPollTimer()
  273. ElMessage.error(taskInfo.value.message || "AI课件生成失败")
  274. }
  275. }
  276. function clearTask() {
  277. clearPollTimer()
  278. taskInfo.value = undefined
  279. }
  280. function clearPollTimer() {
  281. if (pollTimer.value) {
  282. window.clearInterval(pollTimer.value)
  283. pollTimer.value = undefined
  284. }
  285. }
  286. async function applyResult(taskId: string) {
  287. const res = await httpAjaxErrMsg(aiCoursewareResultApi, taskId)
  288. if (res.code !== 200) return
  289. const result = res.data as AiCoursewareGenerateResult
  290. const converted = aiCoursewareToSlides(result.outline)
  291. slidesStore.setTitle(converted.title)
  292. slidesStore.setTheme(converted.theme)
  293. slidesStore.setSlides(converted.slides)
  294. slidesStore.updateSlideIndex(0)
  295. addHistorySnapshot()
  296. ElMessage.success("PPT已生成,请检查后手动保存")
  297. emit("update:visible", false)
  298. }
  299. function updateStudentHeadline(page: AiCoursewarePage, event: Event) {
  300. if (!page.studentContent) page.studentContent = { headline: "", points: [] }
  301. page.studentContent.headline = (event.target as HTMLInputElement).value.trim()
  302. }
  303. function updateStudentPoints(page: AiCoursewarePage, event: Event) {
  304. if (!page.studentContent) page.studentContent = { headline: "", points: [] }
  305. const points = (event.target as HTMLTextAreaElement).value
  306. .split("\n")
  307. .map(item => item.trim())
  308. .filter(Boolean)
  309. page.studentContent.points = points
  310. page.bullets = points
  311. }
  312. function updateActivityTask(page: AiCoursewarePage, event: Event) {
  313. if (!page.activity) page.activity = { task: "", steps: [] }
  314. if (!page.classroomAction) page.classroomAction = { teacherInstruction: "", studentTask: "", interactionType: "" }
  315. const value = (event.target as HTMLInputElement).value.trim()
  316. page.activity.task = value
  317. page.classroomAction.studentTask = value
  318. }
  319. function updateActivitySteps(page: AiCoursewarePage, event: Event) {
  320. if (!page.activity) page.activity = { task: "", steps: [] }
  321. page.activity.steps = (event.target as HTMLTextAreaElement).value
  322. .split("\n")
  323. .map(item => item.trim())
  324. .filter(Boolean)
  325. }
  326. function updateImageKeywords(page: AiCoursewarePage, event: Event) {
  327. page.imageKeywords = (event.target as HTMLInputElement).value
  328. .split(/[,,、;;\s]+/)
  329. .map(item => item.trim())
  330. .filter(Boolean)
  331. .slice(0, 6)
  332. }
  333. function buildExtraPrompt() {
  334. return extraPrompt.value.trim()
  335. }
  336. function formatPageNo(pageNo?: number) {
  337. return String(pageNo || 0).padStart(2, "0")
  338. }
  339. function shortProgressLabel(message?: string) {
  340. if (!message) return ""
  341. if (message.includes("读取") || message.includes("等待")) return "准备中"
  342. if (message.includes("页面") || message.includes("内容")) return "生成内容"
  343. if (message.includes("优化") || message.includes("教学设计")) return "优化设计"
  344. if (message.includes("图片") || message.includes("检索")) return "匹配图片"
  345. if (message.includes("完成")) return "生成完成"
  346. if (message.includes("失败")) return "生成失败"
  347. return message.length > 4 ? message.slice(0, 4) : message
  348. }
  349. function resourceTypeName(type: string) {
  350. const names: Record<string, string> = {
  351. IMG: "图片", VIDEO: "视频", SONG: "音频", MUSIC: "乐谱", LISTEN: "听音练习",
  352. RHYTHM: "节奏练习", THEORY: "乐理知识", MUSIC_WIKI: "名曲鉴赏", INSTRUMENT: "乐器百科", MUSICIAN: "音乐家",
  353. }
  354. return names[type] || "课堂资源"
  355. }
  356. </script>
  357. <style lang="scss" scoped>
  358. .ai-dialog {
  359. height: 82vh;
  360. display: flex;
  361. flex-direction: column;
  362. color: #1f2933;
  363. }
  364. .header,
  365. .footer {
  366. display: flex;
  367. align-items: center;
  368. justify-content: space-between;
  369. }
  370. .title {
  371. font-size: 24px;
  372. font-weight: 700;
  373. color: #1f2933;
  374. }
  375. .sub {
  376. margin-top: 6px;
  377. color: #7b8794;
  378. font-size: 14px;
  379. }
  380. .progress-strip,
  381. .loading-strip {
  382. margin-top: 12px;
  383. height: 42px;
  384. display: flex;
  385. align-items: center;
  386. gap: 12px;
  387. }
  388. .loading-strip {
  389. color: #52616b;
  390. font-size: 13px;
  391. }
  392. .loading-dot {
  393. width: 14px;
  394. height: 14px;
  395. border: 2px solid #d7e7ff;
  396. border-top-color: $themeColor;
  397. border-radius: 50%;
  398. animation: loading-rotate 0.8s linear infinite;
  399. }
  400. .current-step {
  401. width: 64px;
  402. flex-shrink: 0;
  403. color: $themeColor;
  404. font-size: 13px;
  405. font-weight: 600;
  406. white-space: nowrap;
  407. }
  408. .progress-value {
  409. width: 42px;
  410. text-align: right;
  411. font-size: 13px;
  412. color: $themeColor;
  413. font-weight: 700;
  414. }
  415. .progress-line {
  416. height: 6px;
  417. flex: 1;
  418. overflow: hidden;
  419. border-radius: 999px;
  420. background: #e7edf5;
  421. }
  422. .progress-bar {
  423. height: 100%;
  424. border-radius: inherit;
  425. background: $themeColor;
  426. transition: width 0.25s ease;
  427. }
  428. @keyframes loading-rotate {
  429. to {
  430. transform: rotate(360deg);
  431. }
  432. }
  433. .extra {
  434. margin-top: 12px;
  435. }
  436. .label {
  437. margin-bottom: 8px;
  438. font-size: 14px;
  439. color: #52616b;
  440. }
  441. .outline {
  442. margin-top: 14px;
  443. min-height: 0;
  444. flex: 1;
  445. display: grid;
  446. grid-template-columns: 280px minmax(0, 1fr);
  447. gap: 14px;
  448. }
  449. .page-nav {
  450. overflow: auto;
  451. border: 1px solid #edf1f5;
  452. border-radius: 8px;
  453. background: #fafcff;
  454. padding: 8px;
  455. }
  456. .nav-item {
  457. width: 100%;
  458. display: flex;
  459. align-items: center;
  460. gap: 10px;
  461. min-height: 42px;
  462. padding: 8px 10px;
  463. border: 0;
  464. border-radius: 6px;
  465. background: transparent;
  466. color: #52616b;
  467. text-align: left;
  468. cursor: pointer;
  469. &.active {
  470. background: #eaf4ff;
  471. color: #1f2933;
  472. .nav-no {
  473. color: #fff;
  474. background: $themeColor;
  475. }
  476. }
  477. }
  478. .nav-no {
  479. width: 34px;
  480. height: 28px;
  481. line-height: 28px;
  482. text-align: center;
  483. border-radius: 5px;
  484. color: #7b8794;
  485. background: #edf1f5;
  486. flex-shrink: 0;
  487. font-size: 12px;
  488. font-weight: 600;
  489. }
  490. .nav-title {
  491. min-width: 0;
  492. overflow: hidden;
  493. text-overflow: ellipsis;
  494. white-space: nowrap;
  495. font-size: 13px;
  496. font-weight: 600;
  497. }
  498. .page-editor {
  499. min-width: 0;
  500. overflow: auto;
  501. border: 1px solid #edf1f5;
  502. border-radius: 8px;
  503. background: #fff;
  504. padding: 16px;
  505. }
  506. .editor-head {
  507. display: block;
  508. }
  509. .title-editor {
  510. max-width: 460px;
  511. }
  512. .purpose-card {
  513. margin-top: 12px;
  514. }
  515. .field-grid {
  516. margin-top: 12px;
  517. display: grid;
  518. grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  519. gap: 12px;
  520. }
  521. .field-card {
  522. padding: 12px;
  523. border: 1px solid #edf1f5;
  524. border-radius: 8px;
  525. background: #fbfcfe;
  526. &.visual {
  527. background: #fffaf3;
  528. }
  529. }
  530. .field-title {
  531. margin-bottom: 8px;
  532. color: #52616b;
  533. font-size: 13px;
  534. font-weight: 600;
  535. }
  536. .page-title,
  537. .purpose-input,
  538. .headline-input,
  539. .page-bullets,
  540. .page-design,
  541. .keyword-input,
  542. .remark-input {
  543. width: 100%;
  544. border: 1px solid #d9d9d9;
  545. border-radius: 6px;
  546. box-sizing: border-box;
  547. font-family: inherit;
  548. }
  549. .page-title {
  550. height: 36px;
  551. padding: 0 10px;
  552. font-size: 16px;
  553. font-weight: 600;
  554. }
  555. .purpose-input {
  556. height: 36px;
  557. padding: 0 10px;
  558. font-size: 14px;
  559. }
  560. .headline-input {
  561. height: 36px;
  562. padding: 0 10px;
  563. margin-bottom: 10px;
  564. font-size: 14px;
  565. }
  566. .page-bullets,
  567. .page-design {
  568. height: 150px;
  569. padding: 10px;
  570. resize: vertical;
  571. line-height: 1.5;
  572. }
  573. .full-field {
  574. margin-top: 12px;
  575. }
  576. .page-design {
  577. height: 90px;
  578. }
  579. .resource-card {
  580. margin-top: 12px;
  581. padding: 10px 12px;
  582. border: 1px solid #dcefe9;
  583. border-radius: 6px;
  584. background: #f6fbf9;
  585. }
  586. .resource-head {
  587. display: flex;
  588. align-items: baseline;
  589. gap: 10px;
  590. }
  591. .resource-hint {
  592. color: #7b8794;
  593. font-size: 12px;
  594. }
  595. .resource-list {
  596. display: flex;
  597. flex-wrap: wrap;
  598. gap: 8px;
  599. }
  600. .resource-item {
  601. display: flex;
  602. align-items: center;
  603. min-width: 0;
  604. height: 30px;
  605. padding: 0 10px;
  606. border: 1px solid #cfe6de;
  607. border-radius: 4px;
  608. background: #fff;
  609. font-size: 12px;
  610. }
  611. .resource-type {
  612. margin-right: 8px;
  613. color: #168f86;
  614. font-weight: 600;
  615. }
  616. .resource-name {
  617. max-width: 240px;
  618. overflow: hidden;
  619. color: #374151;
  620. text-overflow: ellipsis;
  621. white-space: nowrap;
  622. }
  623. .keyword-card {
  624. margin-top: 12px;
  625. }
  626. .keyword-head {
  627. display: flex;
  628. align-items: baseline;
  629. gap: 10px;
  630. margin-bottom: 8px;
  631. }
  632. .keyword-label {
  633. color: #52616b;
  634. font-size: 13px;
  635. font-weight: 600;
  636. }
  637. .keyword-hint {
  638. color: #9aa5b1;
  639. font-size: 12px;
  640. }
  641. .keyword-input {
  642. height: 34px;
  643. padding: 0 10px;
  644. }
  645. .remark-card {
  646. margin-top: 12px;
  647. padding: 10px 12px;
  648. border-radius: 6px;
  649. background: #f7f9fc;
  650. }
  651. .remark-head {
  652. display: flex;
  653. align-items: baseline;
  654. gap: 10px;
  655. margin-bottom: 8px;
  656. }
  657. .remark-title {
  658. color: #52616b;
  659. font-size: 13px;
  660. font-weight: 600;
  661. }
  662. .remark-hint {
  663. color: #9aa5b1;
  664. font-size: 12px;
  665. }
  666. .remark-input {
  667. height: 72px;
  668. padding: 10px;
  669. resize: vertical;
  670. line-height: 1.5;
  671. background: #fff;
  672. }
  673. .empty {
  674. flex: 1;
  675. margin-top: 14px;
  676. display: flex;
  677. align-items: center;
  678. justify-content: center;
  679. border: 1px dashed #d9d9d9;
  680. color: #7b8794;
  681. }
  682. .task {
  683. min-height: 20px;
  684. }
  685. .footer {
  686. margin-top: 14px;
  687. justify-content: flex-end;
  688. gap: 10px;
  689. }
  690. </style>