index.vue 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807
  1. <template>
  2. <div class="p-2">
  3. <transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
  4. <div v-show="showSearch" class="mb-[10px]">
  5. <el-card shadow="hover">
  6. <el-form ref="queryFormRef" :model="queryParams" :inline="true">
  7. <el-form-item label="等级" prop="levelNumber">
  8. <el-input v-model="queryParams.levelNumber" placeholder="请输入等级" clearable @keyup.enter="handleQuery" />
  9. </el-form-item>
  10. <el-form-item label="小盲预置分" prop="smallBlind">
  11. <el-input v-model="queryParams.smallBlind" placeholder="请输入小盲预置分" clearable @keyup.enter="handleQuery" />
  12. </el-form-item>
  13. <el-form-item label="大盲预置分" prop="bigBlind">
  14. <el-input v-model="queryParams.bigBlind" placeholder="请输入大盲预置分" clearable @keyup.enter="handleQuery" />
  15. </el-form-item>
  16. <el-form-item label="前置预置分" prop="ante">
  17. <el-input v-model="queryParams.ante" placeholder="请输入前置预置分" clearable @keyup.enter="handleQuery" />
  18. </el-form-item>
  19. <el-form-item label="升级时间" prop="durationMinutes">
  20. <el-input v-model="queryParams.durationMinutes" placeholder="请输入升级时间" clearable @keyup.enter="handleQuery" />
  21. </el-form-item>
  22. <el-form-item>
  23. <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
  24. <el-button icon="Refresh" @click="resetQuery">重置</el-button>
  25. </el-form-item>
  26. </el-form>
  27. </el-card>
  28. </div>
  29. </transition>
  30. <div class="blind-levels-container">
  31. <el-card shadow="never" class="blind-levels-card">
  32. <template #header>
  33. <el-row :gutter="10" class="mb8">
  34. <el-col :span="1.5">
  35. <el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['business:levels:add']">新增</el-button>
  36. </el-col>
  37. <el-col :span="1.5">
  38. <el-button type="success" plain icon="Edit" :disabled="single" @click="handleUpdate()" v-hasPermi="['business:levels:edit']">修改</el-button>
  39. </el-col>
  40. <el-col :span="1.5">
  41. <el-button type="danger" plain icon="Delete" :disabled="multiple" @click="handleDelete()" v-hasPermi="['business:levels:remove']">删除</el-button>
  42. </el-col>
  43. <el-col :span="1.5">
  44. <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['business:levels:export']">导出</el-button>
  45. </el-col>
  46. <el-col :span="1.5">
  47. <el-button type="primary" plain icon="Upload" @click="handleImport" v-hasPermi="['business:levels:import']">导入</el-button>
  48. </el-col>
  49. <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
  50. </el-row>
  51. </template>
  52. <el-table v-loading="loading" border :data="levelsList" @selection-change="handleSelectionChange" row-key="id" :row-class-name="tableRowClassName">
  53. <el-table-column type="selection" width="55" align="center" :reserve-selection="true" />
  54. <el-table-column label="等级" align="center" width="120">
  55. <template #default="scope">
  56. <span>{{ scope.row.levelNumber }}</span>
  57. </template>
  58. </el-table-column>
  59. <el-table-column label="小盲预置分" align="center">
  60. <template #default="scope">
  61. <div class="stepper-cell">
  62. <el-button size="small" text @click="stepValue(scope.row, 'smallBlind', -1)">−</el-button>
  63. <span class="stepper-value">{{ scope.row.smallBlind ?? 0 }}</span>
  64. <el-button size="small" text @click="stepValue(scope.row, 'smallBlind', 1)">+</el-button>
  65. </div>
  66. </template>
  67. </el-table-column>
  68. <el-table-column label="大盲预置分" align="center">
  69. <template #default="scope">
  70. <div class="stepper-cell">
  71. <el-button size="small" text @click="stepValue(scope.row, 'bigBlind', -1)">−</el-button>
  72. <span class="stepper-value">{{ scope.row.bigBlind ?? 0 }}</span>
  73. <el-button size="small" text @click="stepValue(scope.row, 'bigBlind', 1)">+</el-button>
  74. </div>
  75. </template>
  76. </el-table-column>
  77. <el-table-column label="前置预置分" align="center">
  78. <template #default="scope">
  79. <div class="stepper-cell">
  80. <el-button size="small" text @click="stepValue(scope.row, 'ante', -1)">−</el-button>
  81. <span class="stepper-value">{{ scope.row.ante ?? 0 }}</span>
  82. <el-button size="small" text @click="stepValue(scope.row, 'ante', 1)">+</el-button>
  83. </div>
  84. </template>
  85. </el-table-column>
  86. <el-table-column label="升级时间" align="center" width="140">
  87. <template #default="scope">
  88. <div class="stepper-cell">
  89. <el-button size="small" text @click="stepValue(scope.row, 'durationMinutes', -1)">−</el-button>
  90. <span class="stepper-value">{{ scope.row.durationMinutes ?? 0 }}</span>
  91. <el-button size="small" text @click="stepValue(scope.row, 'durationMinutes', 1)">+</el-button>
  92. </div>
  93. </template>
  94. </el-table-column>
  95. <el-table-column label="创建时间" align="center" prop="createdAt" width="180">
  96. <template #default="scope">
  97. <span>{{ parseTime(scope.row.createdAt, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
  98. </template>
  99. </el-table-column>
  100. <el-table-column label="更新时间" align="center" prop="updatedAt" width="180">
  101. <template #default="scope">
  102. <span>{{ parseTime(scope.row.updatedAt, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
  103. </template>
  104. </el-table-column>
  105. <el-table-column label="操作" align="center" width="140">
  106. <template #default="scope">
  107. <el-button link type="primary" size="small" :loading="insertLoading" @click="handleInsertAfter(scope.row)">插入</el-button>
  108. <el-button link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
  109. </template>
  110. </el-table-column>
  111. </el-table>
  112. <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
  113. <div class="blind-levels-footer">
  114. <el-button type="primary" :loading="saveLoading" @click="handleBatchSave">保存</el-button>
  115. <el-button @click="handleClose">关闭</el-button>
  116. </div>
  117. </el-card>
  118. </div>
  119. <!-- 添加或修改盲注等级对话框 -->
  120. <el-dialog :title="dialog.title" v-model="dialog.visible" width="500px" append-to-body>
  121. <el-form ref="levelsFormRef" :model="form" :rules="rules" label-width="80px">
  122. <el-form-item label="所属盲注" prop="name">
  123. <el-input v-model="form.name" disabled />
  124. </el-form-item>
  125. <el-form-item label="等级" prop="levelNumber">
  126. <el-input v-model="form.levelNumber" placeholder="请输入等级" />
  127. </el-form-item>
  128. <el-form-item label="小盲" prop="smallBlind">
  129. <el-input v-model="form.smallBlind" placeholder="请输入小盲预置分" />
  130. </el-form-item>
  131. <el-form-item label="大盲" prop="bigBlind">
  132. <el-input v-model="form.bigBlind" placeholder="请输入大盲预置分" />
  133. </el-form-item>
  134. <el-form-item label="前置" prop="ante">
  135. <el-input v-model="form.ante" placeholder="请输入前置预置分" />
  136. </el-form-item>
  137. <el-form-item label="升级时间" prop="durationMinutes">
  138. <el-input v-model="form.durationMinutes" placeholder="请输入升级时间" />
  139. </el-form-item>
  140. <el-form-item label="备注" prop="remark">
  141. <el-input v-model="form.remark" placeholder="请输入备注" />
  142. </el-form-item>
  143. </el-form>
  144. <template #footer>
  145. <div class="dialog-footer">
  146. <el-button :loading="buttonLoading" type="primary" @click="submitForm">确 定</el-button>
  147. <el-button @click="cancel">取 消</el-button>
  148. </div>
  149. </template>
  150. </el-dialog>
  151. <!-- 导入对话框 -->
  152. <el-dialog :title="importDialog.title" v-model="importDialog.visible" width="500px" append-to-body>
  153. <el-upload
  154. ref="uploadRef"
  155. :limit="1"
  156. accept=".xlsx, .xls"
  157. :headers="importDialog.headers"
  158. :action="importDialog.url"
  159. :disabled="importDialog.isUploading"
  160. :on-change="handleFileChange"
  161. :on-progress="handleFileUploadProgress"
  162. :on-success="handleFileSuccess"
  163. :auto-upload="false"
  164. drag
  165. >
  166. <el-icon class="el-icon--upload"><upload-filled /></el-icon>
  167. <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
  168. <template #tip>
  169. <div class="el-upload__tip text-center">
  170. <div class="el-upload__tip">
  171. <el-checkbox v-model="importDialog.updateSupport" />是否更新已经存在的数据
  172. </div>
  173. <span>仅允许导入xls、xlsx格式文件。</span>
  174. <el-link type="primary" :underline="false" style="font-size: 12px; vertical-align: baseline" @click="handleDownloadTemplate">下载模板</el-link>
  175. </div>
  176. </template>
  177. </el-upload>
  178. <template #footer>
  179. <div class="dialog-footer">
  180. <el-button type="primary" @click="submitFileForm">确 定</el-button>
  181. <el-button @click="importDialog.visible = false; uploadFile.value = null; uploadRef.value?.clearFiles()">取 消</el-button>
  182. </div>
  183. </template>
  184. </el-dialog>
  185. </div>
  186. </template>
  187. <script setup name="Levels" lang="ts">
  188. import {
  189. listLevels,
  190. getLevels,
  191. delLevels,
  192. addLevels,
  193. updateLevels,
  194. downloadImportTemplate,
  195. importData,
  196. insertAfter,
  197. batchSave
  198. } from '@/api/system/business/levels';
  199. import { LevelsVO, LevelsQuery, LevelsForm } from '@/api/system/business/levels/types';
  200. import { parseTime } from '@/utils/dateUtils';
  201. import { useBlindStructureInfo } from '@/api/system/business/levels/useBlindStructureId';
  202. import { getToken } from '@/utils/auth';
  203. const { proxy } = getCurrentInstance() as ComponentInternalInstance;
  204. const levelsList = ref<LevelsVO[]>([]);
  205. const buttonLoading = ref(false);
  206. const saveLoading = ref(false);
  207. const insertLoading = ref(false);
  208. const loading = ref(true);
  209. const showSearch = ref(true);
  210. const ids = ref<Array<string | number>>([]);
  211. const single = ref(true);
  212. const multiple = ref(true);
  213. const total = ref(0);
  214. const queryFormRef = ref<ElFormInstance>();
  215. const levelsFormRef = ref<ElFormInstance>();
  216. const uploadRef = ref<ElUploadInstance>();
  217. const uploadFile = ref<File | null>(null);
  218. const rowErrors = ref<Map<string | number, string[]>>(new Map());
  219. const props = defineProps({
  220. blindStructureId: {
  221. type: [Number, String],
  222. required: false,
  223. default: null
  224. },
  225. name: {
  226. type: String,
  227. required: false,
  228. default: ''
  229. }
  230. });
  231. const { info } = useBlindStructureInfo(props);
  232. const currentId = ref<number | string | null>(null);
  233. const currentName = ref('');
  234. const dialog = reactive<DialogOption>({
  235. visible: false,
  236. title: ''
  237. });
  238. const importDialog = reactive({
  239. visible: false,
  240. title: '导入盲注等级数据',
  241. isUploading: false,
  242. updateSupport: false,
  243. headers: { Authorization: 'Bearer ' + getToken() },
  244. url: import.meta.env.VITE_APP_BASE_API + '/business/levels/importData'
  245. });
  246. const initFormData: LevelsForm = {
  247. id: undefined,
  248. name: undefined,
  249. blindStructureId: undefined,
  250. levelNumber: undefined,
  251. smallBlind: undefined,
  252. bigBlind: undefined,
  253. ante: undefined,
  254. durationMinutes: undefined,
  255. createdAt: undefined,
  256. updatedAt: undefined,
  257. createUserId: undefined,
  258. updateUserId: undefined,
  259. remark: undefined
  260. };
  261. const data = reactive<PageData<LevelsForm, LevelsQuery>>({
  262. form: { ...initFormData },
  263. queryParams: {
  264. pageNum: 1,
  265. pageSize: 10,
  266. blindStructureId: undefined,
  267. levelNumber: undefined,
  268. smallBlind: undefined,
  269. bigBlind: undefined,
  270. ante: undefined,
  271. durationMinutes: undefined,
  272. createdAt: undefined,
  273. updatedAt: undefined,
  274. createUserId: undefined,
  275. updateUserId: undefined,
  276. params: {},
  277. // 按级别号排序
  278. orderByColumn: 'levelNumber',
  279. isAsc: 'asc'
  280. },
  281. rules: {
  282. id: [{ required: true, message: '不能为空', trigger: 'blur' }],
  283. levelNumber: [{ required: true, message: '级别编号不能为空', trigger: 'blur' }],
  284. smallBlind: [{ required: true, message: '小盲金额不能为空', trigger: 'blur' }],
  285. bigBlind: [{ required: true, message: '大盲金额不能为空', trigger: 'blur' }],
  286. durationMinutes: [{ required: true, message: '持续时间不能为空', trigger: 'blur' }]
  287. }
  288. });
  289. const { queryParams, form, rules } = toRefs(data);
  290. /** 查询盲注等级列表 */
  291. const getList = async () => {
  292. loading.value = true;
  293. const res = await listLevels(queryParams.value);
  294. levelsList.value = (res.rows || []).sort((a, b) => (a.levelNumber || 0) - (b.levelNumber || 0));
  295. total.value = res.total;
  296. loading.value = false;
  297. };
  298. /** 独立排序方法:按级别号排序(用于插入和保存后重新排序) */
  299. const sortListByLevelNumber = () => {
  300. levelsList.value.sort((a, b) => (a.levelNumber || 0) - (b.levelNumber || 0));
  301. };
  302. /** 计算显示级别:直接返回级别号 */
  303. const getDisplayLevel = (row: LevelsVO): number => {
  304. return row.levelNumber || 0;
  305. };
  306. /** 取消按钮 */
  307. const cancel = () => {
  308. reset();
  309. dialog.visible = false;
  310. };
  311. /** 表单重置 */
  312. const reset = () => {
  313. form.value = { ...initFormData };
  314. levelsFormRef.value?.resetFields();
  315. };
  316. /** 搜索按钮操作 */
  317. const handleQuery = () => {
  318. queryParams.value.pageNum = 1;
  319. getList();
  320. };
  321. /** 重置按钮操作 */
  322. const resetQuery = () => {
  323. queryFormRef.value?.resetFields();
  324. handleQuery();
  325. };
  326. /** 多选框选中数据 */
  327. const handleSelectionChange = (selection: LevelsVO[]) => {
  328. ids.value = selection.map((item) => item.id);
  329. single.value = selection.length != 1;
  330. multiple.value = !selection.length;
  331. };
  332. /** 新增按钮操作 */
  333. const handleAdd = () => {
  334. reset();
  335. dialog.visible = true;
  336. dialog.title = '添加';
  337. };
  338. /** 修改按钮操作 */
  339. const handleUpdate = async (row?: LevelsVO) => {
  340. reset();
  341. const _id = row?.id || ids.value[0];
  342. const res = await getLevels(_id);
  343. Object.assign(form.value, res.data);
  344. dialog.visible = true;
  345. dialog.title = '修改';
  346. };
  347. /** 提交按钮 */
  348. const submitForm = () => {
  349. levelsFormRef.value?.validate(async (valid: boolean) => {
  350. if (valid) {
  351. buttonLoading.value = true;
  352. if (form.value.id) {
  353. await updateLevels(form.value).finally(() => (buttonLoading.value = false));
  354. } else {
  355. await addLevels(form.value).finally(() => (buttonLoading.value = false));
  356. }
  357. proxy?.$modal.msgSuccess('操作成功');
  358. dialog.visible = false;
  359. await getList();
  360. }
  361. });
  362. };
  363. /** 删除按钮操作 */
  364. const handleDelete = async (row?: LevelsVO) => {
  365. // 行内删除:未保存的临时数据,直接移除不弹框
  366. if (row && (row as any)._isNew) {
  367. const idx = levelsList.value.findIndex((r) => r.id === row.id);
  368. if (idx !== -1) {
  369. levelsList.value.splice(idx, 1);
  370. }
  371. proxy?.$modal.msgSuccess('删除成功');
  372. return;
  373. }
  374. // 已保存数据或批量删除:弹确认后调API
  375. const _ids = row?.id || ids.value;
  376. await proxy?.$modal.confirm('是否确认删除编号为"' + _ids + '"的数据项?').finally(() => (loading.value = false));
  377. await delLevels(_ids);
  378. proxy?.$modal.msgSuccess('删除成功');
  379. await getList();
  380. };
  381. /** 导出按钮操作 */
  382. const handleExport = () => {
  383. proxy?.download(
  384. 'business/levels/export',
  385. {
  386. ...queryParams.value
  387. },
  388. `盲注等级详情${parseTime(new Date(), '{y}{m}{d}{h}{i}{s}')}.xlsx`
  389. );
  390. };
  391. /** 导入按钮操作 */
  392. const handleImport = () => {
  393. importDialog.visible = true;
  394. };
  395. /** 下载模板 */
  396. const handleDownloadTemplate = async () => {
  397. try {
  398. const response = await downloadImportTemplate();
  399. const blobData = new Blob([response.data || response], {
  400. type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
  401. });
  402. const urlBlob = window.URL.createObjectURL(blobData);
  403. const link = document.createElement('a');
  404. link.href = urlBlob;
  405. link.setAttribute('download', '盲注等级模板.xlsx');
  406. document.body.appendChild(link);
  407. link.click();
  408. setTimeout(() => {
  409. link.remove();
  410. window.URL.revokeObjectURL(urlBlob);
  411. }, 0);
  412. } catch (error) {
  413. console.error('Download error:', error);
  414. ElMessage.error('下载失败,请重试');
  415. }
  416. };
  417. /** 文件上传中处理 */
  418. const handleFileUploadProgress = () => {
  419. importDialog.isUploading = true;
  420. };
  421. /** 文件上传成功处理 */
  422. const handleFileSuccess = async (response: any, file: any) => {
  423. importDialog.isUploading = false;
  424. uploadRef.value?.clearFiles();
  425. importDialog.visible = false;
  426. if (response.code === 200) {
  427. proxy?.$modal.msgSuccess('导入成功');
  428. await getList();
  429. } else {
  430. proxy?.$modal.msgError(response.msg || '导入失败');
  431. }
  432. };
  433. /** 文件选择变化 */
  434. const handleFileChange = (file: any) => {
  435. uploadFile.value = file.raw;
  436. };
  437. /** 提交上传文件 */
  438. const submitFileForm = () => {
  439. if (!uploadFile.value) {
  440. proxy?.$modal.msgError('请先选择文件');
  441. return;
  442. }
  443. if (!currentId.value) {
  444. proxy?.$modal.msgError('盲注结构ID不能为空');
  445. return;
  446. }
  447. importDialog.isUploading = true;
  448. importData(uploadFile.value, currentId.value)
  449. .then((response: any) => {
  450. importDialog.isUploading = false;
  451. uploadFile.value = null;
  452. uploadRef.value?.clearFiles();
  453. importDialog.visible = false;
  454. if (response.code === 200) {
  455. proxy?.$modal.msgSuccess('导入成功');
  456. getList();
  457. } else {
  458. proxy?.$modal.msgError(response.msg || '导入失败');
  459. }
  460. })
  461. .catch((error) => {
  462. importDialog.isUploading = false;
  463. proxy?.$modal.msgError('导入失败,请重试');
  464. console.error('Import error:', error);
  465. });
  466. };
  467. /** 行内步进器增减(步长=1) */
  468. const stepValue = (row: LevelsVO, field: 'smallBlind' | 'bigBlind' | 'ante' | 'durationMinutes', delta: number) => {
  469. const val = (row[field] as number) || 0;
  470. (row as any)[field] = Math.max(0, val + delta);
  471. };
  472. /** 表格行样式(有错误的行标红) */
  473. const tableRowClassName = ({ row }: { row: LevelsVO }) => {
  474. if (rowErrors.value.has(row.id)) {
  475. return 'row-error';
  476. }
  477. return '';
  478. };
  479. /** 校验所有行数据 */
  480. const validateRows = (): string[] => {
  481. const allErrors: string[] = [];
  482. rowErrors.value.clear();
  483. levelsList.value.forEach((row) => {
  484. const errors: string[] = [];
  485. if (!row.levelNumber || row.levelNumber <= 0) {
  486. errors.push(`级别${row.levelNumber || '?'}:级别编号不能为空`);
  487. }
  488. if (row.smallBlind === null || row.smallBlind === undefined || row.smallBlind < 0) {
  489. errors.push(`级别${row.levelNumber || '?'}:小盲金额不能为空`);
  490. }
  491. if (row.bigBlind === null || row.bigBlind === undefined || row.bigBlind < 0) {
  492. errors.push(`级别${row.levelNumber || '?'}:大盲金额不能为空`);
  493. }
  494. if (row.bigBlind !== null && row.bigBlind !== undefined && row.smallBlind !== null && row.smallBlind !== undefined && row.bigBlind < row.smallBlind) {
  495. errors.push(`级别${row.levelNumber || '?'}:大盲金额不能小于小盲金额`);
  496. }
  497. if (row.durationMinutes === null || row.durationMinutes === undefined || row.durationMinutes < 0) {
  498. errors.push(`级别${row.levelNumber || '?'}:本级别持续时间(分钟)不能为空`);
  499. }
  500. if (errors.length > 0) {
  501. rowErrors.value.set(row.id, errors);
  502. allErrors.push(...errors);
  503. }
  504. });
  505. return allErrors;
  506. };
  507. /** 在指定级别后插入(默认全0,本地插入不刷新列表) */
  508. const handleInsertAfter = async (row: LevelsVO) => {
  509. if (insertLoading.value) return; // 防止重复点击
  510. insertLoading.value = true;
  511. const newLevelNumber = (row.levelNumber || 0) + 1;
  512. const newLevel: LevelsForm = {
  513. id: undefined,
  514. blindStructureId: currentId.value as string | number,
  515. levelNumber: newLevelNumber,
  516. smallBlind: 0,
  517. bigBlind: 0,
  518. ante: 0,
  519. durationMinutes: 0
  520. };
  521. try {
  522. await insertAfter(newLevel, row.levelNumber);
  523. // ✅ 关键修复:插入新级别后,将后续所有记录的levelNumber+1,避免编号重复
  524. levelsList.value.forEach((item) => {
  525. if (item.levelNumber >= newLevelNumber && item.id !== row.id) {
  526. item.levelNumber += 1;
  527. }
  528. });
  529. // 本地插入新行到正确位置
  530. const idx = levelsList.value.findIndex((r) => r.id === row.id);
  531. if (idx !== -1) {
  532. levelsList.value.splice(idx + 1, 0, { ...newLevel, id: Date.now(), _isNew: true } as any);
  533. sortListByLevelNumber();
  534. }
  535. proxy?.$modal.msgSuccess(`成功在第${row.levelNumber}级后插入第${newLevelNumber}级`);
  536. } catch (error) {
  537. proxy?.$modal.msgError('插入失败,请重试');
  538. console.error('Insert after error:', error);
  539. } finally {
  540. insertLoading.value = false;
  541. }
  542. };
  543. /** 批量保存(先拉取全部数据,合并当前页编辑后一起提交) */
  544. const handleBatchSave = async () => {
  545. if (!levelsList.value || levelsList.value.length === 0) {
  546. proxy?.$modal.msgError('没有数据可保存');
  547. return;
  548. }
  549. // 校验当前页
  550. const errors = validateRows();
  551. if (errors.length > 0) {
  552. proxy?.$modal.msgError(errors[0]);
  553. return;
  554. }
  555. saveLoading.value = true;
  556. try {
  557. // ✅ 关键修复:只收集已存在记录的编辑内容(不包括新插入的临时记录)
  558. // 原因:新插入的记录已经通过 insertAfter() API 在后端创建了,
  559. // 如果再提交会导致重复数据!
  560. const localEdits = new Map<string | number, LevelsVO>();
  561. levelsList.value.forEach((row) => {
  562. if (row.id && !(row as any)._isNew) {
  563. // 只收集已存在的记录,忽略 _isNew 标记的临时记录
  564. localEdits.set(row.id, row);
  565. }
  566. });
  567. // 拉取全部数据(不分页,强制按级别号排序)
  568. const allRes = await listLevels({
  569. ...queryParams.value,
  570. pageNum: 1,
  571. pageSize: 9999999,
  572. orderByColumn: 'levelNumber',
  573. isAsc: 'asc'
  574. });
  575. let allRows: LevelsVO[] = allRes.rows || [];
  576. // 用本地编辑覆盖全量数据中对应的行(包括对新插入记录的编辑)
  577. allRows.forEach((row) => {
  578. if (row.id && localEdits.has(row.id)) {
  579. Object.assign(row, localEdits.get(row.id));
  580. }
  581. });
  582. // ✅ 特殊处理:将用户对新插入记录的编辑合并到后端返回的对应记录上
  583. levelsList.value.forEach((localRow) => {
  584. if ((localRow as any)._isNew && localRow.levelNumber) {
  585. // 找到后端数据中相同 levelNumber 的记录(就是刚才 insertAfter 创建的)
  586. const backendRow = allRows.find(r => r.levelNumber === localRow.levelNumber);
  587. if (backendRow) {
  588. // 将用户的编辑内容合并到后端记录上
  589. Object.assign(backendRow, {
  590. smallBlind: localRow.smallBlind,
  591. bigBlind: localRow.bigBlind,
  592. ante: localRow.ante,
  593. durationMinutes: localRow.durationMinutes
  594. });
  595. }
  596. }
  597. });
  598. // ✅ 保存前按级别号排序,然后重新计算级别:级别号连续递增
  599. allRows.sort((a, b) => (a.levelNumber || 0) - (b.levelNumber || 0));
  600. let expectedLevel = 1;
  601. allRows.forEach((row) => {
  602. row.levelNumber = expectedLevel++;
  603. });
  604. console.log('💾 准备保存的数据:', allRows); // 调试日志
  605. await batchSave(allRows as LevelsForm[]);
  606. proxy?.$modal.msgSuccess('保存成功');
  607. await getList();
  608. } catch (error) {
  609. proxy?.$modal.msgError('保存失败,请重试');
  610. console.error('Batch save error:', error);
  611. } finally {
  612. saveLoading.value = false;
  613. }
  614. };
  615. /** 关闭 */
  616. const handleClose = () => {
  617. // 如果在弹窗中使用,关闭弹窗
  618. const parentDialog = (proxy?.$parent as any)?.$parent;
  619. if (parentDialog && parentDialog.$options?.name !== 'Levels') {
  620. // 尝试找到父级 dialog 并关闭
  621. let el: any = proxy?.$el;
  622. while (el) {
  623. if (el.classList && el.classList.contains('el-dialog')) {
  624. const closeBtn = el.querySelector('.el-dialog__headerbtn');
  625. if (closeBtn) closeBtn.click();
  626. break;
  627. }
  628. el = el.parentElement;
  629. }
  630. }
  631. };
  632. onMounted(() => {
  633. const id = currentId;
  634. if (id !== undefined) {
  635. queryParams.value.blindStructureId = currentId.value;
  636. initFormData.name = currentName.value;
  637. }
  638. getList();
  639. // 将保存/关闭按钮移到父级 dialog 的 footer 区域,使其固定不随滚动
  640. nextTick(() => {
  641. const footerEl = (proxy?.$el as HTMLElement)?.querySelector('.blind-levels-footer') as HTMLElement;
  642. if (!footerEl) return;
  643. let dialogEl: HTMLElement | null = footerEl.closest('.el-dialog');
  644. if (!dialogEl) {
  645. let el: HTMLElement | null = footerEl.parentElement;
  646. while (el) {
  647. if (el.classList?.contains('el-dialog')) {
  648. dialogEl = el;
  649. break;
  650. }
  651. el = el.parentElement;
  652. }
  653. }
  654. if (dialogEl) {
  655. let dialogFooter = dialogEl.querySelector('.el-dialog__footer') as HTMLElement;
  656. if (!dialogFooter) {
  657. dialogFooter = document.createElement('div');
  658. dialogFooter.className = 'el-dialog__footer';
  659. dialogEl.appendChild(dialogFooter);
  660. }
  661. // 清空旧的 footer 内容,避免重复
  662. dialogFooter.innerHTML = '';
  663. dialogFooter.appendChild(footerEl);
  664. footerEl.style.position = '';
  665. footerEl.style.bottom = '';
  666. footerEl.style.background = '';
  667. footerEl.style.borderRadius = '';
  668. footerEl.style.boxShadow = '';
  669. footerEl.style.zIndex = '';
  670. footerEl.style.padding = '10px 0 0';
  671. }
  672. });
  673. });
  674. watch(
  675. () => info(),
  676. (newInfo) => {
  677. if (newInfo.id !== currentId.value || newInfo.name !== currentName.value) {
  678. currentId.value = newInfo.id;
  679. currentName.value = newInfo.name;
  680. initFormData.blindStructureId = newInfo.id;
  681. initFormData.name = newInfo.name;
  682. queryParams.value.blindStructureId = newInfo.id;
  683. if (newInfo.id !== null) {
  684. getList();
  685. }
  686. }
  687. },
  688. { immediate: true }
  689. );
  690. // ✅ 暴露刷新方法给父组件调用
  691. defineExpose({
  692. refreshList: getList
  693. });
  694. </script>
  695. <style scoped>
  696. .blind-levels-container {
  697. height: 100%;
  698. }
  699. .blind-levels-card {
  700. height: 100%;
  701. overflow: auto;
  702. }
  703. .blind-levels-footer {
  704. display: flex;
  705. justify-content: flex-end;
  706. gap: 10px;
  707. padding: 10px 0 0;
  708. }
  709. .stepper-cell {
  710. display: flex;
  711. align-items: center;
  712. justify-content: center;
  713. gap: 4px;
  714. }
  715. .stepper-cell .el-button {
  716. padding: 2px 8px;
  717. font-size: 14px;
  718. min-width: 28px;
  719. color: #606266;
  720. }
  721. .stepper-cell .el-button:hover:not(:disabled) {
  722. color: #409eff;
  723. }
  724. .stepper-cell .el-button:disabled {
  725. color: #c0c4cc;
  726. cursor: not-allowed;
  727. }
  728. .stepper-value {
  729. display: inline-block;
  730. min-width: 60px;
  731. text-align: center;
  732. padding: 4px 12px;
  733. background: #f5f7fa;
  734. border-radius: 4px;
  735. font-size: 14px;
  736. color: #606266;
  737. }
  738. :deep(.row-error) {
  739. background-color: #fef0f0 !important;
  740. }
  741. :deep(.row-error td) {
  742. color: #f56c6c;
  743. }
  744. </style>