Commit 755bba6d by DaiJiezhang

Refine phone image handling flow

parent 1b367d0d
package com.xyw.console.phone.entity; package com.xyw.console.phone.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
...@@ -25,10 +26,10 @@ public class WxPhoneEntity { ...@@ -25,10 +26,10 @@ public class WxPhoneEntity {
@TableField("city") @TableField("city")
private String city; private String city;
@TableField("image_attachment_1") @TableField(value = "image_attachment_1", updateStrategy = FieldStrategy.ALWAYS)
private String imageAttachment1; private String imageAttachment1;
@TableField("image_attachment_2") @TableField(value = "image_attachment_2", updateStrategy = FieldStrategy.ALWAYS)
private String imageAttachment2; private String imageAttachment2;
@TableField("card_status") @TableField("card_status")
......
...@@ -2,6 +2,18 @@ package com.xyw.console.phone.mapper; ...@@ -2,6 +2,18 @@ package com.xyw.console.phone.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xyw.console.phone.entity.WxPhoneEntity; import com.xyw.console.phone.entity.WxPhoneEntity;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
public interface WxPhoneMapper extends BaseMapper<WxPhoneEntity> { public interface WxPhoneMapper extends BaseMapper<WxPhoneEntity> {
/**
* 显式更新图片字段,确保 null 值能写入 DB。
* FieldStrategy.ALWAYS 在自定义 SqlSessionFactory 下可能不生效,
* 这里用原生 SQL 绕过,保证删除图片时字段被置为 NULL。
*/
@Update("UPDATE wx_phone SET image_attachment_1 = #{imageAttachment1}, image_attachment_2 = #{imageAttachment2} WHERE id = #{id}")
int updateImages(@Param("id") Long id,
@Param("imageAttachment1") String imageAttachment1,
@Param("imageAttachment2") String imageAttachment2);
} }
...@@ -6,8 +6,10 @@ import com.xyw.console.phone.entity.WxPhoneEntity; ...@@ -6,8 +6,10 @@ import com.xyw.console.phone.entity.WxPhoneEntity;
import com.xyw.console.phone.mapper.WxPhoneMapper; import com.xyw.console.phone.mapper.WxPhoneMapper;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class WxPhoneService { public class WxPhoneService {
...@@ -41,13 +43,44 @@ public class WxPhoneService { ...@@ -41,13 +43,44 @@ public class WxPhoneService {
/** /**
* 浠g爜浣滅敤锛堢櫧璇濓級锛氭洿鏂版寚瀹氭墜鏈哄彿鍗★紝骞舵妸鏁版嵁搴撻噷鐨勬渶鏂拌褰曡繑鍥炵粰鍓嶇銆? * 鍏宠仈鏂囦欢锛欶:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/controller/WxPhoneController.java銆丗:/Project/xyw_console/src/modules/phone/phone-add-dialog.js銆? * 鍏宠仈閫昏緫锛堣皟鐢ㄩ摼/娑堟伅閾?鏁版嵁娴侊級锛歅UT /api/wx-phones/{id} -> updateRecord() -> Mapper.updateById() -> 鍓嶇鍒锋柊鍒楄〃銆? */ * 浠g爜浣滅敤锛堢櫧璇濓級锛氭洿鏂版寚瀹氭墜鏈哄彿鍗★紝骞舵妸鏁版嵁搴撻噷鐨勬渶鏂拌褰曡繑鍥炵粰鍓嶇銆? * 鍏宠仈鏂囦欢锛欶:/Project/xyw_console/backend/src/main/java/com/xyw/console/phone/controller/WxPhoneController.java銆丗:/Project/xyw_console/src/modules/phone/phone-add-dialog.js銆? * 鍏宠仈閫昏緫锛堣皟鐢ㄩ摼/娑堟伅閾?鏁版嵁娴侊級锛歅UT /api/wx-phones/{id} -> updateRecord() -> Mapper.updateById() -> 鍓嶇鍒锋柊鍒楄〃銆? */
public WxPhoneEntity updateRecord(Long id, WxPhoneSaveRequest request) { public WxPhoneEntity updateRecord(Long id, WxPhoneSaveRequest request) {
log.info("更新请求:id={}, request.imageAttachment1={}, request.imageAttachment2={}",
id,
request.getImageAttachment1() == null ? "null" : "长度=" + request.getImageAttachment1().length(),
request.getImageAttachment2() == null ? "null" : "长度=" + request.getImageAttachment2().length());
WxPhoneEntity entity = wxPhoneMapper.selectById(id); WxPhoneEntity entity = wxPhoneMapper.selectById(id);
if (entity == null) { if (entity == null) {
log.warn("更新手机号卡失败:未找到 id={}", id);
return null; return null;
} }
String oldImage1 = entity.getImageAttachment1();
String oldImage2 = entity.getImageAttachment2();
copyRequestToEntity(request, entity); copyRequestToEntity(request, entity);
wxPhoneMapper.updateById(entity); String newImage1 = entity.getImageAttachment1();
return normalizeEntity(wxPhoneMapper.selectById(id)); String newImage2 = entity.getImageAttachment2();
log.info("复制后:id={}, entity.imageAttachment1={}, entity.imageAttachment2={}",
id,
newImage1 == null ? "null" : "长度=" + newImage1.length(),
newImage2 == null ? "null" : "长度=" + newImage2.length());
if (oldImage1 != null && newImage1 == null) {
log.info("删除图片:id={}, 字段=image_attachment_1, 旧值长度={}", id, oldImage1.length());
}
if (oldImage2 != null && newImage2 == null) {
log.info("删除图片:id={}, 字段=image_attachment_2, 旧值长度={}", id, oldImage2.length());
}
int updatedRows = wxPhoneMapper.updateById(entity);
log.info("updateById 返回行数={}, id={}", updatedRows, id);
// 显式更新图片字段,绕过 FieldStrategy.ALWAYS 可能不生效的问题,确保 null 值写入 DB
int imageRows = wxPhoneMapper.updateImages(id, newImage1, newImage2);
log.info("updateImages 返回行数={}, id={}, image1={}, image2={}",
imageRows, id,
newImage1 == null ? "null" : "长度=" + newImage1.length(),
newImage2 == null ? "null" : "长度=" + newImage2.length());
WxPhoneEntity updated = wxPhoneMapper.selectById(id);
log.info("更新完成:id={}, image_attachment_1={}, image_attachment_2={}",
id,
updated.getImageAttachment1() != null ? "存在(长度=" + updated.getImageAttachment1().length() + ")" : "null",
updated.getImageAttachment2() != null ? "存在(长度=" + updated.getImageAttachment2().length() + ")" : "null");
return normalizeEntity(updated);
} }
/** /**
......
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
<title>学有为资产管理台</title> <title>学有为资产管理台</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230f766e'/%3E%3Ctext x='50%25' y='55%25' text-anchor='middle' font-size='26' font-family='Arial' fill='white'%3EXY%3C/text%3E%3C/svg%3E" /> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230f766e'/%3E%3Ctext x='50%25' y='55%25' text-anchor='middle' font-size='26' font-family='Arial' fill='white'%3EXY%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="node_modules/element-plus/dist/index.css" /> <link rel="stylesheet" href="node_modules/element-plus/dist/index.css" />
<link rel="stylesheet" href="styles-v2.css" /> <link rel="stylesheet" href="styles-v2.css?v=20260707" />
</head> </head>
<body> <body>
...@@ -91,8 +91,8 @@ ...@@ -91,8 +91,8 @@
<script src="src/modules/wechat/wechat-list-runtime.js"></script> <script src="src/modules/wechat/wechat-list-runtime.js"></script>
<script src="src/modules/phone/phone-add-dialog.js"></script> <script src="src/modules/phone/phone-add-dialog.js"></script>
<script src="src/modules/phone/phone-detail-drawer.js"></script> <script src="src/modules/phone/phone-detail-drawer.js"></script>
<script src="src/modules/phone/phone-image-cell.js"></script> <script src="src/modules/phone/phone-image-cell.js?v=20260707"></script>
<script src="src/modules/phone/phone-list-runtime.js"></script> <script src="src/modules/phone/phone-list-runtime.js?v=20260707"></script>
<script src="src/modules/shared/record-adapters.js"></script> <script src="src/modules/shared/record-adapters.js"></script>
<script src="src/modules/shared/wechat-api-client.js"></script> <script src="src/modules/shared/wechat-api-client.js"></script>
<script src="src/modules/shared/phone-api-client.js"></script> <script src="src/modules/shared/phone-api-client.js"></script>
......
(function attachPhoneImageCell() { (function attachPhoneImageCell() {
const { ref } = window.Vue; const { ref } = window.Vue;
/**
* 代码作用(白话):手机号卡列表图片列的图片单元组件,负责缩略图展示、占位图回退、预览与删除悬浮操作;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):PhoneListTable 图片列 -> <phone-card-image-cell> -> 预览/删除回调 -> 列表刷新。
*/
function handleImageError(event) { function handleImageError(event) {
const image = event?.target; const image = event?.target;
if (!image) { if (!image) { return; }
return;
}
image.style.display = 'none'; image.style.display = 'none';
const placeholder = image.nextElementSibling; const placeholder = image.nextElementSibling;
if (placeholder) { if (placeholder) { placeholder.style.display = 'flex'; }
placeholder.style.display = 'flex';
}
} }
const PhoneCardImageCell = { const PhoneCardImageCell = {
...@@ -21,76 +14,52 @@ ...@@ -21,76 +14,52 @@
props: { props: {
images: { type: Array, default: () => [] }, images: { type: Array, default: () => [] },
recordId: { type: [String, Number], default: null }, recordId: { type: [String, Number], default: null },
clearImage: { type: Function, default: null } clearImage: { type: Function, default: null },
uploadImage: { type: Function, default: null }
}, },
setup(props) { setup(props) {
const previewVisible = ref(false); const previewVisible = ref(false);
const previewIndex = ref(0); const previewIndex = ref(0);
const imageInputRef = ref(null);
const uploading = ref(false);
function openPreview(index) { function openPreview(index) { previewIndex.value = index; previewVisible.value = true; }
previewIndex.value = index; function closePreview() { previewVisible.value = false; }
previewVisible.value = true; function handleDelete(index) { if (typeof props.clearImage === 'function') { props.clearImage(index); } }
}
function closePreview() { function triggerUpload() {
previewVisible.value = false; if (uploading.value) { return; }
if (typeof props.uploadImage !== 'function') { return; }
imageInputRef.value?.click();
} }
function handleDelete(index) { async function handleFileChange(event) {
if (typeof props.clearImage === 'function') { const input = event?.target;
props.clearImage(index); const files = Array.from(input?.files || []);
} if (!files.length) { if (input) { input.value = ''; } return; }
uploading.value = true;
try { await props.uploadImage(files); } finally { uploading.value = false; if (input) { input.value = ''; } }
} }
return { return { previewVisible, previewIndex, imageInputRef, openPreview, closePreview, handleDelete, handleImageError, triggerUpload, handleFileChange };
previewVisible,
previewIndex,
openPreview,
closePreview,
handleDelete,
handleImageError
};
}, },
template: ` template: `
<div class="phone-image-cell"> <div class="phone-image-cell" @click.stop @mousedown.stop @dblclick.stop>
<input ref="imageInputRef" type="file" accept="image/*" multiple class="phone-image-input" @change="handleFileChange" style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;" />
<template v-if="images && images.length"> <template v-if="images && images.length">
<div <div v-for="(image, index) in images" :key="(recordId ?? 'row') + '-img-' + index" class="phone-image-thumb" @click.stop @mousedown.stop @dblclick.stop>
v-for="(image, index) in images" <img :src="image" alt="thumbnail" class="phone-image-img" @error="handleImageError" />
:key="(recordId ?? 'row') + '-img-' + index" <div class="phone-image-fallback">双击上传</div>
class="phone-image-thumb"
>
<img :src="image" alt="号卡图片缩略图" class="phone-image-img" @error="handleImageError" />
<div class="phone-image-fallback">占位图</div>
<div class="phone-image-actions" @click.stop @mousedown.stop @dblclick.stop> <div class="phone-image-actions" @click.stop @mousedown.stop @dblclick.stop>
<button <button type="button" class="phone-image-action phone-image-action--preview" title="预览" @click.stop="openPreview(index)" @mousedown.stop @dblclick.stop><svg viewBox="0 0 1024 1024" width="16" height="16"><path fill="currentColor" d="M515.2 224c-307.2 0-492.8 313.6-492.8 313.6s214.4 304 492.8 304 492.8-304 492.8-304S822.4 224 515.2 224zM832 652.8c-102.4 86.4-211.2 140.8-320 140.8s-217.6-51.2-320-140.8c-35.2-32-70.4-64-99.2-99.2-6.4-6.4-9.6-12.8-16-19.2 3.2-6.4 9.6-12.8 12.8-19.2 25.6-35.2 57.6-70.4 92.8-102.4 99.2-89.6 208-144 329.6-144s230.4 54.4 329.6 144c35.2 32 64 67.2 92.8 102.4 3.2 6.4 9.6 12.8 12.8 19.2-3.2 6.4-9.6 12.8-16 19.2C902.4 585.6 870.4 620.8 832 652.8z"/><path fill="currentColor" d="M512 345.6c-96 0-169.6 76.8-169.6 169.6 0 96 76.8 169.6 169.6 169.6 96 0 169.6-76.8 169.6-169.6C681.6 422.4 604.8 345.6 512 345.6zM512 640c-67.2 0-121.6-54.4-121.6-121.6 0-67.2 54.4-121.6 121.6-121.6 67.2 0 121.6 54.4 121.6 121.6C633.6 582.4 579.2 640 512 640z"/></svg></button>
type="button" <button type="button" class="phone-image-action phone-image-action--delete" title="删除" @click.stop="handleDelete(index)" @mousedown.stop @dblclick.stop><svg viewBox="0 0 1024 1024" width="16" height="16"><path fill="currentColor" d="M709.469091 209.454545H930.909091a34.909091 34.909091 0 0 1 0 69.818182h-81.454546v607.185455c0 56.366545-44.311273 102.632727-99.746909 102.632727H274.292364c-55.435636 0-99.746909-46.266182-99.746909-102.632727V279.272727H93.090909a34.909091 34.909091 0 0 1 0-69.818182h244.712727a186.181818 186.181818 0 0 1 371.665455 0z m-70.050909 0a116.363636 116.363636 0 0 0-231.563637 0h231.563637z m140.218182 69.818182h-535.272728v607.185455c0 18.455273 13.730909 32.814545 29.928728 32.814545h475.415272c16.174545 0 29.928727-14.359273 29.928728-32.814545V279.272727z m-418.909091 147.2a34.909091 34.909091 0 0 1 69.818182 0v338.897455a34.909091 34.909091 0 0 1-69.818182 0V426.472727z m232.727272 0a34.909091 34.909091 0 0 1 69.818182 0v338.897455a34.909091 34.909091 0 0 1-69.818182 0V426.472727z"/></svg></button>
class="phone-image-action phone-image-action--preview"
title="预览图片"
@click.stop="openPreview(index)"
@mousedown.stop
><svg viewBox="0 0 1024 1024" width="16" height="16"><path fill="currentColor" d="M515.2 224c-307.2 0-492.8 313.6-492.8 313.6s214.4 304 492.8 304 492.8-304 492.8-304S822.4 224 515.2 224zM832 652.8c-102.4 86.4-211.2 140.8-320 140.8s-217.6-51.2-320-140.8c-35.2-32-70.4-64-99.2-99.2-6.4-6.4-9.6-12.8-16-19.2 3.2-6.4 9.6-12.8 12.8-19.2 25.6-35.2 57.6-70.4 92.8-102.4 99.2-89.6 208-144 329.6-144s230.4 54.4 329.6 144c35.2 32 64 67.2 92.8 102.4 3.2 6.4 9.6 12.8 12.8 19.2-3.2 6.4-9.6 12.8-16 19.2C902.4 585.6 870.4 620.8 832 652.8z"/><path fill="currentColor" d="M512 345.6c-96 0-169.6 76.8-169.6 169.6 0 96 76.8 169.6 169.6 169.6 96 0 169.6-76.8 169.6-169.6C681.6 422.4 604.8 345.6 512 345.6zM512 640c-67.2 0-121.6-54.4-121.6-121.6 0-67.2 54.4-121.6 121.6-121.6 67.2 0 121.6 54.4 121.6 121.6C633.6 582.4 579.2 640 512 640z"/></svg></button>
<button
type="button"
class="phone-image-action phone-image-action--delete"
title="删除图片"
@click.stop="handleDelete(index)"
@mousedown.stop
><svg viewBox="0 0 1024 1024" width="16" height="16"><path fill="currentColor" d="M709.469091 209.454545H930.909091a34.909091 34.909091 0 0 1 0 69.818182h-81.454546v607.185455c0 56.366545-44.311273 102.632727-99.746909 102.632727H274.292364c-55.435636 0-99.746909-46.266182-99.746909-102.632727V279.272727H93.090909a34.909091 34.909091 0 0 1 0-69.818182h244.712727a186.181818 186.181818 0 0 1 371.665455 0z m-70.050909 0a116.363636 116.363636 0 0 0-231.563637 0h231.563637z m140.218182 69.818182h-535.272728v607.185455c0 18.455273 13.730909 32.814545 29.928728 32.814545h475.415272c16.174545 0 29.928727-14.359273 29.928728-32.814545V279.272727z m-418.909091 147.2a34.909091 34.909091 0 0 1 69.818182 0v338.897455a34.909091 34.909091 0 0 1-69.818182 0V426.472727z m232.727272 0a34.909091 34.909091 0 0 1 69.818182 0v338.897455a34.909091 34.909091 0 0 1-69.818182 0V426.472727z"/></svg></button>
</div> </div>
</div> </div>
</template> </template>
<div v-else class="phone-image-thumb phone-image-thumb--empty"> <div v-else class="phone-image-thumb phone-image-thumb--empty" title="双击上传" @dblclick.stop="triggerUpload" @mousedown.stop @click.stop>
<span class="phone-image-fallback">占位图</span> <span class="phone-image-fallback">双击上传</span>
</div> </div>
<el-image-viewer <el-image-viewer v-if="previewVisible" :url-list="images" :initial-index="previewIndex" :z-index="3000" :hide-on-click-modal="true" teleported @close="closePreview" />
v-if="previewVisible"
:url-list="images"
:initial-index="previewIndex"
:z-index="3000"
:hide-on-click-modal="true"
teleported
@close="closePreview"
/>
</div> </div>
` `
}; };
......
...@@ -86,6 +86,94 @@ ...@@ -86,6 +86,94 @@
const multipleTableRef = ref(null); const multipleTableRef = ref(null);
/** /**
* 代码作用(白话):内联编辑状态,记录当前正在编辑的单元格 { recordId, field, value },同一时刻只允许一个单元格处于编辑态;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):双击单元格 -> handleCellDblclick() -> editingCell -> 渲染输入控件 -> 失焦/回车 -> handleInlineSave() -> buildSavePayload() -> props.savePhoneRecord() -> loadAllRecords()。
*/
const editingCell = ref(null);
/**
* 代码作用(白话):定义哪些字段支持双击内联编辑以及对应的控件类型,避免模板里写死判断逻辑;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):handleCellDblclick() -> isFieldEditable() -> 渲染 input/select/switch。
*/
const editableFields = {
realPerson: { type: 'input', placeholder: '请输入实名人' },
iccid: { type: 'input', placeholder: '请输入 ICCID' },
city: { type: 'input', placeholder: '请输入城市' },
cardStatus: { type: 'select', options: ['正常', '异常停机', '保号中'] },
carrier: { type: 'select', options: ['中国移动', '中国联通', '中国电信', '中国广电', '亮哥渠道(虚拟)'] },
outbound: { type: 'select', options: ['可外呼', '不可外呼'] },
douyinAccount: { type: 'input', placeholder: '请输入抖音账号' },
linkedWecom: { type: 'input', placeholder: '请输入绑定企业微信' },
channelOperator: { type: 'input', placeholder: '请输入渠道运营商' }
};
/**
* 代码作用(白话):判断某个字段是否允许双击内联编辑;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):模板 @dblclick -> isFieldEditable() -> 决定是否进入编辑态。
*/
function isFieldEditable(field) {
return Object.prototype.hasOwnProperty.call(editableFields, field);
}
/**
* 代码作用(白话):双击单元格时进入编辑态,把当前值暂存到 editingCell,阻止事件冒泡避免触发行选中;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):@dblclick.stop -> handleCellDblclick() -> editingCell = { recordId, field, value }。
*/
function handleCellDblclick(record, field) {
if (!isFieldEditable(field)) {
return;
}
let value;
if (field === 'outbound') {
value = record.outboundCall ? '可外呼' : '不可外呼';
} else {
value = record[field] ?? '';
}
editingCell.value = {
recordId: record.id,
field,
value: typeof value === 'boolean' ? value : String(value)
};
}
/**
* 代码作用(白话):内联编辑确认后构造完整保存载荷并调用保存接口,成功后刷新列表并退出编辑态;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):回车/失焦 -> handleInlineSave() -> buildSavePayload() -> props.savePhoneRecord() -> loadAllRecords() -> editingCell = null。
*/
async function handleInlineSave() {
if (!editingCell.value) {
return;
}
const { recordId, field, value } = editingCell.value;
const record = allRecordsCache.value.find((item) => item.id === recordId);
if (!record) {
editingCell.value = null;
return;
}
const updatedRecord = { ...record };
if (field === 'outbound') {
updatedRecord.outboundCall = value === '可外呼';
} else {
updatedRecord[field] = value;
}
const images = getDisplayImages(record);
const payload = buildSavePayload(updatedRecord, images);
try {
await props.savePhoneRecord(payload);
if (window.ElementPlus?.ElMessage) {
window.ElementPlus.ElMessage.success('修改成功');
}
await loadAllRecords();
} catch (error) {
props.notifyError(error.message || '保存失败,请稍后重试');
} finally {
editingCell.value = null;
}
}
/**
* 代码作用(白话):取消内联编辑,直接退出编辑态不保存;关联文件:F:/Project/xyw_console/src/modules/phone/phone-list-runtime.js;关联逻辑(调用链/消息链/数据流):Esc/点击外部 -> handleInlineCancel() -> editingCell = null。
*/
function handleInlineCancel() {
editingCell.value = null;
}
/**
* 代码作用(白话):从当前缓存里提取运营商选项,避免筛选下拉框写死,后端换数据时前端还能跟着显示;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):loadAllRecords() -> allRecordsCache -> getCarrierOptions() -> 运营商筛选下拉。 * 代码作用(白话):从当前缓存里提取运营商选项,避免筛选下拉框写死,后端换数据时前端还能跟着显示;关联文件:F:/Project/xyw_console/app-v2.js;关联逻辑(调用链/消息链/数据流):loadAllRecords() -> allRecordsCache -> getCarrierOptions() -> 运营商筛选下拉。
*/ */
function getCarrierOptions() { function getCarrierOptions() {
...@@ -349,20 +437,95 @@ ...@@ -349,20 +437,95 @@
if (!window.confirm('确认删除这张图片吗?')) { if (!window.confirm('确认删除这张图片吗?')) {
return; return;
} }
const remaining = getDisplayImages(record).filter((_, idx) => idx !== imageIndex); // 优先从缓存取最新记录,避免 scope.row 是旧引用导致图片数据不一致
const payload = buildSavePayload(record, remaining); const latestRecord = allRecordsCache.value.find((item) => item.id === record.id) || record;
const currentImages = getDisplayImages(latestRecord);
const updatedImages = currentImages.slice();
updatedImages[imageIndex] = '';
const payload = buildSavePayload(latestRecord, updatedImages);
const expectedCount = Math.max(0, currentImages.filter(Boolean).length - 1);
console.info('[phone] 删除图片请求:id=', latestRecord.id, '索引=', imageIndex,
'当前图片数=', currentImages.filter(Boolean).length,
'payload.imageAttachment1 长度=', (payload.imageAttachment1 || '').length,
'payload.imageAttachment2 长度=', (payload.imageAttachment2 || '').length);
try { try {
await props.savePhoneRecord(payload); const saved = await props.savePhoneRecord(payload);
const savedImages = [
saved?.imageAttachment1 || saved?.image_attachment_1,
saved?.imageAttachment2 || saved?.image_attachment_2
].filter(Boolean);
if (savedImages.length > expectedCount) {
console.error('[phone] 图片删除验证失败:id=', latestRecord.id, '预期剩余', expectedCount, '张,实际', savedImages.length, '张',
'saved.imageAttachment1 长度=', (saved?.imageAttachment1 || saved?.image_attachment_1 || '').length,
'saved.imageAttachment2 长度=', (saved?.imageAttachment2 || saved?.image_attachment_2 || '').length);
props.notifyError('图片删除失败:服务端未清除图片数据,请稍后重试');
await loadAllRecords();
return;
}
console.info('[phone] 图片删除成功:id=', latestRecord.id, '索引=', imageIndex, '剩余', savedImages.length, '张');
if (window.ElementPlus?.ElMessage) { if (window.ElementPlus?.ElMessage) {
window.ElementPlus.ElMessage.success('图片已删除'); window.ElementPlus.ElMessage.success('图片已删除');
} }
await loadAllRecords(); await loadAllRecords();
} catch (error) { } catch (error) {
console.error('[phone] 删除图片失败:', error);
props.notifyError(error.message || '删除图片失败,请稍后重试'); props.notifyError(error.message || '删除图片失败,请稍后重试');
} }
} }
/** /**
* 代码作用(白话):把本地图片文件读成 base64 DataURL,供列表内联上传直接复用现有保存接口;关联文件:F:/Project/xyw_console/src/modules/phone/phone-add-dialog.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):File -> readFileAsDataUrl() -> handleInlineImageUpload() -> buildSavePayload() -> props.savePhoneRecord()。
*/
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
reader.onerror = () => reject(new Error('读取图片失败,请重新选择'));
reader.readAsDataURL(file);
});
}
/**
* 代码作用(白话):列表空占位框双击后接收用户选择的图片文件,最多保留 2 张,复用全量保存接口写入 imageAttachment1/2,并从后端刷新缓存以保证后续删除能拿到真实 URL;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-image-cell.js、F:/Project/xyw_console/src/modules/shared/phone-api-client.js;关联逻辑(调用链/消息链/数据流):phone-image-cell handleFileChange() -> handleInlineImageUpload() -> readFileAsDataUrl() -> buildSavePayload() -> props.savePhoneRecord() -> loadAllRecords()。
*/
async function handleInlineImageUpload(record, files) {
if (!record || !Array.isArray(files) || !files.length) {
return;
}
// 优先从缓存取最新记录,避免 scope.row 是旧引用导致已有图片丢失
const latestRecord = allRecordsCache.value.find((item) => item.id === record.id) || record;
const existing = getDisplayImages(latestRecord).slice();
const remainSlots = Math.max(0, 2 - existing.length);
if (remainSlots <= 0) {
if (window.ElementPlus?.ElMessage) {
window.ElementPlus.ElMessage.warning('最多上传 2 张图片');
}
return;
}
const picked = files.slice(0, remainSlots);
try {
const nextImages = existing.slice();
for (let index = 0; index < picked.length; index += 1) {
const url = await readFileAsDataUrl(picked[index]);
if (url) {
nextImages.push(url);
}
}
if (!nextImages.length) {
return;
}
const payload = buildSavePayload(latestRecord, nextImages.slice(0, 2));
await props.savePhoneRecord(payload);
if (window.ElementPlus?.ElMessage) {
window.ElementPlus.ElMessage.success('图片已上传');
}
await loadAllRecords();
} catch (error) {
props.notifyError(error.message || '上传图片失败,请稍后重试');
}
}
/**
* 代码作用(白话):把某一行设为当前选中手机号,并同步到壳层全局状态,方便详情抽屉和编辑弹窗拿到当前对象;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-detail-drawer.js;关联逻辑(调用链/消息链/数据流):点击表格手机号/操作按钮 -> focusRow() -> globalState.selectedBySource.phone -> openDrawer()/详情联动。 * 代码作用(白话):把某一行设为当前选中手机号,并同步到壳层全局状态,方便详情抽屉和编辑弹窗拿到当前对象;关联文件:F:/Project/xyw_console/app-v2.js、F:/Project/xyw_console/src/modules/phone/phone-detail-drawer.js;关联逻辑(调用链/消息链/数据流):点击表格手机号/操作按钮 -> focusRow() -> globalState.selectedBySource.phone -> openDrawer()/详情联动。
*/ */
function focusRow(recordId) { function focusRow(recordId) {
...@@ -473,6 +636,13 @@ ...@@ -473,6 +636,13 @@
getDisplayImages, getDisplayImages,
handleImageError, handleImageError,
handleClearImage, handleClearImage,
handleInlineImageUpload,
editingCell,
editableFields,
isFieldEditable,
handleCellDblclick,
handleInlineSave,
handleInlineCancel,
focusRow, focusRow,
resetTableState, resetTableState,
handleRowAction, handleRowAction,
...@@ -583,6 +753,7 @@ ...@@ -583,6 +753,7 @@
:images="getDisplayImages(scope.row)" :images="getDisplayImages(scope.row)"
:record-id="scope.row.id" :record-id="scope.row.id"
:clear-image="(imageIndex) => handleClearImage(scope.row, imageIndex)" :clear-image="(imageIndex) => handleClearImage(scope.row, imageIndex)"
:upload-image="(files) => handleInlineImageUpload(scope.row, files)"
/> />
</template> </template>
</el-table-column> </el-table-column>
...@@ -593,23 +764,37 @@ ...@@ -593,23 +764,37 @@
</el-table-column> </el-table-column>
<el-table-column label="实名人" min-width="150"> <el-table-column label="实名人" min-width="150">
<template #default="scope"> <template #default="scope">
<span :title="scope.row.realPerson || '-'" style="white-space: nowrap;">{{ scope.row.realPerson || '-' }}</span> <template v-if="editingCell && editingCell.recordId === scope.row.id && editingCell.field === 'realPerson'">
<el-input v-model="editingCell.value" size="small" class="phone-inline-edit-cell" :placeholder="editableFields.realPerson.placeholder" @blur="handleInlineSave" @keyup.enter="handleInlineSave" @keyup.escape="handleInlineCancel" @click.stop />
</template>
<span v-else :title="scope.row.realPerson || '双击编辑'" style="white-space: nowrap; cursor: text;" @dblclick.stop="handleCellDblclick(scope.row, 'realPerson')">{{ scope.row.realPerson || '双击编辑' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="ICCID" min-width="220"> <el-table-column label="ICCID" min-width="220">
<template #default="scope"> <template #default="scope">
<span :title="scope.row.iccid || '-'" style="white-space: nowrap;">{{ scope.row.iccid || '-' }}</span> <template v-if="editingCell && editingCell.recordId === scope.row.id && editingCell.field === 'iccid'">
<el-input v-model="editingCell.value" size="small" class="phone-inline-edit-cell" :placeholder="editableFields.iccid.placeholder" @blur="handleInlineSave" @keyup.enter="handleInlineSave" @keyup.escape="handleInlineCancel" @click.stop />
</template>
<span v-else :title="scope.row.iccid || '双击编辑'" style="white-space: nowrap; cursor: text;" @dblclick.stop="handleCellDblclick(scope.row, 'iccid')">{{ scope.row.iccid || '双击编辑' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="所在城市" min-width="140"> <el-table-column label="所在城市" min-width="140">
<template #default="scope"> <template #default="scope">
<span :title="scope.row.city || '-'" style="white-space: nowrap;">{{ scope.row.city || '-' }}</span> <template v-if="editingCell && editingCell.recordId === scope.row.id && editingCell.field === 'city'">
<el-input v-model="editingCell.value" size="small" class="phone-inline-edit-cell" :placeholder="editableFields.city.placeholder" @blur="handleInlineSave" @keyup.enter="handleInlineSave" @keyup.escape="handleInlineCancel" @click.stop />
</template>
<span v-else :title="scope.row.city || '双击编辑'" style="white-space: nowrap; cursor: text;" @dblclick.stop="handleCellDblclick(scope.row, 'city')">{{ scope.row.city || '双击编辑' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="号卡状态" min-width="120"> <el-table-column label="号卡状态" min-width="120">
<template #default="scope"> <template #default="scope">
<span class="status-chip-wrap"> <template v-if="editingCell && editingCell.recordId === scope.row.id && editingCell.field === 'cardStatus'">
<el-select v-model="editingCell.value" size="small" class="phone-inline-edit-cell" @change="handleInlineSave" @click.stop>
<el-option v-for="opt in editableFields.cardStatus.options" :key="opt" :label="opt" :value="opt"></el-option>
</el-select>
</template>
<span v-else class="status-chip-wrap" style="cursor: pointer;" @dblclick.stop="handleCellDblclick(scope.row, 'cardStatus')">
<span class="status-dot" :class="getStatusClass(scope.row.cardStatus || scope.row.card_status || scope.row.status)"></span> <span class="status-dot" :class="getStatusClass(scope.row.cardStatus || scope.row.card_status || scope.row.status)"></span>
<span>{{ scope.row.cardStatus || scope.row.card_status || scope.row.status }}</span> <span>{{ scope.row.cardStatus || scope.row.card_status || scope.row.status }}</span>
</span> </span>
...@@ -621,11 +806,21 @@ ...@@ -621,11 +806,21 @@
<template #default="scope">{{ formatBooleanText(scope.row.wechat, '已开通', '未开通') }}</template> <template #default="scope">{{ formatBooleanText(scope.row.wechat, '已开通', '未开通') }}</template>
</el-table-column> </el-table-column>
<el-table-column label="可外呼" min-width="100"> <el-table-column label="可外呼" min-width="100">
<template #default="scope">{{ formatBooleanText(scope.row.outboundCall, '可外呼', '不可外呼') }}</template> <template #default="scope">
<template v-if="editingCell && editingCell.recordId === scope.row.id && editingCell.field === 'outbound'">
<el-select v-model="editingCell.value" size="small" class="phone-inline-edit-cell" @change="handleInlineSave" @click.stop>
<el-option v-for="opt in editableFields.outbound.options" :key="opt" :label="opt" :value="opt"></el-option>
</el-select>
</template>
<span v-else style="cursor: pointer;" @dblclick.stop="handleCellDblclick(scope.row, 'outbound')">{{ formatBooleanText(scope.row.outboundCall, '可外呼', '不可外呼') }}</span>
</template>
</el-table-column> </el-table-column>
<el-table-column label="抖音账号" min-width="180"> <el-table-column label="抖音账号" min-width="180">
<template #default="scope"> <template #default="scope">
<span :title="scope.row.douyinAccount || '-'" style="white-space: nowrap;">{{ scope.row.douyinAccount || '-' }}</span> <template v-if="editingCell && editingCell.recordId === scope.row.id && editingCell.field === 'douyinAccount'">
<el-input v-model="editingCell.value" size="small" class="phone-inline-edit-cell" :placeholder="editableFields.douyinAccount.placeholder" @blur="handleInlineSave" @keyup.enter="handleInlineSave" @keyup.escape="handleInlineCancel" @click.stop />
</template>
<span v-else :title="scope.row.douyinAccount || '双击编辑'" style="white-space: nowrap; cursor: text;" @dblclick.stop="handleCellDblclick(scope.row, 'douyinAccount')">{{ scope.row.douyinAccount || '双击编辑' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="小程序备案" min-width="120"> <el-table-column label="小程序备案" min-width="120">
...@@ -634,7 +829,10 @@ ...@@ -634,7 +829,10 @@
<el-table-column label="渠道运营商" min-width="170"> <el-table-column label="渠道运营商" min-width="170">
<template #default="scope"> <template #default="scope">
<span :title="scope.row.channelOperator || '-'" style="white-space: nowrap;">{{ scope.row.channelOperator || '-' }}</span> <template v-if="editingCell && editingCell.recordId === scope.row.id && editingCell.field === 'channelOperator'">
<el-input v-model="editingCell.value" size="small" class="phone-inline-edit-cell" :placeholder="editableFields.channelOperator.placeholder" @blur="handleInlineSave" @keyup.enter="handleInlineSave" @keyup.escape="handleInlineCancel" @click.stop />
</template>
<span v-else :title="scope.row.channelOperator || '双击编辑'" style="white-space: nowrap; cursor: text;" @dblclick.stop="handleCellDblclick(scope.row, 'channelOperator')">{{ scope.row.channelOperator || '双击编辑' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="号码状态" min-width="120"> <el-table-column label="号码状态" min-width="120">
...@@ -642,7 +840,10 @@ ...@@ -642,7 +840,10 @@
</el-table-column> </el-table-column>
<el-table-column label="绑定企业微信" min-width="260"> <el-table-column label="绑定企业微信" min-width="260">
<template #default="scope"> <template #default="scope">
<span :title="scope.row.linkedWecom || '-'" style="white-space: nowrap;">{{ scope.row.linkedWecom || '-' }}</span> <template v-if="editingCell && editingCell.recordId === scope.row.id && editingCell.field === 'linkedWecom'">
<el-input v-model="editingCell.value" size="small" class="phone-inline-edit-cell" :placeholder="editableFields.linkedWecom.placeholder" @blur="handleInlineSave" @keyup.enter="handleInlineSave" @keyup.escape="handleInlineCancel" @click.stop />
</template>
<span v-else :title="scope.row.linkedWecom || '双击编辑'" style="white-space: nowrap; cursor: text;" @dblclick.stop="handleCellDblclick(scope.row, 'linkedWecom')">{{ scope.row.linkedWecom || '双击编辑' }}</span>
</template> </template>
</el-table-column> <el-table-column label="操作" width="100" fixed="right"> </el-table-column> <el-table-column label="操作" width="100" fixed="right">
<template #default="scope"> <template #default="scope">
......
(function attachPhoneApiClient() { (function attachPhoneApiClient() {
const BASE_URL = 'http://localhost:8080/api/wx-phones'; const BASE_URL = 'http://localhost:8080/api/wx-phones';
/** /**
...@@ -50,12 +50,19 @@ ...@@ -50,12 +50,19 @@
const payload = { ...record }; const payload = { ...record };
const isEdit = Boolean(payload.id); const isEdit = Boolean(payload.id);
const targetUrl = isEdit ? `${BASE_URL}/${payload.id}` : BASE_URL; const targetUrl = isEdit ? `${BASE_URL}/${payload.id}` : BASE_URL;
console.info('[phone-api] savePhone 请求:id=', payload.id,
'imageAttachment1 长度=', (payload.imageAttachment1 || '').length,
'imageAttachment2 长度=', (payload.imageAttachment2 || '').length);
const json = await requestJson(targetUrl, { const json = await requestJson(targetUrl, {
method: isEdit ? 'PUT' : 'POST', method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, '手机号保存失败,请确认本地后端已启动'); }, '手机号保存失败,请确认本地后端已启动');
return json.data || json; const data = json.data || json;
console.info('[phone-api] savePhone 响应:id=', payload.id,
'imageAttachment1 长度=', (data?.imageAttachment1 || data?.image_attachment_1 || '').length,
'imageAttachment2 长度=', (data?.imageAttachment2 || data?.image_attachment_2 || '').length);
return data;
} }
/** /**
......
...@@ -1274,10 +1274,14 @@ ...@@ -1274,10 +1274,14 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
font-size: 11px; font-size: 11px;
color: #94a3b8; color: #BBBFCF !important;
background: #f8fafc; background: #f8fafc;
} }
.phone-table-header-nowrap span[title="双击编辑"] {
color: #BBBFCF !important;
}
.phone-image-thumb--empty .phone-image-fallback { .phone-image-thumb--empty .phone-image-fallback {
display: flex; display: flex;
} }
...@@ -1329,3 +1333,42 @@ ...@@ -1329,3 +1333,42 @@
color: #ef4444; color: #ef4444;
background: rgba(255, 255, 255, 0.18); background: rgba(255, 255, 255, 0.18);
} }
/* Hidden file input used by the phone image cell for double-click upload */
.phone-image-input {
display: none;
}
/* Empty placeholder: hint interactivity on hover and double-click */
.phone-image-thumb--empty {
cursor: pointer;
user-select: none;
}
.phone-image-thumb--empty:hover {
border-color: #BBBFCF;
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
}
/* Inline edit cell: input/select used when double-clicking an editable table cell */
.phone-inline-edit-cell {
width: 100%;
min-width: 80px;
}
.phone-inline-edit-cell .el-input__wrapper,
.phone-inline-edit-cell .el-select .el-input__wrapper {
border-color: #BBBFCF !important;
box-shadow: none !important;
background: #ffffff;
}
.phone-inline-edit-cell .el-input__wrapper:hover,
.phone-inline-edit-cell .el-select .el-input__wrapper:hover {
border-color: #9ca3af !important;
}
.phone-inline-edit-cell .el-input__inner {
font-size: 13px;
color: #111827;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment