docs: update URL-to-browser explanation with online shopping metaphor
- Change primary analogy from "delivery service" to "online shopping" to make concepts more relatable - Update all documentation sections to align with the new metaphor - Refactor interactive demo components to use compact layouts and improve visual clarity - Add developer insights section explaining HTTP-API relationship - Enhance browser rendering explanation with assembly metaphor - Improve visual components with better responsive design and user interactions
This commit is contained in:
@@ -1,197 +1,253 @@
|
||||
<!--
|
||||
UrlParserDemo.vue
|
||||
URL解析演示 - 交互式可视化组件
|
||||
URL解析演示 - 网购订单隐喻版
|
||||
|
||||
用途:
|
||||
将 URL 解析过程可视化,通过颜色编码和分块展示,
|
||||
直观地展示 URL 的各个组成部分及其对应的技术含义和生活比喻。
|
||||
设计理念:
|
||||
1. 隐喻对齐:严格对应 url-to-browser.md 中的"网购订单"比喻。
|
||||
2. 视觉映射:将枯燥的 URL 字符串映射为一张清晰的"购物清单"。
|
||||
3. 实时交互:输入即解析,所见即所得。
|
||||
-->
|
||||
<template>
|
||||
<div class="url-parser-demo">
|
||||
<!-- 头部控制区 -->
|
||||
<div class="control-panel">
|
||||
<div class="header-section">
|
||||
<span class="icon">🔍</span>
|
||||
<span class="title">URL 解析器</span>
|
||||
</div>
|
||||
|
||||
<div class="examples-section">
|
||||
<span class="label">试一试:</span>
|
||||
<div class="button-group">
|
||||
<button
|
||||
v-for="ex in examples"
|
||||
:key="ex.name"
|
||||
@click="useExample(ex)"
|
||||
class="action-btn outline small"
|
||||
:class="{ active: currentExample === ex.name }"
|
||||
>
|
||||
{{ ex.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="url-parser-order">
|
||||
<!-- 顶部:输入区 -->
|
||||
<div class="input-section">
|
||||
<div class="input-wrapper">
|
||||
<div class="url-input-box" :class="{ 'has-error': error }">
|
||||
<span class="input-label">URL</span>
|
||||
<input
|
||||
v-model="urlInput"
|
||||
type="text"
|
||||
placeholder="输入或粘贴一个网址..."
|
||||
placeholder="https://www.example.com/path?query=1"
|
||||
class="real-input"
|
||||
@input="parseUrl"
|
||||
class="url-input"
|
||||
/>
|
||||
<button class="clear-btn" @click="clear" v-if="urlInput">✕</button>
|
||||
<button v-if="urlInput" class="clear-btn" @click="clear">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 可视化展示区 -->
|
||||
<div class="visualization-area" v-if="parsed.protocol">
|
||||
<div class="url-blocks">
|
||||
<div
|
||||
v-for="(field, key) in formFields"
|
||||
:key="key"
|
||||
v-show="shouldShowField(key)"
|
||||
class="url-block"
|
||||
:class="[key, { active: hovered === key }]"
|
||||
:style="{ '--block-color': field.color, '--block-bg': hexToRgba(field.color, 0.15) }"
|
||||
@mouseenter="hovered = key"
|
||||
@mouseleave="hovered = null"
|
||||
<div class="quick-actions">
|
||||
<span class="action-label">试一试:</span>
|
||||
<button
|
||||
v-for="ex in examples"
|
||||
:key="ex.name"
|
||||
@click="useExample(ex)"
|
||||
class="action-chip"
|
||||
:class="{ active: currentExample === ex.name }"
|
||||
>
|
||||
<span class="block-value">{{ getDisplayValue(key) }}</span>
|
||||
<span class="block-label">{{ field.techName }}</span>
|
||||
</div>
|
||||
{{ ex.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情说明卡片 -->
|
||||
<div class="info-card" v-if="hovered && formFields[hovered]">
|
||||
<div class="info-header" :style="{ borderLeftColor: formFields[hovered].color }">
|
||||
<span class="info-title">{{ formFields[hovered].techLabel }} ({{ formFields[hovered].techName }})</span>
|
||||
<span class="info-badge" :style="{ backgroundColor: formFields[hovered].color }">
|
||||
{{ formFields[hovered].icon }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="info-content">
|
||||
<div class="info-row">
|
||||
<div class="info-label">技术含义</div>
|
||||
<div class="info-value">
|
||||
<strong>{{ formFields[hovered].techDesc }}</strong>
|
||||
<div class="info-detail">{{ formFields[hovered].techDetail }}</div>
|
||||
<!-- 核心区域:左右对照布局 -->
|
||||
<template v-if="parsed.protocol">
|
||||
<div class="core-stage">
|
||||
<!-- 左侧:解析结果 (技术视角) -->
|
||||
<div class="tech-view">
|
||||
<div class="view-header">
|
||||
<span class="icon">💻</span>
|
||||
<span class="title">技术解析</span>
|
||||
</div>
|
||||
<div class="code-blocks">
|
||||
<div
|
||||
v-for="(field, key) in formFields"
|
||||
:key="key"
|
||||
v-show="shouldShowField(key)"
|
||||
class="code-block"
|
||||
:class="[key, { active: hovered === key }]"
|
||||
:style="{ '--color': field.color }"
|
||||
@mouseenter="hovered = key"
|
||||
@mouseleave="hovered = null"
|
||||
>
|
||||
<span class="field-name">{{ key }}</span>
|
||||
<span class="field-value">{{ getDisplayValue(key) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-divider"></div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">生活比喻</div>
|
||||
<div class="info-value">
|
||||
<strong>{{ formFields[hovered].analogyLabel }}</strong>
|
||||
<div class="info-detail">{{ formFields[hovered].analogyDesc }}</div>
|
||||
|
||||
<!-- 中间:转换箭头 -->
|
||||
<div class="transform-arrow">
|
||||
<span class="arrow-icon">➔</span>
|
||||
<span class="arrow-text">映射为</span>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:购物单 (生活视角) -->
|
||||
<div class="life-view">
|
||||
<div class="view-header">
|
||||
<span class="icon">🧾</span>
|
||||
<span class="title">购物订单</span>
|
||||
</div>
|
||||
<div class="order-ticket">
|
||||
<div class="ticket-hole"></div>
|
||||
|
||||
<div
|
||||
v-for="(field, key) in formFields"
|
||||
:key="key"
|
||||
v-show="shouldShowField(key)"
|
||||
class="ticket-row"
|
||||
:class="{ active: hovered === key }"
|
||||
:style="{ '--color': field.color }"
|
||||
@mouseenter="hovered = key"
|
||||
@mouseleave="hovered = null"
|
||||
>
|
||||
<div class="ticket-icon" :style="{ backgroundColor: field.color }">{{ field.icon }}</div>
|
||||
<div class="ticket-content">
|
||||
<span class="ticket-label">{{ field.analogyLabel }}</span>
|
||||
<span class="ticket-desc">{{ field.analogyDesc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态提示 -->
|
||||
<div class="empty-state" v-else-if="!urlInput">
|
||||
<p>👆 在上方输入网址,查看它是由哪些部分组成的</p>
|
||||
</div>
|
||||
|
||||
<div class="default-info" v-else>
|
||||
<p>👆 鼠标悬停在上方色块上,查看详细解释</p>
|
||||
|
||||
<!-- 技术答疑面板 -->
|
||||
<transition name="fade">
|
||||
<div class="qa-panel" v-if="activeQa">
|
||||
<div class="qa-header">{{ activeQa.title }}</div>
|
||||
<div class="qa-content">
|
||||
<div v-for="(item, idx) in activeQa.content" :key="idx" class="qa-item">
|
||||
<div class="qa-q">Q: {{ item.q }}</div>
|
||||
<div class="qa-a">A: {{ item.a }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qa-placeholder" v-else>
|
||||
👆 鼠标悬停在上方色块,查看详细技术解释
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<!-- 空状态引导 -->
|
||||
<div class="empty-state" v-else>
|
||||
<div class="empty-icon">🛒</div>
|
||||
<div class="empty-text">
|
||||
<p>输入网址,生成你的"数字购物单"</p>
|
||||
<span class="sub-text">看看浏览器如何理解这一长串字符</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const urlInput = ref('')
|
||||
const parsed = ref({})
|
||||
const hovered = ref(null)
|
||||
const currentExample = ref('')
|
||||
const error = ref(false)
|
||||
|
||||
const examples = [
|
||||
{ name: '百度搜索', url: 'https://www.baidu.com/s?wd=hello' },
|
||||
{ name: 'GitHub项目', url: 'https://github.com/vuejs/core' },
|
||||
{ name: '带端口', url: 'http://localhost:8080/api/users' },
|
||||
{ name: '带锚点', url: 'https://vuejs.org/guide/introduction.html#what-is-vue' }
|
||||
{ name: '标准网购', url: 'https://www.nike.com/shoes/running?size=42&color=red' },
|
||||
{ name: '带端口', url: 'http://localhost:8080/api/status' },
|
||||
{ name: '带锚点', url: 'https://vuejs.org/guide.html#setup' }
|
||||
]
|
||||
|
||||
// 定义字段映射(严格对齐 url-to-browser.md)
|
||||
const formFields = {
|
||||
protocol: {
|
||||
techName: 'Protocol',
|
||||
techLabel: '协议',
|
||||
color: '#f43f5e', // Red
|
||||
icon: '规',
|
||||
analogyLabel: '快递公司',
|
||||
techDesc: '通信规则',
|
||||
analogyDesc: '决定是用"顺丰"(HTTPS)还是"平邮"(HTTP)传输',
|
||||
techDetail: 'https (加密) 或 http (明文)'
|
||||
icon: '🚚',
|
||||
analogyLabel: '物流方式',
|
||||
analogyDesc: '决定怎么送货(HTTP普通/HTTPS加密保密)。',
|
||||
qa: {
|
||||
title: '🤔 为什么要写 http/https?',
|
||||
content: [
|
||||
{
|
||||
q: '这两者有什么区别?',
|
||||
a: 'HTTP 就像寄明信片,邮递员(黑客)能看到内容。HTTPS 就像寄密封的信封,只有收件人能拆开看。'
|
||||
},
|
||||
{
|
||||
q: '为什么现在都是 https?',
|
||||
a: '为了安全!现在的浏览器如果发现不是 HTTPS,会提示"不安全",就像快递公司拒收没封口的信件一样。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
hostname: {
|
||||
techName: 'Hostname',
|
||||
techLabel: '域名',
|
||||
color: '#3b82f6', // Blue
|
||||
icon: '名',
|
||||
analogyLabel: '收件人',
|
||||
techDesc: '服务器地址',
|
||||
analogyDesc: '如 "google.com",方便人类记忆的名字',
|
||||
techDetail: '最终需要通过 DNS 解析为 IP 地址'
|
||||
icon: '🏠',
|
||||
analogyLabel: '店铺名称',
|
||||
analogyDesc: '告诉浏览器去哪家店(服务器)买东西。',
|
||||
qa: {
|
||||
title: '🤔 域名还是 IP?',
|
||||
content: [
|
||||
{
|
||||
q: '浏览器认识域名吗?',
|
||||
a: '其实不认识。浏览器只认识 IP 地址(一串数字)。域名是为了方便人记的。下一步(DNS 查询)就是把这个名字翻译成数字。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
port: {
|
||||
techName: 'Port',
|
||||
techLabel: '端口',
|
||||
color: '#f59e0b', // Amber
|
||||
icon: '门',
|
||||
analogyLabel: '门牌号',
|
||||
techDesc: '服务入口',
|
||||
analogyDesc: '如 ":8080",大楼里的具体房间号',
|
||||
techDetail: '默认端口(80/443)通常会被浏览器省略'
|
||||
icon: '🔢',
|
||||
analogyLabel: '柜台编号',
|
||||
analogyDesc: '店铺很大,指定去几号柜台办理业务。',
|
||||
qa: {
|
||||
title: '🤔 这里的数字是什么意思?',
|
||||
content: [
|
||||
{
|
||||
q: '为什么平时上网看不到它?',
|
||||
a: '因为有默认值!就像去银行默认去"综合柜台"一样。HTTP 默认是 80,HTTPS 默认是 443。只有特殊的才需要写出来。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
pathname: {
|
||||
techName: 'Path',
|
||||
techLabel: '路径',
|
||||
color: '#10b981', // Emerald
|
||||
icon: '径',
|
||||
analogyLabel: '具体位置',
|
||||
techDesc: '资源路径',
|
||||
analogyDesc: '如 "/files/doc.txt",文件柜的位置',
|
||||
techDetail: '指向服务器上的具体资源'
|
||||
icon: '📦',
|
||||
analogyLabel: '货架位置',
|
||||
analogyDesc: '商品在仓库里的具体存放位置。',
|
||||
qa: {
|
||||
title: '🤔 这一长串是干嘛的?',
|
||||
content: [
|
||||
{
|
||||
q: '它对应服务器上的文件吗?',
|
||||
a: '通常是的。/shoes/running 就像告诉仓库管理员:去"鞋子区"的"跑步架"上拿货。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
search: {
|
||||
techName: 'Query',
|
||||
techLabel: '参数',
|
||||
color: '#8b5cf6', // Violet
|
||||
icon: '参',
|
||||
analogyLabel: '备注',
|
||||
techDesc: '查询参数',
|
||||
analogyDesc: '如 "?q=hello",告诉对方具体要求',
|
||||
techDetail: '键值对形式的附加数据'
|
||||
icon: '📝',
|
||||
analogyLabel: '订单备注',
|
||||
analogyDesc: '给商家的额外要求(如:红色、42码)。',
|
||||
qa: {
|
||||
title: '🤔 问号后面的内容?',
|
||||
content: [
|
||||
{
|
||||
q: '这对网页有什么影响?',
|
||||
a: '就像你点外卖备注"不要香菜"。网页内容会根据这些参数变化,比如只显示红色的鞋子。'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
hash: {
|
||||
techName: 'Hash',
|
||||
techLabel: '锚点',
|
||||
color: '#ec4899', // Pink
|
||||
icon: '锚',
|
||||
analogyLabel: '页码',
|
||||
techDesc: '页内定位',
|
||||
analogyDesc: '如 "#section1",书的某一页',
|
||||
techDetail: '浏览器滚动到指定位置,不会发送给服务器'
|
||||
icon: '🔖',
|
||||
analogyLabel: '说明书页码',
|
||||
analogyDesc: '拿到商品后,直接翻到说明书的第几页。',
|
||||
qa: {
|
||||
title: '🤔 为什么要用 # 号?',
|
||||
content: [
|
||||
{
|
||||
q: '这部分会发给服务器吗?',
|
||||
a: '不会。这只是给你自己(浏览器)看的。就像你买书回家后翻到第10页,书店老板并不需要知道你看哪一页。'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hexToRgba = (hex, alpha) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
const activeField = computed(() => hovered.value || null)
|
||||
|
||||
const activeQa = computed(() => {
|
||||
if (!activeField.value) return null
|
||||
return formFields[activeField.value].qa
|
||||
})
|
||||
|
||||
const shouldShowField = (key) => {
|
||||
const val = parsed.value[key]
|
||||
if (!val) return false
|
||||
if (val === '无') return false
|
||||
if (key === 'search' && (val === '' || val === '?')) return false
|
||||
if (key === 'hash' && (val === '' || val === '#')) return false
|
||||
return true
|
||||
@@ -199,10 +255,8 @@ const shouldShowField = (key) => {
|
||||
|
||||
const getDisplayValue = (key) => {
|
||||
let val = parsed.value[key]
|
||||
|
||||
if (key === 'protocol') return val + '://'
|
||||
if (key === 'port') return ':' + val.replace('(默认)', '')
|
||||
// simple formatting
|
||||
if (key === 'port') return ':' + val
|
||||
return val
|
||||
}
|
||||
|
||||
@@ -216,6 +270,8 @@ const clear = () => {
|
||||
urlInput.value = ''
|
||||
parsed.value = {}
|
||||
currentExample.value = ''
|
||||
hovered.value = null
|
||||
error.value = false
|
||||
}
|
||||
|
||||
const parseUrl = () => {
|
||||
@@ -223,295 +279,341 @@ const parseUrl = () => {
|
||||
parsed.value = {}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let urlStr = urlInput.value.trim()
|
||||
// Auto-add protocol if missing for better UX
|
||||
// 自动补全协议以支持 new URL() 解析
|
||||
if (!urlStr.match(/^https?:\/\//)) {
|
||||
if (urlStr.startsWith('localhost')) {
|
||||
urlStr = 'http://' + urlStr
|
||||
} else {
|
||||
urlStr = 'https://' + urlStr
|
||||
}
|
||||
urlStr = (urlStr.startsWith('localhost') ? 'http://' : 'https://') + urlStr
|
||||
}
|
||||
|
||||
const u = new URL(urlStr)
|
||||
|
||||
// Determine if port is explicit or default
|
||||
let portDisplay = u.port
|
||||
if (!portDisplay) {
|
||||
// Just for display logic in the parser, we might not show it if it's default/hidden
|
||||
// But let's show it if we want to be educational
|
||||
// Actually, let's only show if it's in the string or we want to be explicit
|
||||
// For visualizer, maybe better to show what's THERE.
|
||||
// But to be educational, maybe show implied?
|
||||
// Let's stick to what's in the URL object but handle defaults
|
||||
// If the user typed it, u.port is set. If implied, it's empty string.
|
||||
}
|
||||
|
||||
parsed.value = {
|
||||
protocol: u.protocol.replace(':', ''),
|
||||
hostname: u.hostname,
|
||||
port: u.port, // Only show if explicit
|
||||
pathname: u.pathname === '/' ? '/' : u.pathname,
|
||||
port: u.port || (u.protocol === 'https:' ? '443 (默认)' : '80 (默认)'),
|
||||
pathname: u.pathname,
|
||||
search: u.search,
|
||||
hash: u.hash
|
||||
}
|
||||
error.value = false
|
||||
} catch (e) {
|
||||
// simplistic fallback or error state could go here
|
||||
// parsed.value = {}
|
||||
// 解析失败时不更新 parsed,或者显示错误状态
|
||||
// 这里选择静默失败,等待用户输入完整
|
||||
if (urlInput.value.length > 10) {
|
||||
// error.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.url-parser-demo {
|
||||
.url-parser-order {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background-color: var(--vp-c-bg);
|
||||
padding: 20px;
|
||||
margin: 16px 0;
|
||||
font-family: var(--vp-font-family-base);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* 输入区 */
|
||||
.input-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.url-input-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.examples-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: transparent;
|
||||
color: var(--vp-c-text-2);
|
||||
padding: 8px 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
.url-input-box:focus-within {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.action-btn.active {
|
||||
background-color: var(--vp-c-brand-soft);
|
||||
color: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.input-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 2.5rem 0.75rem 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.url-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--vp-c-brand);
|
||||
.input-label {
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-right: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
background: none;
|
||||
.real-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-1);
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
}
|
||||
.clear-btn {
|
||||
color: var(--vp-c-text-3);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 4px;
|
||||
}
|
||||
.clear-btn:hover { color: var(--vp-c-text-1); }
|
||||
|
||||
.clear-btn:hover {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
margin-bottom: 1rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.url-blocks {
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.action-label {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
padding-top: 4px;
|
||||
}
|
||||
.action-chip {
|
||||
padding: 4px 12px;
|
||||
border-radius: 100px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-1);
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.action-chip:hover {
|
||||
background: var(--vp-c-brand-soft);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
.action-chip.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.url-block {
|
||||
/* 核心展示区 */
|
||||
.core-stage {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 通用标题 */
|
||||
.view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.view-header .icon { font-size: 16px; }
|
||||
.view-header .title { font-weight: bold; font-size: 14px; color: var(--vp-c-text-1); }
|
||||
|
||||
/* 左侧:技术视图 */
|
||||
.tech-view {
|
||||
flex: 1;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
background-color: var(--block-bg);
|
||||
border: 1px solid var(--block-color);
|
||||
cursor: help;
|
||||
transition: all 0.2s;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.url-block:hover, .url-block.active {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.block-value {
|
||||
font-weight: bold;
|
||||
color: var(--block-color);
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
margin-top: 0.25rem;
|
||||
opacity: 0.7;
|
||||
color: var(--block-color);
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
animation: slide-up 0.3s ease;
|
||||
}
|
||||
|
||||
.info-header {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
.code-blocks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.code-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
border-left: 3px solid var(--color);
|
||||
transition: all 0.2s;
|
||||
cursor: default;
|
||||
}
|
||||
.code-block:hover, .code-block.active {
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.field-name {
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-2);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.field-value {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-1);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 中间:箭头 */
|
||||
.transform-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-left: 4px solid transparent;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 12px;
|
||||
width: 40px;
|
||||
}
|
||||
.arrow-icon { font-size: 20px; }
|
||||
|
||||
/* 右侧:生活视图 (票据样式) */
|
||||
.life-view {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
/* 暗黑模式适配票据背景 */
|
||||
:root.dark .life-view {
|
||||
background: #1e1e20;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.95rem;
|
||||
.order-ticket {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px dashed var(--vp-c-divider);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info-badge {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
.ticket-hole {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.ticket-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.ticket-row:hover, .ticket-row.active {
|
||||
background: var(--vp-c-bg-soft);
|
||||
opacity: 1;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.ticket-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1px 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-divider {
|
||||
background: var(--vp-c-divider);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
.ticket-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.75rem;
|
||||
.ticket-label {
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.ticket-desc {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.info-detail {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.empty-state, .default-info {
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: var(--vp-c-text-3);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
|
||||
.empty-text p { font-size: 16px; font-weight: bold; margin: 0 0 8px 0; color: var(--vp-c-text-2); }
|
||||
.sub-text { font-size: 13px; }
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.core-stage {
|
||||
flex-direction: column;
|
||||
}
|
||||
.transform-arrow {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
gap: 8px;
|
||||
}
|
||||
.arrow-icon { transform: rotate(90deg); }
|
||||
}
|
||||
|
||||
/* QA Panel */
|
||||
.qa-panel {
|
||||
margin-top: 16px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.qa-header {
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-brand);
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--vp-c-divider);
|
||||
}
|
||||
.qa-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.qa-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.qa-q {
|
||||
color: var(--vp-c-text-1);
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.qa-a {
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.qa-placeholder {
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 13px;
|
||||
padding: 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
border: 1px dashed var(--vp-c-divider);
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
/* Transitions */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.info-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.info-divider {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
Reference in New Issue
Block a user