feat(embed): 合同导入与工作台融成一体(主题/字体/配色/滚动全对齐)

深色外壳里嵌一张米白纸、外加两层滚动条,是「没浑然一体」的根源:

- 宿主把当前主题下的设计令牌 postMessage 给嵌入页(跨源读不到对方的
  CSS 变量),两边共用同一套配色/字体/圆角;切主题即时跟随,不用刷新
  (src 里的 theme 只作首帧初值,避免换主题触发 iframe 重载白闪)
- 收起嵌入页里重复的部件:自带侧栏、顶栏指标条(货号总数/供应商/待核对)、
  右下角「问一句」悬浮球(宿主已有小皮)
- 只留一层滚动:路由区固定为视口高度,滚动交给 iframe 内部
- 输入框沿用宿主的「无方框」规则:去掉描边与焦点光环,改底色区分
- 页面头去掉「新窗口打开」(打开的是裸 SKU 页),刷新改成图标按钮
- 令牌名对不上时会告警,免得嵌入页悄悄回落到旧配色
This commit is contained in:
Drsakura
2026-09-11 12:53:22 +08:00
parent 453f3b1583
commit 436519e0a6
3 changed files with 82 additions and 25 deletions
+1 -1
View File
@@ -140,7 +140,7 @@ function App() {
<Route path="/purchase-orders" element={<PurchaseOrderManagement />} />
<Route path="/products" element={<ProductManagement />} />
<Route path="/suppliers" element={<SupplierManagement />} />
<Route path="/procurement/import" element={<EmbeddedSku view="import" />} />
<Route path="/procurement/import" element={<EmbeddedSku view="import" theme={theme} />} />
<Route path="/customers" element={<CustomerManagement />} />
<Route path="/orders" element={<OrderManagement />} />
<Route path="/prospects" element={<ProspectManagement />} />
+74 -24
View File
@@ -1,4 +1,5 @@
import React, { useState, useRef } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { RefreshIcon } from '../components/Icons'
/**
* SKU 功能嵌入页(整合过渡期方案)。
@@ -8,9 +9,40 @@ import React, { useState, useRef } from 'react'
*
* 现在嵌入的是独立的 sku-manager 服务(3300);等后端合并(P1/P2)完成后,
* 把 SKU_ORIGIN 换成同源子路径(如 /sku)即可,页面组件本身不用动。
*
* 外观一体:跨源 iframe 读不到宿主的 CSS 变量,所以由宿主把当前主题下
* 这些变量的实际取值读出来 postMessage 过去,嵌入页照单贴到自己对应的
* 变量上(嵌入页侧不含任何宿主变量名,见 sku-manager/public/embed.css)。
*/
const SKU_ORIGIN = import.meta.env.VITE_SKU_ORIGIN || 'http://127.0.0.1:3300'
/**
* 设计令牌桥:左边是嵌入页的 CSS 变量名,右边是宿主这边的取值来源。
* 只有这一处维护映射 —— 嵌入页负责结构,宿主负责配色与字体。
*/
const TOKEN_BRIDGE = {
'--bg': '--bg-primary', // 卡片底色 = 外壳底色 → 嵌入页"消失"
'--panel': '--surface', // 卡片、输入框默认底
'--panel-2': '--surface-deep', // 比卡片再深一档:输入框、拖入区
'--raised': '--surface-hover', // 悬浮、拖拽经过
'--line': '--border',
'--line-soft': '--border-light',
'--fg': '--text-primary',
'--fg-dim': '--text-secondary',
'--fg-mute': '--text-tertiary',
'--accent': '--primary',
'--accent-ink': '--primary',
'--accent-line': '--primary-border',
'--accent-glow': '--accent-glow',
'--accent-fill': '--primary-bg', // 输入框聚焦时的实底
'--on-accent': '--bg-primary', // 强调色上的文字:取宿主底色,两种主题都自动是反色
'--font-display': '--font-body',
'--font-body': '--font-body',
'--font-mono': '--font-mono',
'--r-sm': '--radius-sm',
'--r-md': '--radius-md'
}
const VIEW_META = {
search: {
title: '产品管理',
@@ -26,14 +58,47 @@ const VIEW_META = {
}
}
function EmbeddedSku({ view = 'search' }) {
function EmbeddedSku({ view = 'search', theme = 'dark' }) {
const [loaded, setLoaded] = useState(false)
const frameRef = useRef(null)
const meta = VIEW_META[view] || VIEW_META.search
const src = `${SKU_ORIGIN}/?embed=1&view=${encodeURIComponent(view)}`
/* src 里的 theme 只当"首帧用哪个主题"的初始值:钉死成首次渲染时的值。
否则换主题时 React 会改 src,iframe 整页重载白闪一下。 */
const initialTheme = useRef(theme).current
const pushTheme = useCallback(() => {
const frame = frameRef.current
if (!frame?.contentWindow) return
const computed = window.getComputedStyle(document.documentElement)
const vars = {}
Object.entries(TOKEN_BRIDGE).forEach(([skuVar, hostVar]) => {
const value = computed.getPropertyValue(hostVar).trim()
/* 宿主改了变量名而这里没跟上时,嵌入页会悄悄回落到自己的旧配色 ——
必须喊出来,否则要对着截图猜半天。 */
if (!value) console.warn(`[embed] 宿主找不到变量 ${hostVar},嵌入页的 ${skuVar} 将回落默认值`)
vars[skuVar] = value
})
frame.contentWindow.postMessage({ type: 'happy:embed', theme, vars }, '*')
}, [theme])
useEffect(() => {
if (!loaded) return undefined
/* 放到下一个宏任务:App 是在自己的 effect 里才把 data-theme 写到 <html> 上,
而子组件的 effect 先于父组件执行,此刻读到的还是上一个主题的令牌。 */
const id = window.setTimeout(pushTheme, 0)
return () => window.clearTimeout(id)
}, [loaded, pushTheme])
const reload = () => {
setLoaded(false)
frameRef.current?.contentWindow?.location.reload()
}
const src = `${SKU_ORIGIN}/?embed=1&view=${encodeURIComponent(view)}&theme=${initialTheme}`
return (
<div className="page-container">
<div className="page-container embed-shell">
<div className="page-header">
<div>
<h1 className="page-title">{meta.title}</h1>
@@ -41,31 +106,16 @@ function EmbeddedSku({ view = 'search' }) {
{meta.sub}
</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-sm" onClick={() => frameRef.current?.contentWindow?.location.reload()}>
刷新
</button>
<a className="btn btn-sm" href={src} target="_blank" rel="noreferrer" style={{ textDecoration: 'none' }}>
新窗口打开
</a>
</div>
<button type="button" className="embed-refresh" onClick={reload} title="重新加载" aria-label="重新加载">
<RefreshIcon size={16} />
</button>
</div>
<div
style={{
position: 'relative',
height: 'calc(100vh - 210px)',
minHeight: '560px',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
overflow: 'hidden',
background: 'var(--surface)'
}}
>
<div className="embed-frame">
{!loaded && (
<div className="loading" style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
<div className="spinner"></div>
<span style={{ marginLeft: '12px' }}>正在加载 SKU 模块…</span>
<span style={{ marginLeft: '12px' }}>正在加载{meta.title}…</span>
</div>
)}
<iframe
+7
View File
@@ -224,6 +224,13 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: non
.app-container { min-height: 100vh; }
.main-content { min-height: 0; overflow: hidden; padding: 0; }
.route-content { min-height: calc(100vh - var(--header-height)); overflow-y: auto; padding: 32px clamp(18px, 3.6vw, 52px) 56px; }
/* 嵌入页(合同导入):让 iframe 撑满路由区,只留一层滚动。
宿主和嵌入页各有一条滚动条时,「嵌进来」的感觉最明显。 */
.route-content:has(.embed-shell) { display: flex; flex-direction: column; height: calc(100vh - var(--header-height)); min-height: 0; overflow: hidden; padding-bottom: 0; }
.embed-shell { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.embed-frame { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; border-radius: var(--radius-lg) var(--radius-lg) 0 0; background: var(--bg-primary); }
.embed-refresh { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 32px; height: 32px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-tertiary); cursor: pointer; transition: color var(--transition-fast), background var(--transition-fast); }
.embed-refresh:hover { background: var(--surface-hover); color: var(--text-primary); }
.global-header { position: sticky; top: 0; z-index: 25; display: flex; align-items: center; gap: 22px; min-height: var(--header-height); padding: 0 clamp(18px, 3.6vw, 52px); background: color-mix(in srgb, var(--bg-primary) 90%, transparent); border-bottom: 1px solid var(--border-light); backdrop-filter: blur(18px) saturate(1.25); }
.global-header-brand { display: flex; align-items: center; gap: 13px; min-width: 240px; }
.global-header-brand strong, .global-header-brand span { display: block; }