|
|
@@ -0,0 +1,329 @@
|
|
|
+<script setup lang="ts">
|
|
|
+definePageMeta({
|
|
|
+ auth: false,
|
|
|
+})
|
|
|
+
|
|
|
+type CookieItem = {
|
|
|
+ name: string
|
|
|
+ value: string
|
|
|
+ decodedValue?: string
|
|
|
+ valueLength: number
|
|
|
+}
|
|
|
+
|
|
|
+type CookieSsrSnapshot = {
|
|
|
+ serverCollectedAt: string
|
|
|
+ url: string
|
|
|
+ method: string
|
|
|
+ clientIp: string | null
|
|
|
+ cookieHeader: string
|
|
|
+ cookieHeaderLength: number
|
|
|
+ cookies: CookieItem[]
|
|
|
+ headers: Record<string, string>
|
|
|
+}
|
|
|
+
|
|
|
+function safeDecodeURIComponent(value: string): string | undefined {
|
|
|
+ try {
|
|
|
+ return decodeURIComponent(value)
|
|
|
+ }
|
|
|
+ catch {
|
|
|
+ return undefined
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function parseCookieHeader(cookieHeader: string): CookieItem[] {
|
|
|
+ if (!cookieHeader)
|
|
|
+ return []
|
|
|
+
|
|
|
+ return cookieHeader
|
|
|
+ .split(';')
|
|
|
+ .map(part => part.trim())
|
|
|
+ .filter(Boolean)
|
|
|
+ .map((part) => {
|
|
|
+ const idx = part.indexOf('=')
|
|
|
+ const name = (idx >= 0 ? part.slice(0, idx) : part).trim()
|
|
|
+ const value = (idx >= 0 ? part.slice(idx + 1) : '').trim()
|
|
|
+ const decodedValue = value ? safeDecodeURIComponent(value) : undefined
|
|
|
+
|
|
|
+ return {
|
|
|
+ name,
|
|
|
+ value,
|
|
|
+ decodedValue,
|
|
|
+ valueLength: value.length,
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function collectSsrSnapshot(): CookieSsrSnapshot {
|
|
|
+ const url = useRequestURL()
|
|
|
+ const event = useRequestEvent()
|
|
|
+
|
|
|
+ const headersRaw = useRequestHeaders([
|
|
|
+ 'cookie',
|
|
|
+ 'user-agent',
|
|
|
+ 'accept-language',
|
|
|
+ 'referer',
|
|
|
+ 'host',
|
|
|
+ 'x-forwarded-for',
|
|
|
+ 'x-real-ip',
|
|
|
+ ])
|
|
|
+
|
|
|
+ const headers: Record<string, string> = {}
|
|
|
+ for (const [k, v] of Object.entries(headersRaw)) {
|
|
|
+ if (typeof v === 'string')
|
|
|
+ headers[k] = v
|
|
|
+ }
|
|
|
+
|
|
|
+ const cookieHeader = headersRaw.cookie || ''
|
|
|
+ const cookies = parseCookieHeader(cookieHeader)
|
|
|
+
|
|
|
+ const xff = headersRaw['x-forwarded-for']
|
|
|
+ const clientIp = (xff ? xff.split(',')[0]?.trim() : null)
|
|
|
+ || headersRaw['x-real-ip']
|
|
|
+ || event?.node?.req?.socket?.remoteAddress
|
|
|
+ || null
|
|
|
+
|
|
|
+ return {
|
|
|
+ serverCollectedAt: new Date().toISOString(),
|
|
|
+ url: url.toString(),
|
|
|
+ method: event?.node?.req?.method || 'UNKNOWN',
|
|
|
+ clientIp,
|
|
|
+ cookieHeader,
|
|
|
+ cookieHeaderLength: cookieHeader.length,
|
|
|
+ cookies,
|
|
|
+ headers,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const importMetaServer = import.meta.server
|
|
|
+
|
|
|
+const ssrSnapshot = useState<CookieSsrSnapshot | null>('test-cookie-ssr-snapshot', () => {
|
|
|
+ if (!import.meta.server)
|
|
|
+ return null
|
|
|
+ return collectSsrSnapshot()
|
|
|
+})
|
|
|
+
|
|
|
+const hydrated = ref(false)
|
|
|
+const clientNow = ref(new Date().toISOString())
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ hydrated.value = true
|
|
|
+ clientNow.value = new Date().toISOString()
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div class="cookie-page">
|
|
|
+ <h1 class="title">
|
|
|
+ Cookie SSR 调试
|
|
|
+ </h1>
|
|
|
+
|
|
|
+ <div class="meta">
|
|
|
+ <div class="meta__row">
|
|
|
+ <span class="meta__label">当前环境</span>
|
|
|
+ <span class="meta__value">{{ importMetaServer ? 'server' : 'client' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="meta__row">
|
|
|
+ <span class="meta__label">客户端已挂载</span>
|
|
|
+ <span class="meta__value">{{ hydrated ? 'yes' : 'no' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="meta__row">
|
|
|
+ <span class="meta__label">客户端时间</span>
|
|
|
+ <span class="meta__value">{{ clientNow }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <van-divider />
|
|
|
+
|
|
|
+ <template v-if="ssrSnapshot">
|
|
|
+ <h2 class="section-title">
|
|
|
+ 原始 Cookie Header
|
|
|
+ </h2>
|
|
|
+ <pre class="code">{{ ssrSnapshot.cookieHeader || '(empty)' }}</pre>
|
|
|
+
|
|
|
+ <van-divider />
|
|
|
+
|
|
|
+ <h2 class="section-title">
|
|
|
+ 解析后的 Cookies
|
|
|
+ </h2>
|
|
|
+
|
|
|
+ <template v-if="ssrSnapshot.cookies.length">
|
|
|
+ <div class="table-wrap">
|
|
|
+ <table class="table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>name</th>
|
|
|
+ <th>value</th>
|
|
|
+ <th>decodedValue</th>
|
|
|
+ <th>length</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ <tr
|
|
|
+ v-for="c in ssrSnapshot.cookies"
|
|
|
+ :key="c.name"
|
|
|
+ >
|
|
|
+ <td class="mono">
|
|
|
+ {{ c.name }}
|
|
|
+ </td>
|
|
|
+ <td class="mono">
|
|
|
+ {{ c.value }}
|
|
|
+ </td>
|
|
|
+ <td class="mono">
|
|
|
+ {{ c.decodedValue ?? '-' }}
|
|
|
+ </td>
|
|
|
+ <td class="mono">
|
|
|
+ {{ c.valueLength }}
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <template v-else>
|
|
|
+ <div class="empty">
|
|
|
+ 未收到 Cookie(请求头里没有 cookie 字段或为空)。
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <van-divider />
|
|
|
+
|
|
|
+ <h2 class="section-title">
|
|
|
+ Cookie 相关请求头(SSR 采样)
|
|
|
+ </h2>
|
|
|
+
|
|
|
+ <pre class="code">{{ JSON.stringify(ssrSnapshot.headers, null, 2) }}</pre>
|
|
|
+
|
|
|
+ <van-divider />
|
|
|
+
|
|
|
+ <h2 class="section-title">
|
|
|
+ SSR 快照(服务端渲染时采集并注入到客户端)
|
|
|
+ </h2>
|
|
|
+
|
|
|
+ <van-cell-group inset>
|
|
|
+ <van-cell
|
|
|
+ title="SSR 采集时间"
|
|
|
+ :value="ssrSnapshot.serverCollectedAt"
|
|
|
+ />
|
|
|
+ <van-cell
|
|
|
+ title="请求 URL"
|
|
|
+ :value="ssrSnapshot.url"
|
|
|
+ />
|
|
|
+ <van-cell
|
|
|
+ title="请求方法"
|
|
|
+ :value="ssrSnapshot.method"
|
|
|
+ />
|
|
|
+ <van-cell
|
|
|
+ title="推测客户端 IP"
|
|
|
+ :value="ssrSnapshot.clientIp || '-'"
|
|
|
+ />
|
|
|
+ <van-cell
|
|
|
+ title="Cookie Header 长度"
|
|
|
+ :value="String(ssrSnapshot.cookieHeaderLength)"
|
|
|
+ />
|
|
|
+ <van-cell
|
|
|
+ title="Cookie 数量(解析后)"
|
|
|
+ :value="String(ssrSnapshot.cookies.length)"
|
|
|
+ />
|
|
|
+ </van-cell-group>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template v-else>
|
|
|
+ <van-empty description="未获取到 SSR 快照(可能是纯 CSR 导航且未命中 SSR,或当前环境不可用)。" />
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.cookie-page {
|
|
|
+ padding: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.title {
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 700;
|
|
|
+ margin: 0 0 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.section-title {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 700;
|
|
|
+ margin: 12px 0 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.meta {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 1fr;
|
|
|
+ gap: 6px;
|
|
|
+ padding: 12px;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: rgba(0, 0, 0, 0.03);
|
|
|
+}
|
|
|
+
|
|
|
+.meta__row {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 12px;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.meta__label {
|
|
|
+ opacity: 0.7;
|
|
|
+}
|
|
|
+
|
|
|
+.meta__value {
|
|
|
+ font-weight: 600;
|
|
|
+ word-break: break-all;
|
|
|
+ text-align: right;
|
|
|
+}
|
|
|
+
|
|
|
+.code {
|
|
|
+ padding: 12px;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: rgba(0, 0, 0, 0.04);
|
|
|
+ overflow: auto;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.45;
|
|
|
+ margin: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.table-wrap {
|
|
|
+ overflow: auto;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: rgba(0, 0, 0, 0.02);
|
|
|
+ padding: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.table {
|
|
|
+ width: 100%;
|
|
|
+ border-collapse: collapse;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.table th,
|
|
|
+.table td {
|
|
|
+ padding: 8px;
|
|
|
+ border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
|
|
+ text-align: left;
|
|
|
+ vertical-align: top;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.mono {
|
|
|
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
|
+}
|
|
|
+
|
|
|
+.empty {
|
|
|
+ padding: 12px;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: rgba(0, 0, 0, 0.03);
|
|
|
+ font-size: 12px;
|
|
|
+ opacity: 0.8;
|
|
|
+}
|
|
|
+
|
|
|
+.notes {
|
|
|
+ margin: 0;
|
|
|
+ padding-left: 18px;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.6;
|
|
|
+}
|
|
|
+</style>
|