FeatureForm.vue 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. <script setup lang="ts">
  2. import {
  3. NForm,
  4. NFormItem,
  5. NInput,
  6. NSelect,
  7. NRadioGroup,
  8. NRadio,
  9. NButton,
  10. NUpload,
  11. useMessage,
  12. } from 'naive-ui'
  13. import { ref, reactive } from 'vue'
  14. import { submitFeedback, uploadFeedbackAssets, deleteFeedbackAsset } from '@/service/feedback'
  15. import type { FeedbackPriority, FeatureCategory, FeedbackUploadResult } from '@/service/feedback/interface'
  16. import type { UploadFileInfo } from 'naive-ui'
  17. defineOptions({
  18. name: 'FeatureForm',
  19. })
  20. const emit = defineEmits<{
  21. submitted: []
  22. }>()
  23. const message = useMessage()
  24. const loading = ref(false)
  25. const formData = reactive({
  26. title: '',
  27. category: 'new' as FeatureCategory,
  28. priority: 'medium' as FeedbackPriority,
  29. description: '',
  30. scenario: '',
  31. relatedPage: '',
  32. })
  33. // 暂存的本地文件列表(提交时才上传)
  34. interface PendingFile {
  35. id: string
  36. file: File
  37. name: string
  38. }
  39. const pendingFiles = ref<PendingFile[]>([])
  40. const categoryOptions = [
  41. { label: '新功能', value: 'new' },
  42. { label: '功能改进', value: 'improvement' },
  43. { label: '体验优化', value: 'experience' },
  44. ]
  45. const rules = {
  46. title: { required: true, message: '请输入标题', trigger: 'blur' },
  47. description: { required: true, message: '请描述需求', trigger: 'blur' },
  48. }
  49. const formRef = ref()
  50. // 选择文件 → 暂存
  51. function handleFileSelect({ file }: { file: UploadFileInfo }) {
  52. if (!file.file) return
  53. if (file.file.size > 10 * 1024 * 1024) {
  54. message.warning('文件大小不能超过 10MB')
  55. return
  56. }
  57. pendingFiles.value.push({
  58. id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
  59. file: file.file,
  60. name: file.name || file.file.name,
  61. })
  62. }
  63. // 粘贴截图 → 暂存
  64. function handlePaste(event: ClipboardEvent) {
  65. const items = event.clipboardData?.items
  66. if (!items) return
  67. for (const item of items) {
  68. if (item.type.startsWith('image/')) {
  69. const file = item.getAsFile()
  70. if (file) {
  71. event.preventDefault()
  72. const fileName = `screenshot_${Date.now()}.png`
  73. const renamedFile = new File([file], fileName, { type: file.type })
  74. pendingFiles.value.push({
  75. id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
  76. file: renamedFile,
  77. name: fileName,
  78. })
  79. message.info('截图已添加')
  80. }
  81. break
  82. }
  83. }
  84. }
  85. function removePendingFile(index: number) {
  86. pendingFiles.value.splice(index, 1)
  87. }
  88. async function handleSubmit() {
  89. try {
  90. await formRef.value?.validate()
  91. } catch {
  92. return
  93. }
  94. loading.value = true
  95. let uploadedResults: FeedbackUploadResult[] = []
  96. try {
  97. // 1. 提交时才上传文件
  98. if (pendingFiles.value.length > 0) {
  99. const files = pendingFiles.value.map((p) => p.file)
  100. uploadedResults = await uploadFeedbackAssets(files)
  101. }
  102. // 2. 提交需求(附带已上传的 assetId 列表)
  103. await submitFeedback({
  104. title: formData.title,
  105. type: 'feature',
  106. priority: formData.priority,
  107. category: formData.category,
  108. description: formData.description,
  109. scenario: formData.scenario || undefined,
  110. relatedPage: formData.relatedPage || undefined,
  111. assetIds: uploadedResults.length > 0
  112. ? uploadedResults.map((r) => r.assetId)
  113. : undefined,
  114. })
  115. message.success('需求已提交,感谢建议!')
  116. resetForm()
  117. emit('submitted')
  118. } catch (err: any) {
  119. // 提交失败 → 清理已上传的文件
  120. if (uploadedResults.length > 0) {
  121. for (const result of uploadedResults) {
  122. try {
  123. await deleteFeedbackAsset(result.assetId)
  124. } catch {
  125. // 忽略
  126. }
  127. }
  128. }
  129. message.error(err?.message || '提交失败,请稍后重试')
  130. } finally {
  131. loading.value = false
  132. }
  133. }
  134. function resetForm() {
  135. formData.title = ''
  136. formData.category = 'new'
  137. formData.priority = 'medium'
  138. formData.description = ''
  139. formData.scenario = ''
  140. formData.relatedPage = ''
  141. pendingFiles.value = []
  142. }
  143. </script>
  144. <template>
  145. <NForm ref="formRef" :model="formData" :rules="rules" label-placement="top" label-width="auto">
  146. <NFormItem label="标题" path="title">
  147. <NInput v-model:value="formData.title" placeholder="简要描述需求" maxlength="100" show-count />
  148. </NFormItem>
  149. <div class="grid grid-cols-1 gap-x-4 md:grid-cols-2">
  150. <NFormItem label="需求类型" path="category">
  151. <NSelect v-model:value="formData.category" :options="categoryOptions" />
  152. </NFormItem>
  153. <NFormItem label="优先级" path="priority">
  154. <NRadioGroup v-model:value="formData.priority">
  155. <NRadio value="urgent">紧急</NRadio>
  156. <NRadio value="high">高</NRadio>
  157. <NRadio value="medium">中</NRadio>
  158. <NRadio value="low">低</NRadio>
  159. </NRadioGroup>
  160. </NFormItem>
  161. </div>
  162. <NFormItem label="需求描述" path="description">
  163. <NInput
  164. v-model:value="formData.description"
  165. type="textarea"
  166. placeholder="详细描述你期望的功能或改进"
  167. :rows="5"
  168. />
  169. </NFormItem>
  170. <NFormItem label="使用场景">
  171. <NInput
  172. v-model:value="formData.scenario"
  173. type="textarea"
  174. placeholder="描述什么情况下需要这个功能(可选)"
  175. :rows="3"
  176. />
  177. </NFormItem>
  178. <NFormItem label="相关页面">
  179. <NInput v-model:value="formData.relatedPage" placeholder="可选,如:站点监控 / 批量下载" />
  180. </NFormItem>
  181. <!-- 截图/附件 -->
  182. <NFormItem label="截图 / 附件">
  183. <div class="w-full" @paste="handlePaste">
  184. <NUpload
  185. :show-file-list="false"
  186. :custom-request="({ file }) => handleFileSelect({ file: file as UploadFileInfo })"
  187. accept="image/*,.pdf,.log,.txt,.xlsx,.xls,.csv,.zip"
  188. :multiple="true"
  189. >
  190. <NButton dashed class="w-full">
  191. <template #icon>
  192. <span class="i-ph:upload-simple" />
  193. </template>
  194. 点击选择文件或粘贴截图(Ctrl+V)
  195. </NButton>
  196. </NUpload>
  197. <!-- 暂存文件列表 -->
  198. <div v-if="pendingFiles.length > 0" class="mt-3 space-y-2">
  199. <div
  200. v-for="(pf, index) in pendingFiles"
  201. :key="pf.id"
  202. class="flex items-center justify-between rounded bg-neutral-50 px-3 py-2 dark:bg-neutral-800"
  203. >
  204. <span class="text-sm text-neutral-600 dark:text-neutral-400">
  205. 📎 {{ pf.name }}
  206. <span class="ml-1 text-xs text-neutral-400">
  207. ({{ (pf.file.size / 1024).toFixed(1) }}KB)
  208. </span>
  209. </span>
  210. <NButton size="tiny" quaternary type="error" @click="removePendingFile(index)">
  211. <template #icon>
  212. <span class="i-ph:x" />
  213. </template>
  214. </NButton>
  215. </div>
  216. </div>
  217. <p class="mt-1 text-xs text-neutral-400">
  218. 支持图片、PDF、日志文件等,单文件最大 10MB。文件将在提交时上传。
  219. </p>
  220. </div>
  221. </NFormItem>
  222. <div class="flex justify-end gap-3">
  223. <NButton @click="resetForm">重置</NButton>
  224. <NButton type="primary" :loading="loading" @click="handleSubmit">
  225. <template #icon>
  226. <span class="i-ph:paper-plane-tilt" />
  227. </template>
  228. 提交需求
  229. </NButton>
  230. </div>
  231. </NForm>
  232. </template>