UserImportDialog.vue 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. <template>
  2. <el-dialog
  3. v-model="visible"
  4. title="用户导入"
  5. width="800px"
  6. :close-on-click-modal="false"
  7. @close="handleClose"
  8. >
  9. <el-steps :active="step" finish-status="success" align-center style="margin-bottom: 20px">
  10. <el-step title="上传文件" />
  11. <el-step title="列映射设置" />
  12. <el-step title="导入结果" />
  13. </el-steps>
  14. <!-- Step 1: Upload -->
  15. <div v-if="step === 0" class="upload-container">
  16. <el-upload
  17. class="upload-demo"
  18. drag
  19. action="#"
  20. :auto-upload="false"
  21. :on-change="handleFileChange"
  22. :limit="1"
  23. accept=".xlsx,.xls,.csv"
  24. >
  25. <el-icon class="el-icon--upload"><upload-filled /></el-icon>
  26. <div class="el-upload__text">
  27. 拖拽文件到此处或 <em>点击上传</em>
  28. </div>
  29. <template #tip>
  30. <div class="el-upload__tip">
  31. 支持 .xlsx, .xls, .csv 格式文件
  32. </div>
  33. </template>
  34. </el-upload>
  35. </div>
  36. <!-- Step 2: Mapping -->
  37. <div v-if="step === 1" class="mapping-container">
  38. <div class="preview-header">
  39. <el-form inline>
  40. <el-form-item label="数据开始行">
  41. <el-input-number v-model="startRow" :min="1" />
  42. </el-form-item>
  43. </el-form>
  44. </div>
  45. <el-alert title="请选择Excel列与系统字段的对应关系" type="info" :closable="false" style="margin-bottom: 10px" />
  46. <el-form label-width="120px">
  47. <el-form-item label="手机号 (必填)">
  48. <el-select v-model="mapping.mobile" placeholder="选择列">
  49. <el-option
  50. v-for="(header, index) in headers"
  51. :key="index"
  52. :label="`列 ${header}`"
  53. :value="index"
  54. />
  55. </el-select>
  56. </el-form-item>
  57. <el-form-item label="姓名">
  58. <el-select v-model="mapping.name" placeholder="选择列">
  59. <el-option
  60. v-for="(header, index) in headers"
  61. :key="index"
  62. :label="`列 ${header}`"
  63. :value="index"
  64. />
  65. </el-select>
  66. </el-form-item>
  67. <el-form-item label="英文名">
  68. <el-select v-model="mapping.english_name" placeholder="选择列">
  69. <el-option
  70. v-for="(header, index) in headers"
  71. :key="index"
  72. :label="`列 ${header}`"
  73. :value="index"
  74. />
  75. </el-select>
  76. </el-form-item>
  77. </el-form>
  78. <div class="data-preview">
  79. <h4>数据预览 (前10行)</h4>
  80. <el-table :data="previewData" border height="200" size="small">
  81. <el-table-column
  82. v-for="(header, index) in headers"
  83. :key="index"
  84. :label="header"
  85. min-width="100"
  86. >
  87. <template #default="scope">
  88. {{ scope.row[index] }}
  89. </template>
  90. </el-table-column>
  91. </el-table>
  92. </div>
  93. </div>
  94. <!-- Step 3: Result -->
  95. <div v-if="step === 2" class="result-container">
  96. <div class="result-summary">
  97. <el-result
  98. :icon="importResult.fail_count > 0 ? 'warning' : 'success'"
  99. :title="importResult.fail_count > 0 ? '导入完成 (有失败)' : '导入成功'"
  100. :sub-title="`总计: ${importResult.total_count}, 成功: ${importResult.success_count}, 失败: ${importResult.fail_count}`"
  101. >
  102. <template #extra>
  103. <el-button type="primary" @click="downloadSuccessRecords" v-if="importResult.success_count > 0">导出成功记录(含密码)</el-button>
  104. <el-button type="danger" @click="downloadFailLog" v-if="importResult.fail_count > 0">导出失败日志</el-button>
  105. </template>
  106. </el-result>
  107. </div>
  108. </div>
  109. <template #footer>
  110. <span class="dialog-footer">
  111. <el-button @click="handleClose" v-if="step !== 2">取消</el-button>
  112. <el-button @click="handleClose" v-if="step === 2">关闭</el-button>
  113. <el-button type="primary" @click="uploadAndPreview" v-if="step === 0" :disabled="!selectedFile">
  114. 下一步
  115. </el-button>
  116. <el-button type="primary" @click="executeImport" v-if="step === 1" :loading="importing">
  117. 开始导入
  118. </el-button>
  119. </span>
  120. </template>
  121. </el-dialog>
  122. </template>
  123. <script setup lang="ts">
  124. import { ref, reactive } from 'vue'
  125. import { UploadFilled } from '@element-plus/icons-vue'
  126. import { ElMessage } from 'element-plus'
  127. import request from '../utils/request' // Assuming request utility exists
  128. const props = defineProps<{
  129. modelValue: boolean
  130. }>()
  131. const emit = defineEmits(['update:modelValue', 'success'])
  132. const visible = ref(props.modelValue)
  133. const step = ref(0)
  134. const selectedFile = ref<File | null>(null)
  135. const headers = ref<string[]>([])
  136. const previewData = ref<any[]>([])
  137. const startRow = ref(2) // Default start row 2 (assuming row 1 is header)
  138. const importing = ref(false)
  139. const mapping = reactive({
  140. mobile: null as number | null,
  141. name: null as number | null,
  142. english_name: null as number | null
  143. })
  144. const importResult = ref({
  145. total_count: 0,
  146. success_count: 0,
  147. fail_count: 0,
  148. result_data: [] as any[]
  149. })
  150. // Watch prop change
  151. import { watch } from 'vue'
  152. watch(() => props.modelValue, (val) => {
  153. visible.value = val
  154. if (val) {
  155. reset()
  156. }
  157. })
  158. watch(visible, (val) => {
  159. emit('update:modelValue', val)
  160. })
  161. const reset = () => {
  162. step.value = 0
  163. selectedFile.value = null
  164. headers.value = []
  165. previewData.value = []
  166. startRow.value = 2
  167. mapping.mobile = null
  168. mapping.name = null
  169. mapping.english_name = null
  170. importResult.value = { total_count: 0, success_count: 0, fail_count: 0, result_data: [] }
  171. }
  172. const handleFileChange = (file: any) => {
  173. selectedFile.value = file.raw
  174. }
  175. const uploadAndPreview = async () => {
  176. if (!selectedFile.value) return
  177. const formData = new FormData()
  178. formData.append('file', selectedFile.value)
  179. try {
  180. const res = await request.post('/users/import/preview', formData, {
  181. headers: { 'Content-Type': 'multipart/form-data' }
  182. })
  183. headers.value = res.data.headers
  184. previewData.value = res.data.preview_data
  185. // Auto guess mapping if headers match
  186. // Simplified logic: If header row exists in preview data
  187. // But preview endpoint returns data including header row usually?
  188. // Backend logic: "pd.read_excel(..., header=None)". So row 0 is likely headers.
  189. step.value = 1
  190. } catch (error) {
  191. ElMessage.error('文件解析失败')
  192. console.error(error)
  193. }
  194. }
  195. const executeImport = async () => {
  196. if (mapping.mobile === null) {
  197. ElMessage.warning('请至少映射手机号列')
  198. return
  199. }
  200. importing.value = true
  201. const formData = new FormData()
  202. if (selectedFile.value) {
  203. formData.append('file', selectedFile.value)
  204. }
  205. formData.append('start_row', startRow.value.toString())
  206. // Filter out nulls
  207. const cleanMapping: any = { mobile: mapping.mobile }
  208. if (mapping.name !== null) cleanMapping.name = mapping.name
  209. if (mapping.english_name !== null) cleanMapping.english_name = mapping.english_name
  210. formData.append('mapping', JSON.stringify(cleanMapping))
  211. try {
  212. const res = await request.post('/users/import', formData, {
  213. headers: { 'Content-Type': 'multipart/form-data' }
  214. })
  215. importResult.value = res.data
  216. step.value = 2
  217. emit('success')
  218. } catch (error) {
  219. ElMessage.error('导入失败')
  220. } finally {
  221. importing.value = false
  222. }
  223. }
  224. const handleClose = () => {
  225. visible.value = false
  226. }
  227. // Download helpers
  228. const downloadSuccessRecords = () => {
  229. const successes = importResult.value.result_data.filter((r: any) => r.status === 'success')
  230. if (successes.length === 0) return
  231. // Create CSV content
  232. const header = ['Row', 'Mobile', 'Name', 'English Name', 'Initial Password']
  233. const rows = successes.map((r: any) => [
  234. r.row,
  235. r.mobile,
  236. r.name,
  237. r.english_name,
  238. r.initial_password
  239. ])
  240. downloadCSV(header, rows, `import_success_${new Date().getTime()}.csv`)
  241. }
  242. const downloadFailLog = () => {
  243. const fails = importResult.value.result_data.filter((r: any) => r.status === 'fail')
  244. if (fails.length === 0) return
  245. const header = ['Row', 'Error Message']
  246. const rows = fails.map((r: any) => [r.row, r.error])
  247. downloadCSV(header, rows, `import_fail_${new Date().getTime()}.csv`)
  248. }
  249. const downloadCSV = (header: string[], rows: any[][], filename: string) => {
  250. let csvContent = "data:text/csv;charset=utf-8,\uFEFF" // Add BOM
  251. csvContent += header.join(",") + "\r\n"
  252. rows.forEach(rowArray => {
  253. const row = rowArray.map(field => {
  254. const str = String(field || '')
  255. // Escape quotes
  256. if (str.includes(',') || str.includes('"') || str.includes('\n')) {
  257. return `"${str.replace(/"/g, '""')}"`
  258. }
  259. return str
  260. }).join(",")
  261. csvContent += row + "\r\n"
  262. })
  263. const encodedUri = encodeURI(csvContent)
  264. const link = document.createElement("a")
  265. link.setAttribute("href", encodedUri)
  266. link.setAttribute("download", filename)
  267. document.body.appendChild(link)
  268. link.click()
  269. document.body.removeChild(link)
  270. }
  271. </script>
  272. <style scoped>
  273. .upload-container {
  274. display: flex;
  275. justify-content: center;
  276. padding: 40px 0;
  277. }
  278. .mapping-container {
  279. margin-top: 20px;
  280. }
  281. .preview-header {
  282. margin-bottom: 20px;
  283. }
  284. .data-preview {
  285. margin-top: 20px;
  286. }
  287. .result-container {
  288. display: flex;
  289. justify-content: center;
  290. padding: 40px 0;
  291. }
  292. </style>