first commit
This commit is contained in:
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
29
README.md
Normal file
29
README.md
Normal file
@ -0,0 +1,29 @@
|
||||
# web3-test
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
jsconfig.json
Normal file
8
jsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
2944
package-lock.json
generated
Normal file
2944
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "web3-test",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"ethers": "^6.13.2",
|
||||
"vue": "^3.5.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vite": "^7.0.6",
|
||||
"vite-plugin-vue-devtools": "^8.0.0"
|
||||
}
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
453
src/App.vue
Normal file
453
src/App.vue
Normal file
@ -0,0 +1,453 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ethers } from 'ethers'
|
||||
|
||||
const ownerAddress = ref('')
|
||||
const connectedAddress = ref('')
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const safes = ref([])
|
||||
const progressText = ref('')
|
||||
const hasInjectedProvider = ref(false)
|
||||
const latestBlock = ref(null)
|
||||
const selectedPreset = ref(50000)
|
||||
const allSafes = ref([])
|
||||
|
||||
// BSC Testnet RPC 与 Safe 工厂地址(SafeProxyFactory 1.3.x,CREATE2 地址)
|
||||
const RPC_HTTP_URL = 'https://bsc-testnet-rpc.publicnode.com'
|
||||
const RPC_WS_URL = 'wss://bsc-testnet-rpc.publicnode.com'
|
||||
const SAFE_PROXY_FACTORY = '0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2'
|
||||
|
||||
// 可选:自定义扫描区块范围
|
||||
const fromBlockInput = ref('')
|
||||
const toBlockInput = ref('')
|
||||
|
||||
const SAFE_ABI = [
|
||||
'function getOwners() view returns (address[])',
|
||||
]
|
||||
|
||||
function isValidAddress(address) {
|
||||
return /^0x[a-fA-F0-9]{40}$/.test(address)
|
||||
}
|
||||
|
||||
async function fetchSafes() {
|
||||
errorMessage.value = ''
|
||||
safes.value = []
|
||||
progressText.value = ''
|
||||
|
||||
const targetOwner = ownerAddress.value.trim()
|
||||
if (!isValidAddress(targetOwner)) {
|
||||
errorMessage.value = '请输入有效的 0x 开头的以太坊地址'
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const provider = new ethers.JsonRpcProvider(RPC_HTTP_URL, 97)
|
||||
const latestBlock = await provider.getBlockNumber()
|
||||
const fromBlock = fromBlockInput.value
|
||||
? Number(fromBlockInput.value)
|
||||
: Math.max(1, latestBlock - 300000)
|
||||
const toBlock = toBlockInput.value ? Number(toBlockInput.value) : latestBlock
|
||||
|
||||
if (Number.isNaN(fromBlock) || Number.isNaN(toBlock) || fromBlock > toBlock) {
|
||||
throw new Error('区块范围不合法')
|
||||
}
|
||||
|
||||
const factoryTopic = ethers.id('ProxyCreation(address,address)')
|
||||
const logs = await getFactoryLogsChunked(provider, SAFE_PROXY_FACTORY, factoryTopic, fromBlock, toBlock, 50000, (processedTo) => {
|
||||
progressText.value = `正在扫描日志... 当前进度: 至区块 ${processedTo}`
|
||||
})
|
||||
|
||||
progressText.value = `扫描到 ${logs.length} 个 ProxyCreation 事件,正在筛选...`
|
||||
|
||||
const iface = new ethers.Interface(['event ProxyCreation(address proxy,address singleton)'])
|
||||
const candidates = new Set()
|
||||
for (const log of logs) {
|
||||
try {
|
||||
const decoded = iface.decodeEventLog('ProxyCreation', log.data, log.topics)
|
||||
const proxy = decoded?.proxy || decoded?.[0]
|
||||
if (proxy) candidates.add(ethers.getAddress(proxy))
|
||||
} catch (_) {
|
||||
// 忽略无法解码的日志
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueProxies = Array.from(candidates)
|
||||
if (uniqueProxies.length === 0) {
|
||||
errorMessage.value = '未找到任何 Safe 创建事件,请调大区间或确认网络'
|
||||
return
|
||||
}
|
||||
|
||||
const safeIface = new ethers.Interface(SAFE_ABI)
|
||||
|
||||
const matched = []
|
||||
let checked = 0
|
||||
for (const proxy of uniqueProxies) {
|
||||
try {
|
||||
const data = safeIface.encodeFunctionData('getOwners', [])
|
||||
const result = await provider.call({ to: proxy, data })
|
||||
const owners = safeIface.decodeFunctionResult('getOwners', result)[0]
|
||||
if (owners.map((o) => ethers.getAddress(o)).includes(ethers.getAddress(targetOwner))) {
|
||||
matched.push(proxy)
|
||||
}
|
||||
} catch (_) {
|
||||
// 非 Safe 合约或未初始化,忽略
|
||||
} finally {
|
||||
checked += 1
|
||||
if (checked % 25 === 0) {
|
||||
progressText.value = `已检查 ${checked}/${uniqueProxies.length} 个候选...`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
safes.value = matched
|
||||
if (safes.value.length === 0) {
|
||||
errorMessage.value = '未找到关联的 Safe 多签地址。'
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage.value = err?.message || '查询失败,请稍后重试'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
progressText.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 一键自动填充区块范围:from = latest - 预设跨度,to = latest
|
||||
async function fillBlockRange() {
|
||||
try {
|
||||
const provider = new ethers.JsonRpcProvider(RPC_HTTP_URL, 97)
|
||||
const latest = await provider.getBlockNumber()
|
||||
latestBlock.value = latest
|
||||
const span = Number(selectedPreset.value) || 50000
|
||||
const from = Math.max(1, latest - span)
|
||||
fromBlockInput.value = String(from)
|
||||
toBlockInput.value = String(latest)
|
||||
} catch (e) {
|
||||
errorMessage.value = e?.message || '获取最新区块失败'
|
||||
}
|
||||
}
|
||||
|
||||
// 分段获取工厂事件,避免 RPC getLogs 区块跨度限制
|
||||
async function getFactoryLogsChunked(provider, address, topic0, fromBlock, toBlock, maxSpan, onProgress) {
|
||||
const all = []
|
||||
let span = Math.max(1000, Math.min(maxSpan || 50000, toBlock - fromBlock))
|
||||
let start = fromBlock
|
||||
|
||||
while (start <= toBlock) {
|
||||
let end = Math.min(start + span, toBlock)
|
||||
try {
|
||||
const logs = await provider.getLogs({ address, topics: [topic0], fromBlock: start, toBlock: end })
|
||||
all.push(...logs)
|
||||
if (onProgress) onProgress(end)
|
||||
start = end + 1
|
||||
} catch (e) {
|
||||
const msg = String(e?.message || '')
|
||||
// 自适应减小跨度
|
||||
if (span <= 1000 || msg.includes('exceed maximum block range')) {
|
||||
if (span <= 1000) throw e
|
||||
span = Math.max(1000, Math.floor(span / 2))
|
||||
} else {
|
||||
// 其他错误,稍微减半重试
|
||||
span = Math.max(1000, Math.floor(span / 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// 通过 WS provider 扫描历史所有 Safe 地址
|
||||
async function fetchAllSafesViaWs() {
|
||||
errorMessage.value = ''
|
||||
progressText.value = ''
|
||||
isLoading.value = true
|
||||
try {
|
||||
const provider = new ethers.WebSocketProvider(RPC_WS_URL, 97)
|
||||
const latest = await provider.getBlockNumber()
|
||||
if (!fromBlockInput.value) {
|
||||
const span = Number(selectedPreset.value) || 50000
|
||||
fromBlockInput.value = String(Math.max(1, latest - span))
|
||||
}
|
||||
if (!toBlockInput.value) {
|
||||
toBlockInput.value = String(latest)
|
||||
}
|
||||
const fromBlock = Number(fromBlockInput.value)
|
||||
const toBlock = Number(toBlockInput.value)
|
||||
const factoryTopic = ethers.id('ProxyCreation(address,address)')
|
||||
const logs = await getFactoryLogsChunked(provider, SAFE_PROXY_FACTORY, factoryTopic, fromBlock, toBlock, 50000, (processedTo) => {
|
||||
progressText.value = `通过 WS 扫描... 已至区块 ${processedTo}`
|
||||
})
|
||||
const iface = new ethers.Interface(['event ProxyCreation(address proxy,address singleton)'])
|
||||
const set = new Set(allSafes.value)
|
||||
for (const log of logs) {
|
||||
try {
|
||||
const decoded = iface.decodeEventLog('ProxyCreation', log.data, log.topics)
|
||||
const proxy = decoded?.proxy || decoded?.[0]
|
||||
if (proxy) set.add(ethers.getAddress(proxy))
|
||||
} catch (_) {}
|
||||
}
|
||||
allSafes.value = Array.from(set)
|
||||
} catch (e) {
|
||||
errorMessage.value = e?.message || '通过 WS 获取失败'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
progressText.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 连接浏览器钱包(MetaMask 等)
|
||||
async function connectWallet() {
|
||||
try {
|
||||
if (!window.ethereum) {
|
||||
errorMessage.value = '未检测到浏览器钱包(如 MetaMask)'
|
||||
return
|
||||
}
|
||||
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' })
|
||||
connectedAddress.value = accounts[0] ? ethers.getAddress(accounts[0]) : ''
|
||||
if (!ownerAddress.value && connectedAddress.value) ownerAddress.value = connectedAddress.value
|
||||
// 监听账户/网络变化
|
||||
window.ethereum.on?.('accountsChanged', (accs) => {
|
||||
connectedAddress.value = accs[0] ? ethers.getAddress(accs[0]) : ''
|
||||
if (!ownerAddress.value && connectedAddress.value) ownerAddress.value = connectedAddress.value
|
||||
})
|
||||
window.ethereum.on?.('chainChanged', () => {
|
||||
// 可根据需要刷新
|
||||
})
|
||||
} catch (e) {
|
||||
errorMessage.value = e?.message || '连接钱包失败'
|
||||
}
|
||||
}
|
||||
|
||||
// WS 订阅 Safe 工厂 ProxyCreation
|
||||
let wsProvider = null
|
||||
let wsListener = null
|
||||
|
||||
function startWsSubscription() {
|
||||
try {
|
||||
if (wsProvider) return
|
||||
wsProvider = new ethers.WebSocketProvider(RPC_WS_URL, 97)
|
||||
const factoryTopic = ethers.id('ProxyCreation(address,address)')
|
||||
wsListener = async (log) => {
|
||||
try {
|
||||
if (!log?.topics || log.topics[0] !== factoryTopic || log.address.toLowerCase() !== SAFE_PROXY_FACTORY.toLowerCase()) return
|
||||
const iface = new ethers.Interface(['event ProxyCreation(address proxy,address singleton)'])
|
||||
const decoded = iface.decodeEventLog('ProxyCreation', log.data, log.topics)
|
||||
const proxy = decoded?.proxy || decoded?.[0]
|
||||
if (!proxy) return
|
||||
// 记录所有 Safe(实时新建)
|
||||
const normalized = ethers.getAddress(proxy)
|
||||
if (!allSafes.value.includes(normalized)) allSafes.value = [normalized, ...allSafes.value]
|
||||
const provider = new ethers.JsonRpcProvider(RPC_HTTP_URL, 97)
|
||||
const safeIface = new ethers.Interface(SAFE_ABI)
|
||||
const data = safeIface.encodeFunctionData('getOwners', [])
|
||||
const result = await provider.call({ to: proxy, data })
|
||||
const owners = safeIface.decodeFunctionResult('getOwners', result)[0]
|
||||
const target = ownerAddress.value || connectedAddress.value
|
||||
if (target && owners.map((o) => ethers.getAddress(o)).includes(ethers.getAddress(target))) {
|
||||
const addr = ethers.getAddress(proxy)
|
||||
if (!safes.value.includes(addr)) safes.value = [addr, ...safes.value]
|
||||
}
|
||||
} catch (_) {
|
||||
// 忽略单条失败
|
||||
}
|
||||
}
|
||||
wsProvider.on({ address: SAFE_PROXY_FACTORY, topics: [factoryTopic] }, wsListener)
|
||||
} catch (e) {
|
||||
errorMessage.value = e?.message || 'WS 订阅失败'
|
||||
}
|
||||
}
|
||||
|
||||
function stopWsSubscription() {
|
||||
try {
|
||||
if (wsProvider && wsListener) {
|
||||
const factoryTopic = ethers.id('ProxyCreation(address,address)')
|
||||
wsProvider.off({ address: SAFE_PROXY_FACTORY, topics: [factoryTopic] }, wsListener)
|
||||
}
|
||||
wsListener = null
|
||||
wsProvider?.destroy?.()
|
||||
wsProvider = null
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
try { hasInjectedProvider.value = typeof window !== 'undefined' && !!window.ethereum } catch (_) { hasInjectedProvider.value = false }
|
||||
startWsSubscription()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopWsSubscription()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1>查询多签 Safe 地址(BSC Testnet, chain=97)</h1>
|
||||
|
||||
<div class="form">
|
||||
<button @click="connectWallet" :disabled="!hasInjectedProvider">{{ connectedAddress ? '已连接' : '连接钱包' }}</button>
|
||||
<span v-if="connectedAddress" class="mono">{{ connectedAddress }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form">
|
||||
<input
|
||||
v-model="ownerAddress"
|
||||
type="text"
|
||||
placeholder="输入钱包地址(0x...)"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button :disabled="isLoading" @click="fetchSafes">{{ isLoading ? '查询中...' : '查询' }}</button>
|
||||
</div>
|
||||
|
||||
<div class="form small">
|
||||
<input v-model="fromBlockInput" type="number" placeholder="起始区块(可选)" />
|
||||
<input v-model="toBlockInput" type="number" placeholder="结束区块(可选)" />
|
||||
<div>
|
||||
<select v-model="selectedPreset">
|
||||
<option :value="10000">最近 1 万块</option>
|
||||
<option :value="50000">最近 5 万块</option>
|
||||
<option :value="100000">最近 10 万块</option>
|
||||
<option :value="300000">最近 30 万块</option>
|
||||
<option :value="1000000">最近 100 万块</option>
|
||||
</select>
|
||||
<button @click="fillBlockRange">自动填充</button>
|
||||
</div>
|
||||
<small v-if="latestBlock !== null" class="hint">最新区块: {{ latestBlock }}</small>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
<p v-if="progressText && isLoading" class="progress">{{ progressText }}</p>
|
||||
|
||||
<ul v-if="safes.length > 0" class="result">
|
||||
<li v-for="safe in safes" :key="safe">
|
||||
<span class="mono">{{ safe }}</span>
|
||||
<a
|
||||
class="link"
|
||||
:href="`https://testnet.bscscan.com/address/${safe}`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>查看区块浏览器</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="form">
|
||||
<button :disabled="isLoading" @click="fetchAllSafesViaWs">通过 WS 获取所有 Safe</button>
|
||||
<span v-if="allSafes.length">共 {{ allSafes.length }} 个</span>
|
||||
</div>
|
||||
|
||||
<ul v-if="allSafes.length > 0" class="result">
|
||||
<li v-for="safe in allSafes" :key="safe">
|
||||
<span class="mono">{{ safe }}</span>
|
||||
<a
|
||||
class="link"
|
||||
:href="`https://testnet.bscscan.com/address/${safe}`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>查看</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 720px;
|
||||
margin: 48px auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form.small {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #333;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button[disabled] {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d93025;
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
color: #6b7280;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.form.small input[type="number"] {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.result {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.result li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
4
src/main.js
Normal file
4
src/main.js
Normal file
@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
18
vite.config.js
Normal file
18
vite.config.js
Normal file
@ -0,0 +1,18 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user