Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84448b3064 | |||
| 386e0bce90 | |||
| b584188ce2 | |||
| 60cd28e085 |
@@ -22,14 +22,19 @@ rm -rf "${DIST}/user"
|
|||||||
mkdir -p "${DIST}/user"
|
mkdir -p "${DIST}/user"
|
||||||
cp -R "${UC}/." "${DIST}/user/"
|
cp -R "${UC}/." "${DIST}/user/"
|
||||||
|
|
||||||
# 2) 合并 _headers:用户中心路径规则前缀 /user,追加到官网 _headers 之后。
|
# 2) 合并 _headers 的 CSP。⚠️ CF Pages _headers 是【叠加】的:/* 与 /user/* 都匹配
|
||||||
# 删掉并入产物里那份会被 CF 忽略的 /user/_headers,避免误导。
|
# /user/,两条 CSP 都会下发,浏览器对多条 CSP 取【交集(最严)】→ 官网严格 CSP
|
||||||
|
# 的「style-src 无 unsafe-inline / connect-src 'self'」赢,用户中心的内联样式被拦、
|
||||||
|
# 连 API 被拦 → 页面裸奔且登录失败。故在 /user/* 里用 `! Content-Security-Policy`
|
||||||
|
# 先【删掉】/* 继承来的官网 CSP,再设用户中心自己的(须排在 /* 之后:先加后删)。
|
||||||
|
# 其余安全头(HSTS/X-Frame/…)/* 与用户中心一致,沿用 /* 即可,不重复。
|
||||||
rm -f "${DIST}/user/_headers"
|
rm -f "${DIST}/user/_headers"
|
||||||
{
|
{
|
||||||
echo ""
|
echo ""
|
||||||
echo "# ==== 用户中心(/user/*)独立 CSP,combine-site.sh 从 usercenter/_headers 重定 ===="
|
echo "# ==== 用户中心(/user/*):删掉官网 /* 继承的 CSP,换用用户中心自己的 ===="
|
||||||
# 行首 '/'(路径规则)前缀 /user;'#' 注释与缩进的 header 行不动。
|
echo "/user/*"
|
||||||
sed -E 's#^/#/user/#' "${UC}/_headers"
|
echo " ! Content-Security-Policy"
|
||||||
|
grep -iE '^[[:space:]]*Content-Security-Policy:' "${UC}/_headers"
|
||||||
} >> "${DIST}/_headers"
|
} >> "${DIST}/_headers"
|
||||||
|
|
||||||
echo "==> combine-site: done — 用户中心并入 ${DIST}/user/,_headers 已按 /user/* 分域合并"
|
echo "==> combine-site: done — 用户中心并入 ${DIST}/user/,_headers 已按 /user/* 分域合并"
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ func NewCORS() func(http.Handler) http.Handler {
|
|||||||
h.Set("Access-Control-Allow-Origin", origin)
|
h.Set("Access-Control-Allow-Origin", origin)
|
||||||
h.Add("Vary", "Origin")
|
h.Add("Vary", "Origin")
|
||||||
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
// X-Refresh-Token:静默续期/登出把 refresh token 放在此自定义头里
|
||||||
|
// (header token 方案,见 web/usercenter/lib/api/http.ts)。不放行则浏览器
|
||||||
|
// 预检拦截 /v1/auth/refresh → 登录后一刷新即掉线(login 不带此头故不受影响)。
|
||||||
|
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Refresh-Token")
|
||||||
h.Set("Access-Control-Max-Age", "600")
|
h.Set("Access-Control-Max-Age", "600")
|
||||||
}
|
}
|
||||||
// 预检请求直接 204(不落到业务路由,避免 /v1/... 的 OPTIONS 404)。
|
// 预检请求直接 204(不落到业务路由,避免 /v1/... 的 OPTIONS 404)。
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,6 +36,11 @@ func TestCORS(t *testing.T) {
|
|||||||
if w.Header().Get("Access-Control-Allow-Methods") == "" {
|
if w.Header().Get("Access-Control-Allow-Methods") == "" {
|
||||||
t.Fatalf("preflight missing Allow-Methods")
|
t.Fatalf("preflight missing Allow-Methods")
|
||||||
}
|
}
|
||||||
|
// 静默续期/登出把 refresh token 放 X-Refresh-Token 头,必须在允许头里,
|
||||||
|
// 否则浏览器预检拦截 /v1/auth/refresh → 登录后一刷新即掉线。
|
||||||
|
if ah := w.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(ah, "X-Refresh-Token") {
|
||||||
|
t.Fatalf("preflight Allow-Headers must include X-Refresh-Token, got %q", ah)
|
||||||
|
}
|
||||||
|
|
||||||
// 未白名单 Origin:不补 Allow-Origin。
|
// 未白名单 Origin:不补 Allow-Origin。
|
||||||
r = httptest.NewRequest("POST", "/v1/auth/login", nil)
|
r = httptest.NewRequest("POST", "/v1/auth/login", nil)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// shared.tsx — 公共样式常量与原子(承袭 ucapp.jsx 的 ucCard / ucInput / UCLang)
|
// shared.tsx — 公共样式常量与原子(承袭 ucapp.jsx 的 ucCard / ucInput / UCLang)
|
||||||
import React from 'react';
|
'use client';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import type { Lang } from '../lib/i18n';
|
import type { Lang } from '../lib/i18n';
|
||||||
|
|
||||||
export const card: React.CSSProperties = {
|
export const card: React.CSSProperties = {
|
||||||
@@ -22,34 +23,110 @@ export const input: React.CSSProperties = {
|
|||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LANGS: [Lang, string][] = [
|
||||||
|
['en', 'English'], ['zh', '中文'], ['ja', '日本語'],
|
||||||
|
['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// 自定义下拉:原生 <select> 的弹层由系统定位(macOS 锚在选中项、会"漂移"),
|
||||||
|
// 改成自控菜单(按钮 + 绝对定位列表),不漂移、样式统一、跨端一致。
|
||||||
export function LangSeg({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => void }) {
|
export function LangSeg({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => void }) {
|
||||||
// 6 语言用原生下拉(段控横排会挤)。默认 en。
|
const [open, setOpen] = useState(false);
|
||||||
const langs: [Lang, string][] = [
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
['en', 'English'], ['zh', '中文'], ['ja', '日本語'],
|
useEffect(() => {
|
||||||
['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'],
|
if (!open) return;
|
||||||
];
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', onDoc);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDoc);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const current = LANGS.find(([v]) => v === lang)?.[1] ?? String(lang);
|
||||||
return (
|
return (
|
||||||
<select
|
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
value={lang}
|
<button
|
||||||
onChange={(e) => setLang(e.target.value as Lang)}
|
type="button"
|
||||||
aria-label="Language"
|
onClick={() => setOpen((o) => !o)}
|
||||||
style={{
|
aria-haspopup="listbox"
|
||||||
// 圆角矩形 + 柔和边框(原纯 --border 太生硬)。
|
aria-expanded={open}
|
||||||
border: '1px solid color-mix(in oklab, var(--border), transparent 55%)',
|
aria-label="Language"
|
||||||
cursor: 'pointer',
|
style={{
|
||||||
borderRadius: 10,
|
display: 'inline-flex',
|
||||||
padding: '5px 10px',
|
alignItems: 'center',
|
||||||
fontFamily: 'var(--font-sans)',
|
gap: 6,
|
||||||
fontSize: 12.5,
|
border: '1px solid color-mix(in oklab, var(--border), transparent 55%)',
|
||||||
fontWeight: 700,
|
cursor: 'pointer',
|
||||||
background: 'var(--bg-subtle)',
|
borderRadius: 10,
|
||||||
color: 'var(--fg2)',
|
padding: '5px 10px',
|
||||||
boxShadow: '0 1px 2px rgba(45, 30, 20, 0.05)',
|
fontFamily: 'var(--font-sans)',
|
||||||
}}
|
fontSize: 12.5,
|
||||||
>
|
fontWeight: 700,
|
||||||
{langs.map(([v, l]) => (
|
background: 'var(--bg-subtle)',
|
||||||
<option key={v} value={v}>{l}</option>
|
color: 'var(--fg2)',
|
||||||
))}
|
boxShadow: '0 1px 2px rgba(45, 30, 20, 0.05)',
|
||||||
</select>
|
}}
|
||||||
|
>
|
||||||
|
<span>{current}</span>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true"
|
||||||
|
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 140ms' }}>
|
||||||
|
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 'calc(100% + 6px)',
|
||||||
|
right: 0,
|
||||||
|
minWidth: 132,
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: '0 10px 30px rgba(20, 12, 6, 0.22)',
|
||||||
|
padding: 5,
|
||||||
|
zIndex: 60,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{LANGS.map(([v, l]) => {
|
||||||
|
const on = v === lang;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={on}
|
||||||
|
onClick={() => { setLang(v); setOpen(false); }}
|
||||||
|
style={{
|
||||||
|
textAlign: 'left',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: on ? 'var(--accent-subtle, var(--bg-subtle))' : 'transparent',
|
||||||
|
color: on ? 'var(--accent)' : 'var(--fg1)',
|
||||||
|
fontWeight: on ? 700 : 500,
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 13,
|
||||||
|
padding: '8px 11px',
|
||||||
|
borderRadius: 8,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* 迁自 design/ui_kits/website/index.html 的 <header> + site.js 的菜单/滚动毛玻璃逻辑。
|
* 迁自 design/ui_kits/website/index.html 的 <header> + site.js 的菜单/滚动毛玻璃逻辑。
|
||||||
* 语言切换由原型的 JS 文本替换改为「路由跳转」(zh=/, en=/en/),单显不并排(铁律 6)。
|
* 语言切换由原型的 JS 文本替换改为「路由跳转」(zh=/, en=/en/),单显不并排(铁律 6)。
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Download, Menu } from 'lucide-react';
|
import { Download, Menu } from 'lucide-react';
|
||||||
import { SITE } from '../config/site';
|
import { SITE } from '../config/site';
|
||||||
|
|
||||||
@@ -29,6 +29,17 @@ function Mark() {
|
|||||||
export default function Header({ lang = 'zh', t = {} }) {
|
export default function Header({ lang = 'zh', t = {} }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
const [langOpen, setLangOpen] = useState(false);
|
||||||
|
const langRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!langOpen) return;
|
||||||
|
const onDoc = (e) => { if (langRef.current && !langRef.current.contains(e.target)) setLangOpen(false); };
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') setLangOpen(false); };
|
||||||
|
document.addEventListener('mousedown', onDoc);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };
|
||||||
|
}, [langOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||||
@@ -64,16 +75,36 @@ export default function Header({ lang = 'zh', t = {} }) {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="right">
|
<div class="right">
|
||||||
<select
|
<div ref={langRef} style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
class="langsel"
|
<button
|
||||||
value={lang}
|
type="button"
|
||||||
onChange={(e) => { window.location.href = langHref(e.target.value); }}
|
class="langsel"
|
||||||
aria-label="Language"
|
onClick={() => setLangOpen((o) => !o)}
|
||||||
>
|
aria-haspopup="listbox"
|
||||||
{langs.map(([code, label]) => (
|
aria-expanded={langOpen}
|
||||||
<option key={code} value={code}>{label}</option>
|
aria-label="Language"
|
||||||
))}
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||||
</select>
|
>
|
||||||
|
<span>{(langs.find(([c]) => c === lang) || ['', 'English'])[1]}</span>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true"
|
||||||
|
style={{ transform: langOpen ? 'rotate(180deg)' : 'none', transition: 'transform 140ms' }}>
|
||||||
|
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{langOpen && (
|
||||||
|
<div role="listbox" style={{ position: 'absolute', top: 'calc(100% + 6px)', right: 0, minWidth: 132, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 12, boxShadow: '0 10px 30px rgba(20, 12, 6, 0.22)', padding: 5, zIndex: 60, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{langs.map(([code, label]) => {
|
||||||
|
const on = code === lang;
|
||||||
|
return (
|
||||||
|
<a key={code} role="option" aria-selected={on} href={langHref(code)}
|
||||||
|
style={{ textAlign: 'left', textDecoration: 'none', background: on ? 'var(--accent-subtle, var(--bg-subtle))' : 'transparent', color: on ? 'var(--accent)' : 'var(--fg1)', fontWeight: on ? 700 : 500, fontFamily: 'var(--font-sans)', fontSize: 13, padding: '8px 11px', borderRadius: 8, whiteSpace: 'nowrap' }}>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<a class="linklogin" href={SITE.usercenter}>{t.login}</a>
|
<a class="linklogin" href={SITE.usercenter}>{t.login}</a>
|
||||||
<a class="btn btn-primary" href="#download">
|
<a class="btn btn-primary" href="#download">
|
||||||
<Download />
|
<Download />
|
||||||
|
|||||||
Reference in New Issue
Block a user