tool.xpcool.com/src/views/regex-visual/RegexVisualView.vue
夏犀麟 dd38aeaf38
All checks were successful
Build and Deploy (tool.xpcool.com) / build-and-deploy (push) Successful in 1m10s
feat(theme): 改版为清晰蓝卡片工作台并接管 UI 库尺度
主色青玉改宝石蓝,字号整体放大一档(正文 16px),
通过 --td-comp-* 与 --td-font-* 接管 TDesign 控件高度与文字,
修复 theme-mode 选择器权重不足导致深色变量被 UI 库覆盖的问题。
2026-09-14 23:54:53 +08:00

369 lines
9.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>