All checks were successful
Build and Deploy (tool.xpcool.com) / build-and-deploy (push) Successful in 1m10s
主色青玉改宝石蓝,字号整体放大一档(正文 16px), 通过 --td-comp-* 与 --td-font-* 接管 TDesign 控件高度与文字, 修复 theme-mode 选择器权重不足导致深色变量被 UI 库覆盖的问题。
369 lines
9.8 KiB
Vue
369 lines
9.8 KiB
Vue
<template>
|
||
<div class="page-wrap">
|
||
<h2 class="page-title">正则可视化</h2>
|
||
<p class="page-desc">把正则表达式解析成结构树,一眼看懂每个字符的含义,附匹配测试。</p>
|
||
|
||
<t-card class="tool-section" title="正则表达式">
|
||
<div class="form-row">
|
||
<t-input v-model="pattern" placeholder="如:^https?://(www\.)?[\w-]+\.(com|cn)(/\S*)?$" class="pattern-input" @change="parse" />
|
||
<t-select v-model="flags" multiple :options="FLAG_OPTIONS" class="flag-select" />
|
||
</div>
|
||
</t-card>
|
||
|
||
<ErrorAlert :message="error" @close="error = ''" />
|
||
|
||
<t-card v-if="nodes.length" class="tool-section" title="结构解析">
|
||
<div class="tree">
|
||
<div v-for="(n, i) in flat" :key="i" class="tree-node" :style="{ paddingLeft: n.depth * 22 + 'px' }">
|
||
<span class="tag" :class="'tag-' + n.type">{{ typeLabel(n.type) }}</span>
|
||
<code class="txt">{{ n.text || '(空)' }}</code>
|
||
<span class="desc">{{ n.explain }}</span>
|
||
</div>
|
||
</div>
|
||
</t-card>
|
||
|
||
<t-card class="tool-section" title="匹配测试">
|
||
<t-textarea v-model="testText" :autosize="{ minRows: 3, maxRows: 8 }" placeholder="输入测试文本" />
|
||
<div v-if="matches.length" class="match-list">
|
||
<div v-for="(m, i) in matches" :key="i" class="match-item">
|
||
<span class="m-index">#{{ i + 1 }}</span>
|
||
<code class="m-text">{{ m }}</code>
|
||
</div>
|
||
</div>
|
||
<p v-else-if="pattern && testText" class="no-match">没有匹配结果</p>
|
||
</t-card>
|
||
|
||
<HistoryPanel
|
||
:items="history.items"
|
||
@select="onHistorySelect"
|
||
@remove="history.remove"
|
||
@clear="history.clear"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||
import { useHistory } from '@/composables/useHistory'
|
||
|
||
const history = useHistory('regex-visual')
|
||
const pattern = ref('')
|
||
const testText = ref('')
|
||
const error = ref('')
|
||
const flags = ref<string[]>(['g'])
|
||
|
||
const FLAG_OPTIONS = [
|
||
{ label: 'g 全局', value: 'g' },
|
||
{ label: 'i 忽略大小写', value: 'i' },
|
||
{ label: 'm 多行', value: 'm' },
|
||
{ label: 's 点号匹配换行', value: 's' },
|
||
{ label: 'u Unicode', value: 'u' },
|
||
]
|
||
|
||
/* ---------------- 正则解析器 ---------------- */
|
||
|
||
type NodeType =
|
||
| 'group' | 'class' | 'quantifier' | 'anchor' | 'escape' | 'literal' | 'any' | 'alternation' | 'flags'
|
||
|
||
interface RegexNode {
|
||
type: NodeType
|
||
text: string
|
||
explain: string
|
||
children?: RegexNode[]
|
||
}
|
||
|
||
interface FlatNode extends RegexNode {
|
||
depth: number
|
||
}
|
||
|
||
const NODE_EXPLAIN: Record<string, string> = {
|
||
group: '分组',
|
||
class: '字符类',
|
||
quantifier: '量词',
|
||
anchor: '锚点',
|
||
escape: '转义',
|
||
literal: '字面量',
|
||
any: '任意字符',
|
||
alternation: '或',
|
||
flags: '修饰符',
|
||
}
|
||
|
||
function typeLabel(t: NodeType): string {
|
||
return NODE_EXPLAIN[t] ?? t
|
||
}
|
||
|
||
function parseRegex(pattern: string): RegexNode[] {
|
||
let pos = 0
|
||
const len = pattern.length
|
||
const root: RegexNode[] = []
|
||
|
||
function parseUntilClose(): RegexNode[] {
|
||
const nodes: RegexNode[] = []
|
||
while (pos < len) {
|
||
const c = pattern[pos]
|
||
if (c === ')') {
|
||
pos++
|
||
break
|
||
}
|
||
let node: RegexNode | null = null
|
||
if (c === '(') {
|
||
const groupStart = pos
|
||
const rest = pattern.slice(pos, pos + 4)
|
||
let explain = '捕获分组'
|
||
let advance = 1
|
||
if (rest.startsWith('(?:')) {
|
||
explain = '非捕获分组'
|
||
advance = 3
|
||
} else if (rest.startsWith('(?=')) {
|
||
explain = '正向先行断言'
|
||
advance = 3
|
||
} else if (rest.startsWith('(?!')) {
|
||
explain = '负向先行断言'
|
||
advance = 3
|
||
} else if (rest.startsWith('(?<=')) {
|
||
explain = '正向后行断言'
|
||
advance = 4
|
||
} else if (rest.startsWith('(?<!')) {
|
||
explain = '负向后行断言'
|
||
advance = 4
|
||
} else if (rest.startsWith('(?<')) {
|
||
explain = '具名分组'
|
||
advance = 1
|
||
}
|
||
pos += advance
|
||
const children = parseUntilClose()
|
||
node = {
|
||
type: 'group',
|
||
text: pattern.slice(groupStart, pos),
|
||
explain,
|
||
children,
|
||
}
|
||
} else if (c === '[') {
|
||
const end = pattern.indexOf(']', pos + 1)
|
||
const close = end === -1 ? len : end + 1
|
||
const body = pattern.slice(pos, close)
|
||
node = {
|
||
type: 'class',
|
||
text: body,
|
||
explain: body.startsWith('[^') ? '否定字符类' : '字符类(匹配其中任一字符)',
|
||
}
|
||
pos = close
|
||
} else if (c === '*' || c === '+' || c === '?') {
|
||
node = {
|
||
type: 'quantifier',
|
||
text: c,
|
||
explain: c === '*' ? '重复 0 次或多次' : c === '+' ? '重复 1 次或多次' : '重复 0 次或 1 次',
|
||
}
|
||
pos++
|
||
} else if (c === '{') {
|
||
const m = pattern.slice(pos).match(/^\{(\d+)(?:,(\d*))?\}/)
|
||
if (m) {
|
||
const min = m[1]
|
||
const max = m[2] === undefined ? min : m[2] === '' ? '∞' : m[2]
|
||
node = { type: 'quantifier', text: m[0], explain: `重复 ${min} 到 ${max} 次` }
|
||
pos += m[0].length
|
||
} else {
|
||
node = { type: 'literal', text: c, explain: '字面量 {(非量词)' }
|
||
pos++
|
||
}
|
||
} else if (c === '^' || c === '$') {
|
||
node = { type: 'anchor', text: c, explain: c === '^' ? '匹配输入开头' : '匹配输入结尾' }
|
||
pos++
|
||
} else if (c === '\\') {
|
||
const esc = pattern.slice(pos, pos + 2)
|
||
const what = esc[1] ?? ''
|
||
const map: Record<string, string> = {
|
||
d: '数字字符 [0-9]', D: '非数字字符', w: '单词字符 [A-Za-z0-9_]', W: '非单词字符',
|
||
s: '空白字符(空格/制表/换行)', S: '非空白字符', b: '单词边界', B: '非单词边界',
|
||
n: '换行符', t: '制表符', r: '回车符', '0': '空字符',
|
||
}
|
||
node = {
|
||
type: 'escape',
|
||
text: esc,
|
||
explain: map[what] ?? `转义字符 ${what === '\\' ? '反斜杠' : `「${what}」`}`,
|
||
}
|
||
pos += 2
|
||
} else if (c === '.') {
|
||
node = { type: 'any', text: '.', explain: flags.value.includes('s') ? '任意字符(含换行)' : '任意字符(不含换行)' }
|
||
pos++
|
||
} else if (c === '|') {
|
||
node = { type: 'alternation', text: '|', explain: '或(两边表达式二选一)' }
|
||
pos++
|
||
} else {
|
||
node = { type: 'literal', text: c, explain: `字面量「${c}」` }
|
||
pos++
|
||
}
|
||
nodes.push(node)
|
||
}
|
||
return nodes
|
||
}
|
||
|
||
try {
|
||
const nodes = parseUntilClose()
|
||
if (nodes.length === 0 && len === 0) return []
|
||
return nodes
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
function flatten(nodes: RegexNode[], depth: number, out: FlatNode[] = []): FlatNode[] {
|
||
for (const n of nodes) {
|
||
out.push({ ...n, depth })
|
||
if (n.children?.length) flatten(n.children, depth + 1, out)
|
||
}
|
||
return out
|
||
}
|
||
|
||
const nodes = ref<RegexNode[]>([])
|
||
const flat = computed<FlatNode[]>(() => flatten(nodes.value, 0))
|
||
|
||
function parse() {
|
||
error.value = ''
|
||
nodes.value = []
|
||
const p = pattern.value.trim()
|
||
if (!p) return
|
||
try {
|
||
new RegExp(p, flags.value.join(''))
|
||
} catch (e) {
|
||
error.value = `正则语法错误:${e instanceof Error ? e.message : '请检查表达式'}`
|
||
return
|
||
}
|
||
nodes.value = parseRegex(p)
|
||
history.add(p)
|
||
}
|
||
|
||
const matches = computed<string[]>(() => {
|
||
if (!pattern.value || !testText.value) return []
|
||
try {
|
||
const re = new RegExp(pattern.value, flags.value.join(''))
|
||
const out: string[] = []
|
||
let m: RegExpExecArray | null
|
||
let guard = 0
|
||
while ((m = re.exec(testText.value)) !== null && guard < 200) {
|
||
out.push(m[0])
|
||
guard++
|
||
if (!re.global) break
|
||
if (m.index === re.lastIndex) re.lastIndex++
|
||
}
|
||
return out
|
||
} catch {
|
||
return []
|
||
}
|
||
})
|
||
|
||
function onHistorySelect(text: string) {
|
||
pattern.value = text
|
||
parse()
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page-wrap {
|
||
max-width: 860px;
|
||
}
|
||
.form-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.pattern-input {
|
||
flex: 1;
|
||
min-width: 260px;
|
||
font-family: ui-monospace, Consolas, monospace;
|
||
}
|
||
.flag-select {
|
||
width: 200px;
|
||
}
|
||
.tree {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
.tree-node {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
}
|
||
.tree-node:hover {
|
||
background: var(--td-bg-color-container-hover);
|
||
}
|
||
.tag {
|
||
flex-shrink: 0;
|
||
font-size: var(--tk-text-xs);
|
||
padding: 1px 6px;
|
||
border-radius: 3px;
|
||
color: #fff;
|
||
min-width: 40px;
|
||
text-align: center;
|
||
}
|
||
.tag-group {
|
||
background: #667eea;
|
||
}
|
||
.tag-class {
|
||
background: #11998e;
|
||
}
|
||
.tag-quantifier {
|
||
background: #f76b1c;
|
||
}
|
||
.tag-anchor {
|
||
background: #8e44ad;
|
||
}
|
||
.tag-escape {
|
||
background: #c0392b;
|
||
}
|
||
.tag-literal {
|
||
background: #7f8c8d;
|
||
}
|
||
.tag-any {
|
||
background: #2c3e50;
|
||
}
|
||
.tag-alternation {
|
||
background: #e67e22;
|
||
}
|
||
.txt {
|
||
font-family: ui-monospace, Consolas, monospace;
|
||
font-size: 13px;
|
||
color: var(--td-text-color-primary);
|
||
word-break: break-all;
|
||
}
|
||
.desc {
|
||
color: var(--td-text-color-secondary);
|
||
font-size: var(--tk-text-xs);
|
||
}
|
||
.match-list {
|
||
margin-top: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.match-item {
|
||
display: flex;
|
||
gap: 10px;
|
||
padding: 6px 10px;
|
||
border: 1px solid var(--td-border-level-1-color);
|
||
border-radius: 6px;
|
||
background: var(--td-bg-color-container);
|
||
}
|
||
.m-index {
|
||
color: var(--td-text-color-placeholder);
|
||
font-size: var(--tk-text-xs);
|
||
}
|
||
.m-text {
|
||
font-family: ui-monospace, Consolas, monospace;
|
||
font-size: 13px;
|
||
color: var(--td-brand-color);
|
||
word-break: break-all;
|
||
}
|
||
.no-match {
|
||
margin: 12px 0 0;
|
||
font-size: 13px;
|
||
color: var(--td-text-color-placeholder);
|
||
}
|
||
</style>
|