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