Appearance
实现 文本批量修饰 工具
分类:developer | 标签:修饰、行号、前缀 批量加行号、前后缀、项目符号、填充对齐
2.1 功能说明
批量加行号、前后缀、项目符号、填充对齐
核心能力
- b.label
- 直接换行
- 空一行
2.1.1 使用指南
功能说明
对多行文本做批量的格式加工:加行号、加前后缀、加项目符号、对齐补白、复制多份、重排成多列。典型场景是把一列数据快速改写成 SQL 的 IN 列表、Markdown 列表、配置文件条目或对齐的等宽表格。所有处理都在本地完成。
处理顺序
选项按固定顺序叠加,理解顺序才能得到预期结果:
- 按行拆分,可选先去掉每行首尾空白、跳过空行;
- 对正文按设定宽度做填充对齐;
- 依次拼接:行号 + 项目符号 + 行首前缀 + 正文 + 行尾后缀;
- 如启用多列,把处理后的若干行合并成一行;
- 最后按设定次数整体重复。
各选项说明
- 添加行号:可设置起始编号、补零位数(例如补零 3 位得到 001)以及编号与正文之间的分隔符。
- 项目符号:内置常见的短横线、星号、Markdown 无序列表等,也可自定义任意字符串。
- 行首前缀 / 行尾后缀:例如前缀填单引号、后缀填「单引号加逗号」,就能把一列 ID 变成 SQL 的 IN 列表。
- 按宽度填充对齐:不足指定宽度时用填充字符补齐,可选左中右对齐;超出宽度的行保持原样不截断。勾选「中文按两个宽度计算」后,中日韩字符按 2 个宽度统计,等宽字体下的视觉对齐更准确。
- 重排为多列:把连续的 N 行合并成一行,用指定分隔符连接,最后不足一行的部分照常输出。列间分隔填
\t会被识别为真正的制表符。 - 整体重复:把最终结果整体复制若干份,可选择两份之间是否空一行。
关于特殊字符
宽度计算基于完整字符而非编码单元,包含表情符号或生僻字的文本也不会被截断成乱码。
2.2 代码实现
2.2.1 组件结构
本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-input、el-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。
2.2.2 核心逻辑概览
<script setup> 中定义的主要函数/方法:
defaultOptions()charsOf()isWideChar()displayWidth()padText()unescapeSep()applyToInput()resetOptions()clearAll()copyOutput()downloadOutput()
2.2.3 关键实现代码
以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。
vue
import { ref, reactive, computed } from 'vue'
import { ElMessage } from 'element-plus'
import ToolPageShell from '@/components/ToolPageShell.vue'
const input = ref('')
const bulletPresets = [
{ label: '短横线 -', value: '- ' },
{ label: '星号 *', value: '* ' },
{ label: '间隔号 ·', value: '· ' },
{ label: '加号 +', value: '+ ' },
{ label: '大于号 >', value: '> ' },
{ label: '自定义', value: 'custom' }
]
function defaultOptions() {
return {
skipEmpty: true,
trimLine: false,
lineNumber: false,
numberStart: 1,
numberPad: 0,
numberSep: '. ',
bullet: false,
bulletChar: '- ',
bulletCustom: '',
prefix: '',
suffix: '',
pad: false,
padWidth: 20,
padAlign: 'left',
padChar: ' ',
padCjkWide: true,
columns: false,
columnCount: 3,
columnSep: '\\t',
repeat: 1,
repeatJoin: 'newline'
}
}
const opt = reactive(defaultOptions())
const sourceLines = computed(() => (input.value ? input.value.split(/\r?\n/) : []))
// 使用 Array.from 保证代理对(如表情符号)不会被拆散
function charsOf(str) {
return Array.from(str)
}
function isWideChar(ch) {
const code = ch.codePointAt(0)
return (
(code >= 0x1100 && code <= 0x115f) ||
(code >= 0x2e80 && code <= 0xa4cf) ||
(code >= 0xac00 && code <= 0xd7a3) ||
(code >= 0xf900 && code <= 0xfaff) ||
(code >= 0xfe30 && code <= 0xfe6f) ||
(code >= 0xff00 && code <= 0xff60) ||
(code >= 0xffe0 && code <= 0xffe6) ||
(code >= 0x20000 && code <= 0x3fffd)
)
}
function displayWidth(str) {
const chars = charsOf(str)
if (!opt.padCjkWide) return chars.length
return chars.reduce((sum, ch) => sum + (isWideChar(ch) ? 2 : 1), 0)
}
function padText(str) {
const fill = opt.padChar === '' ? ' ' : charsOf(opt.padChar)[0]
const width = opt.padWidth
const current = displayWidth(str)
if (current >= width) return str
const gap = width - current
if (opt.padAlign === 'left') return str + fill.repeat(gap)
if (opt.padAlign === 'right') return fill.repeat(gap) + str
const left = Math.floor(gap / 2)
return fill.repeat(left) + str + fill.repeat(gap - left)
}
function unescapeSep(str) {
return String(str).replace(/\\t/g, '\t').replace(/\\n/g, '\n')
}
const output = computed(() => {
let lines = sourceLines.value
if (!lines.length) return ''
if (opt.trimLine) lines = lines.map(l => l.trim())
if (opt.skipEmpty) lines = lines.filter(l => l.trim() !== '')
if (!lines.length) return ''
const bulletText = opt.bullet ? (opt.bulletChar === 'custom' ? opt.bulletCustom : opt.bulletChar) : ''
let processed = lines.map((line, index) => {
let body = opt.pad ? padText(line) : line
let numberText = ''
if (opt.lineNumber) {
const n = String(opt.numberStart + index)
numberText = (opt.numberPad > 0 ? n.padStart(opt.numberPad, '0') : n) + opt.numberSep
}
return `${numberText}${bulletText}${opt.prefix}${body}${opt.suffix}`
})
if (opt.columns && opt.columnCount > 1) {
const sep = unescapeSep(opt.columnSep)
const grouped = []
for (let i = 0; i < processed.length; i += opt.columnCount) {
grouped.push(processed.slice(i, i + opt.columnCount).join(sep))
}
processed = grouped
}
const block = processed.join('\n')
if (opt.repeat <= 1) return block
const joiner = opt.repeatJoin === 'blank' ? '\n\n' : '\n'
return Array.from({ length: opt.repeat }, () => block).join(joiner)
})
const outputLineCount = computed(() => (output.value ? output.value.split('\n').length : 0))
function applyToInput() {
input.value = output.value
ElMessage.success('已把结果回填到输入框,可继续叠加处理')
}
function resetOptions() {
Object.assign(opt, defaultOptions())
ElMessage.success('选项已重置')
}
function clearAll() {
input.value = ''
}
async function copyOutput() {
if (!output.value) return
try {
await navigator.clipboard.writeText(output.value)
ElMessage.success('已复制到剪贴板')
} catch {
ElMessage.warning('复制失败,请手动选择文本复制')
}
}
function downloadOutput() {
if (!output.value) return
const blob = new Blob([output.value], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'decorated-text.txt'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success('已开始下载')
}2.3 效果截图
