2 커밋 88bb62ff1c ... 7734dd85ba

작성자 SHA1 메시지 날짜
  wengan01 7734dd85ba Merge remote-tracking branch 'origin/master' 1 주 전
  wengan01 ff84adbab4 旅游推荐优化 1 주 전
2개의 변경된 파일과 82개의 추가작업 그리고 92개의 파일을 삭제
  1. 82 52
      src/components/Editor/index.vue
  2. 0 40
      src/views/content/travelRecommendation/index.vue

+ 82 - 52
src/components/Editor/index.vue

@@ -70,38 +70,38 @@ Quill.register(SizeAttributor, true);
 Quill.register(Quill.import('attributors/style/align') as any, true);
 Quill.register(Quill.import('attributors/style/direction') as any, true);
 
-// 缩进改为内联样式:Quill默认缩进输出class(ql-indent-N),APP端渲染器只认内联样式,
-// 后台加的缩进到APP上会全部丢失(正文贴边、无法自行调整边距)。
-// 这里用padding-left承载缩进层级,编辑器与APP看到的效果一致
-const INDENT_EM = 2; // 每级缩进对应的em数
-const INDENT_MAX = 8; // 与Quill默认缩进层级上限保持一致
-const StyleAttributorBase: any = (Quill.import('parchment') as any).StyleAttributor;
-class IndentStyleAttributor extends StyleAttributorBase {
-  add(node: HTMLElement, value: any) {
-    // 工具栏按钮和Tab键传的是'+1'/'-1',先换算成绝对层级
-    if (value === '+1' || value === '-1') {
-      const cur = this.value(node) || 0;
-      value = value === '+1' ? cur + 1 : cur - 1;
+// 缩进改为"行首不间断空格"(首行缩进):曾用内联padding-left,但APP端渲染器把
+// padding-left作用到整段(所有行整体平移),不符合中文首行缩进观感。
+// U+00A0任何渲染器都不折叠、且只影响首行,该方案APP端零改动。
+// 缩进按钮不再走Quill的indent格式(会输出ql-indent class),实现见toolbar handlers.indent
+const INDENT_SPACES = 2; // 每级缩进的不间断空格数(即首行缩进2字符)
+const INDENT_MAX = 8; // 缩进层级上限
+const NBSP = '\u00a0';
+
+// 增加/减少缩进:对选区每一行行首插入或移除一级不间断空格(首行缩进,APP端原样显示)
+function applyIndent(direction: string) {
+  const quill = toRaw(quillEditorRef.value)?.getQuill();
+  if (!quill) return;
+  const range = quill.getSelection();
+  if (!range) return;
+  const lines = quill.getLines(range);
+  if (!lines || !lines.length) return;
+  // 从后往前处理:前行插入/删除文本会让后行的下标移位
+  for (let i = lines.length - 1; i >= 0; i--) {
+    const idx = (lines[i] as any).offset(quill.scroll);
+    const head = quill.getText(idx, INDENT_MAX * INDENT_SPACES);
+    let level = 0;
+    while (level < INDENT_MAX && head.startsWith(NBSP.repeat((level + 1) * INDENT_SPACES))) level++;
+    if (direction === '+1') {
+      if (level < INDENT_MAX) quill.insertText(idx, NBSP.repeat(INDENT_SPACES), 'user');
+    } else if (level > 0) {
+      quill.deleteText(idx, INDENT_SPACES, 'user');
+    } else if (head.startsWith(NBSP)) {
+      // 行首是单个不间断空格(手工敲的)时也允许退掉
+      quill.deleteText(idx, 1, 'user');
     }
-    const level = Math.min(Math.max(parseInt(value, 10) || 0, 0), INDENT_MAX);
-    if (level <= 0) return this.remove(node);
-    node.style.paddingLeft = `${level * INDENT_EM}em`;
-    return true;
-  }
-  value(node: HTMLElement) {
-    const raw = node.style ? node.style.paddingLeft : '';
-    if (!raw) return undefined;
-    const n = parseFloat(raw);
-    if (!n || n <= 0) return undefined;
-    const level = Math.round(raw.indexOf('px') >= 0 ? n / (INDENT_EM * 16) : n / INDENT_EM);
-    return level > 0 ? level : undefined;
-  }
-  remove(node: HTMLElement) {
-    node.style.paddingLeft = '';
-    return true;
   }
 }
-Quill.register(new IndentStyleAttributor('indent', 'padding-left', { scope: (Quill.import('parchment') as any).Scope.BLOCK }), true);
 
 // 视频用 <video> 标签插入:Quill默认video格式是iframe嵌入,APP端渲染器对iframe兼容差;
 // video标签在WebView原生可播、flutter_html等组件渲染也可扩展支持,兼容性严格优于iframe
@@ -158,8 +158,8 @@ const TOOLBAR_TIPS: [string, string][] = [
   ['button.ql-code-block', '代码块:选中内容设为代码块'],
   ['button.ql-list[value="ordered"]', '有序列表:选中内容加数字编号'],
   ['button.ql-list[value="bullet"]', '无序列表:选中内容加圆点符号'],
-  ['button.ql-indent[value="-1"]', '减少缩进:降低段落缩进层级(APP端同步生效)'],
-  ['button.ql-indent[value="+1"]', '增加缩进:提高段落缩进层级(APP端同步生效)'],
+  ['button.ql-indent[value="-1"]', '减少缩进:减少段落首行缩进(APP端同步生效)'],
+  ['button.ql-indent[value="+1"]', '增加缩进:段落首行缩进2字符(APP端同步生效)'],
   ['.ql-picker.ql-size .ql-picker-label', '字体大小:设置选中文字的字号'],
   ['.ql-picker.ql-header .ql-picker-label', '标题:切换段落标题级别'],
   ['.ql-picker.ql-color .ql-picker-label', '字体颜色:设置选中文字的颜色'],
@@ -315,6 +315,10 @@ const options = ref<any>({
           videoDialog.uploadedName = '';
           videoDialog.mode = 'url';
           videoDialog.visible = true;
+        },
+        // 缩进按钮:行首不间断空格实现首行缩进,不用Quill的indent格式(会输出ql-indent class,APP不认识)
+        indent: (value: string) => {
+          applyIndent(value);
         }
       }
     },
@@ -346,7 +350,7 @@ watch(
   () => props.modelValue,
   (v: string) => {
     if (v === lastEmitted) return;
-    // 旧数据缩进存的是Quill class(ql-indent-N),先转成内联padding-left;
+    // 旧数据缩进存的是Quill class(ql-indent-N)或上一版内联padding-left,统一转成行首不间断空格(首行缩进);
     // 再把手工敲的空格/Tab统一成不间断空格,保证不编辑直接保存也能在APP生效。
     // 转换在watch渲染期同步执行,一旦对畸形/超大内容抛错会连累整个Editor挂载失败、
     // 弹窗打不开(表现为点"修改"没反应),所以异常时降级为原始内容,绝不阻断渲染
@@ -566,30 +570,50 @@ function handleTextChange() {
   emit('update:modelValue', lastEmitted);
 }
 
-// 历史数据兼容:旧版缩进是Quill class(ql-indent-N),加载时转成内联padding-left
+// 历史数据兼容:旧缩进(Quill class ql-indent-N / 上一版内联padding-left)转成行首不间断空格;
+// 幂等:转换后标记被移除,再次进入不会重复叠加缩进
 function migrateIndentHtml(html: string): string {
-  if (!html || html.indexOf('ql-indent-') < 0) return html;
+  if (!html || (html.indexOf('ql-indent-') < 0 && html.toLowerCase().indexOf('padding-left') < 0)) return html;
   return html.replace(/<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (tag: string, name: string, attrs: string) => {
+    if (!/^(?:p|div|h[1-6]|li|blockquote|pre)$/i.test(name)) return tag;
+    let level = 0;
+    let newAttrs = attrs;
     const cm = /\sclass\s*=\s*"([^"]*)"/i.exec(attrs) || /\sclass\s*=\s*'([^']*)'/i.exec(attrs);
-    if (!cm) return tag;
-    const im = /ql-indent-(\d+)/.exec(cm[1]);
-    if (!im) return tag;
-    const level = Math.min(parseInt(im[1], 10) || 0, INDENT_MAX);
-    if (level < 1) return tag;
-    const restClass = cm[1].replace(/ql-indent-\d+/g, '').replace(/\s+/g, ' ').trim();
-    let newAttrs = attrs.replace(cm[0], restClass ? ` class="${restClass}"` : '');
-    const pad = `padding-left: ${level * INDENT_EM}em;`;
-    const sm = /\sstyle\s*=\s*"([^"]*)"/i.exec(newAttrs);
+    if (cm) {
+      const im = /ql-indent-(\d+)/.exec(cm[1]);
+      if (im) {
+        level = Math.min(parseInt(im[1], 10) || 0, INDENT_MAX);
+        const restClass = cm[1]
+          .replace(/ql-indent-\d+/g, '')
+          .replace(/\s+/g, ' ')
+          .trim();
+        newAttrs = newAttrs.replace(cm[0], restClass ? ` class="${restClass}"` : '');
+      }
+    }
+    const sm = /\sstyle\s*=\s*"([^"]*)"/i.exec(newAttrs) || /\sstyle\s*=\s*'([^']*)'/i.exec(newAttrs);
     if (sm) {
-      newAttrs = newAttrs.replace(sm[0], ` style="${pad}${sm[1]}"`);
-    } else {
-      newAttrs += ` style="${pad}"`;
+      const pm = /padding-left\s*:\s*([^;"]+)/i.exec(sm[1]);
+      if (pm) {
+        const raw = pm[1].trim();
+        const n = parseFloat(raw);
+        if (n > 0) {
+          const lv = Math.round(raw.toLowerCase().indexOf('px') >= 0 ? n / (INDENT_SPACES * 16) : n / INDENT_SPACES);
+          level = Math.max(level, Math.min(lv, INDENT_MAX));
+        }
+        const restStyle = sm[1]
+          .replace(/padding-left\s*:\s*[^;"]+;?/gi, '')
+          .replace(/\s+/g, ' ')
+          .replace(/^;+/, '')
+          .trim();
+        newAttrs = newAttrs.replace(sm[0], restStyle ? ` style="${restStyle}"` : '');
+      }
     }
-    return `<${name}${newAttrs}>`;
+    if (level < 1) return tag;
+    return `<${name}${newAttrs}>` + NBSP.repeat(level * INDENT_SPACES);
   });
 }
 
-// 读缩进层级:兼容内联padding-left与旧版ql-indent-N class
+// 读缩进层级:兼容内联padding-left与旧版ql-indent-N class(粘贴内容用,转成行首不间断空格)
 function readIndentLevel(el: HTMLElement): number {
   let level = 0;
   const cm = /ql-indent-(\d+)/.exec(el.getAttribute('class') || '');
@@ -597,20 +621,18 @@ function readIndentLevel(el: HTMLElement): number {
   const pl = el.style.paddingLeft;
   if (pl) {
     const n = parseFloat(pl);
-    if (n > 0) level = Math.max(level, Math.round(pl.indexOf('px') >= 0 ? n / (INDENT_EM * 16) : n / INDENT_EM));
+    if (n > 0) level = Math.max(level, Math.round(pl.indexOf('px') >= 0 ? n / (INDENT_SPACES * 16) : n / INDENT_SPACES));
   }
   return level;
 }
 
-// 插入一个带块级格式的换行符:Quill的缩进/对齐/标题/列表都是"行"格式,只认换行符上的属性
+// 插入一个带块级格式的换行符:Quill的对齐/标题/列表都是"行"格式,只认换行符上的属性
 function insertBlockBreak(delta: Delta, el: HTMLElement, listType: 'ordered' | 'bullet' | null) {
   const fmt: { [key: string]: any } = {};
   const hm = /^h([1-6])$/.exec(el.tagName.toLowerCase());
   if (hm) fmt.header = parseInt(hm[1], 10);
   if (el.style.textAlign) fmt.align = el.style.textAlign;
   if (listType) fmt.list = listType;
-  const level = readIndentLevel(el);
-  if (level > 0) fmt.indent = level;
   delta.insert('\n', Object.keys(fmt).length ? fmt : undefined);
 }
 
@@ -682,6 +704,14 @@ function buildDeltaWithListSupport(
   // 是否是块级元素(需要换行)
   const isBlock = ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'br'].includes(tagName);
 
+  // 粘贴内容里的旧缩进(padding-left/ql-indent class)转成行首不间断空格,与编辑器缩进按钮同一方案
+  if (isBlock && tagName !== 'br') {
+    const indentLevel = readIndentLevel(el);
+    if (indentLevel > 0) {
+      delta.insert(NBSP.repeat(indentLevel * INDENT_SPACES));
+    }
+  }
+
   // 遍历子节点
   for (let i = 0; i < el.childNodes.length; i++) {
     const child = el.childNodes[i];

+ 0 - 40
src/views/content/travelRecommendation/index.vue

@@ -165,20 +165,6 @@
           </div>
         </el-form-item>
 
-        <!-- 开放时间 -->
-        <el-form-item label="开放时间" prop="openTime">
-          <el-time-picker
-            v-model="openTimeRange"
-            is-range
-            format="HH:mm"
-            range-separator="至"
-            start-placeholder="开始时间"
-            end-placeholder="结束时间"
-            style="width: 100%"
-          />
-          <div class="form-tip">不选则APP端不显示</div>
-        </el-form-item>
-
         <!-- 联系电话 -->
         <el-form-item label="电话" prop="contactPhone">
           <el-input v-model="form.contactPhone" placeholder="空则无联系方式" />
@@ -317,7 +303,6 @@ const initForm = {
   longitude: null,
   latitude: null,
   contactPhone: '',
-  openTime: '',
   content: '',
   // 留空由后端自动分配为「当前最大值+1」,避免默认值与已有记录撞车导致新增失败
   sortOrder: null,
@@ -325,7 +310,6 @@ const initForm = {
   contentImages: []
 };
 const form = ref({ ...initForm, contentImages: [] });
-const openTimeRange = ref(null);
 
 // 置顶排序校验:新增允许留空(后端自动排到最后),修改必须是一个不小于1的有效值
 // 后端 updateByBo 对 null/小于1 会直接抛「置顶排序最小为1」,这里提前拦住,避免保存时才报错
@@ -659,7 +643,6 @@ function handleDelete(row) {
 // ========== 新增/修改 ==========
 function handleAdd() {
   form.value = { ...initForm, contentImages: [] };
-  openTimeRange.value = null;
   dialogTitle.value = '新增旅游推荐';
   dialogVisible.value = true;
 }
@@ -677,28 +660,12 @@ async function handleUpdate(row) {
       longitude: data.longitude || null,
       latitude: data.latitude || null,
       contactPhone: data.contactPhone || '',
-      openTime: data.openTime || '',
       content: data.content || '',
       // 库里若为 0/NULL 这类无效值,保持为空强制用户重填,不能兜底成 0(后端会拒绝保存)
       sortOrder: data.sortOrder && data.sortOrder >= 1 ? data.sortOrder : null,
       status: data.status || '0',
       contentImages: (data.imageList || []).map(img => img.imageUrl)
     };
-    // 解析开放时间字符串为时间范围选择器值
-    if (data.openTime) {
-      const parts = data.openTime.split(/[-–~至]/);
-      if (parts.length === 2) {
-        const [sh, sm] = parts[0].trim().split(':').map(Number);
-        const [eh, em] = parts[1].trim().split(':').map(Number);
-        const start = new Date(); start.setHours(sh || 0, sm || 0, 0, 0);
-        const end = new Date(); end.setHours(eh || 0, em || 0, 0, 0);
-        openTimeRange.value = [start, end];
-      } else {
-        openTimeRange.value = null;
-      }
-    } else {
-      openTimeRange.value = null;
-    }
     dialogTitle.value = '修改旅游推荐';
     dialogVisible.value = true;
   } catch (err) {
@@ -711,13 +678,6 @@ async function handleUpdate(row) {
 function submitForm() {
   formRef.value.validate(async (valid) => {
     if (!valid) return;
-    // 将时间选择器的值转为字符串存入openTime
-    if (openTimeRange.value && openTimeRange.value.length === 2) {
-      const fmt = (d) => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
-      form.value.openTime = `${fmt(openTimeRange.value[0])}-${fmt(openTimeRange.value[1])}`;
-    } else {
-      form.value.openTime = '';
-    }
     try {
       if (form.value.id) {
         await updateTravelRecommendation(form.value);