feat: 门户接入企微网页授权——企微内静默免输身份领取,浏览器外保留手动输入
This commit is contained in:
@@ -547,6 +547,40 @@ input:focus {
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.wecom-identity-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--green-soft);
|
||||
}
|
||||
|
||||
.wecom-identity-chip b {
|
||||
color: var(--green-deep);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wecom-identity-chip span {
|
||||
color: var(--ink);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wecom-identity-chip button {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.recovery-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -107,11 +107,135 @@ function partnerApi(
|
||||
path:
|
||||
| "/api/partner"
|
||||
| "/api/partner-upload"
|
||||
| "/api/partner-batch-workbook",
|
||||
| "/api/partner-batch-workbook"
|
||||
| "/api/wecom-oauth",
|
||||
) {
|
||||
return `${resolveAdminOrigin()}${path}`;
|
||||
}
|
||||
|
||||
const WECOM_CORP_ID = process.env.NEXT_PUBLIC_WECOM_CORP_ID ?? "";
|
||||
const WECOM_AGENT_ID = process.env.NEXT_PUBLIC_WECOM_AGENT_ID ?? "";
|
||||
const WECOM_OAUTH_ENABLED = Boolean(WECOM_CORP_ID && WECOM_AGENT_ID);
|
||||
const WECOM_PORTAL_BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "/koc";
|
||||
const WECOM_IDENTITY_SESSION_KEY = "koc-wecom-identity";
|
||||
const WECOM_AUTH_FAILED_KEY = "koc-wecom-auth-failed";
|
||||
const WECOM_OAUTH_SID_PREFIX = "koc-wecom-oauth-sid";
|
||||
const WECOM_OAUTH_STATE_LIMIT = 120;
|
||||
|
||||
type WecomPortalIdentity = {
|
||||
ticket: string;
|
||||
name: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
type PortalParams = {
|
||||
task: string;
|
||||
claim: string;
|
||||
share: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
function readSessionValue<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSessionValue(key: string, value: unknown): boolean {
|
||||
try {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markSessionFlag(key: string) {
|
||||
try {
|
||||
window.sessionStorage.setItem(key, "1");
|
||||
} catch {
|
||||
// 隐私模式等场景下忽略,最多少一次自动授权
|
||||
}
|
||||
}
|
||||
|
||||
function hasSessionFlag(key: string) {
|
||||
try {
|
||||
return window.sessionStorage.getItem(key) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadWecomIdentity(): WecomPortalIdentity | null {
|
||||
const identity = readSessionValue<WecomPortalIdentity>(
|
||||
WECOM_IDENTITY_SESSION_KEY,
|
||||
);
|
||||
if (!identity?.ticket || typeof identity.expiresAt !== "number") return null;
|
||||
if (Date.now() > identity.expiresAt) return null;
|
||||
return identity;
|
||||
}
|
||||
|
||||
function isWecomEmbeddedBrowser() {
|
||||
const userAgent = window.navigator.userAgent;
|
||||
return /wxwork/i.test(userAgent) || /MicroMessenger/i.test(userAgent);
|
||||
}
|
||||
|
||||
function buildWecomAuthorizeUrl(state: string) {
|
||||
const query = new URLSearchParams({
|
||||
appid: WECOM_CORP_ID,
|
||||
redirect_uri: `${window.location.origin}${WECOM_PORTAL_BASE_PATH}/`,
|
||||
response_type: "code",
|
||||
scope: "snsapi_base",
|
||||
state,
|
||||
agentid: WECOM_AGENT_ID,
|
||||
});
|
||||
return `https://open.weixin.qq.com/connect/oauth2/authorize?${query}#wechat_redirect`;
|
||||
}
|
||||
|
||||
function buildWecomOAuthState(params: PortalParams): string {
|
||||
const compact = new URLSearchParams();
|
||||
if (params.task) compact.set("t", params.task);
|
||||
if (params.claim) compact.set("c", params.claim);
|
||||
if (params.share) compact.set("s", params.share);
|
||||
if (params.note) compact.set("n", params.note);
|
||||
if (compact.toString().length <= WECOM_OAUTH_STATE_LIMIT) {
|
||||
return compact.toString();
|
||||
}
|
||||
const sid = crypto.randomUUID().replaceAll("-", "").slice(0, 12);
|
||||
if (writeSessionValue(`${WECOM_OAUTH_SID_PREFIX}:${sid}`, params)) {
|
||||
return `sid=${sid}`;
|
||||
}
|
||||
const fallback = new URLSearchParams();
|
||||
if (params.task) fallback.set("t", params.task);
|
||||
if (params.share) fallback.set("s", params.share);
|
||||
return fallback.toString();
|
||||
}
|
||||
|
||||
function restoreParamsFromWecomState(state: string): PortalParams {
|
||||
const parsed = new URLSearchParams(state);
|
||||
const sid = parsed.get("sid");
|
||||
if (sid) {
|
||||
const key = `${WECOM_OAUTH_SID_PREFIX}:${sid}`;
|
||||
const stored = readSessionValue<PortalParams>(key);
|
||||
try {
|
||||
window.sessionStorage.removeItem(key);
|
||||
} catch {
|
||||
// 忽略清理失败
|
||||
}
|
||||
if (stored?.task || stored?.share) return stored;
|
||||
return { task: "", claim: "", share: "", note: "" };
|
||||
}
|
||||
return {
|
||||
task: parsed.get("t") ?? "",
|
||||
claim: parsed.get("c") ?? "",
|
||||
share: parsed.get("s") ?? "",
|
||||
note: parsed.get("n") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function isMobilePortalViewport() {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
@@ -325,6 +449,10 @@ export default function Home() {
|
||||
const [creatorViews, setCreatorViews] = useState("");
|
||||
const [entryMode, setEntryMode] = useState<"claim" | "recover">("claim");
|
||||
const [recoveredClaims, setRecoveredClaims] = useState<RecoveredClaim[]>([]);
|
||||
const [wecomIdentity, setWecomIdentity] = useState<WecomPortalIdentity | null>(
|
||||
null,
|
||||
);
|
||||
const [manualEntry, setManualEntry] = useState(false);
|
||||
const [adminOrigin, setAdminOrigin] = useState("");
|
||||
const [downloadingImage, setDownloadingImage] = useState<number | null>(null);
|
||||
const [batchDownloading, setBatchDownloading] = useState(false);
|
||||
@@ -401,10 +529,79 @@ export default function Home() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const exchangeWecomIdentity = useCallback(
|
||||
async (code: string, restored: PortalParams) => {
|
||||
try {
|
||||
const response = await fetch(partnerApi("/api/wecom-oauth"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
const result = (await response.json()) as {
|
||||
userType?: string;
|
||||
identityTicket?: string;
|
||||
displayName?: string;
|
||||
expiresIn?: number;
|
||||
error?: string;
|
||||
};
|
||||
if (
|
||||
response.ok &&
|
||||
result.userType === "external" &&
|
||||
result.identityTicket
|
||||
) {
|
||||
const identity: WecomPortalIdentity = {
|
||||
ticket: result.identityTicket,
|
||||
name: result.displayName || "企微客户",
|
||||
expiresAt:
|
||||
Date.now() +
|
||||
Math.max(60, (result.expiresIn ?? 1800) - 60) * 1_000,
|
||||
};
|
||||
writeSessionValue(WECOM_IDENTITY_SESSION_KEY, identity);
|
||||
setWecomIdentity(identity);
|
||||
setManualEntry(false);
|
||||
setToast(`企微身份已确认:${identity.name}`);
|
||||
} else {
|
||||
markSessionFlag(WECOM_AUTH_FAILED_KEY);
|
||||
setToast(
|
||||
result.userType === "internal"
|
||||
? "当前为企业内部成员身份,请手动输入微信号领取"
|
||||
: result.error || "企微授权失败,请手动输入微信号",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
markSessionFlag(WECOM_AUTH_FAILED_KEY);
|
||||
setToast("企微授权失败,请手动输入微信号");
|
||||
}
|
||||
setTaskToken(restored.task);
|
||||
setClaimToken(restored.claim);
|
||||
setDelegationToken(restored.share);
|
||||
setSelectedId(restored.note);
|
||||
if (!restored.task && !restored.share) {
|
||||
setLoading(false);
|
||||
setError("任务链接不完整,请向运营重新获取");
|
||||
return;
|
||||
}
|
||||
await loadTask(restored.task, restored.claim, restored.share);
|
||||
},
|
||||
[loadTask],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setAdminOrigin(resolveAdminOrigin());
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const oauthCode = params.get("code") ?? "";
|
||||
if (oauthCode) {
|
||||
const restored = restoreParamsFromWecomState(params.get("state") ?? "");
|
||||
const cleaned = new URL(window.location.href);
|
||||
cleaned.searchParams.delete("code");
|
||||
cleaned.searchParams.delete("state");
|
||||
window.history.replaceState({}, "", cleaned);
|
||||
void exchangeWecomIdentity(oauthCode, restored);
|
||||
return;
|
||||
}
|
||||
const cachedIdentity = loadWecomIdentity();
|
||||
if (cachedIdentity) setWecomIdentity(cachedIdentity);
|
||||
const task = params.get("task") ?? "";
|
||||
const claim = params.get("claim") ?? "";
|
||||
const delegation = params.get("share") ?? "";
|
||||
@@ -418,10 +615,28 @@ export default function Home() {
|
||||
setError("任务链接不完整,请向运营重新获取");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
WECOM_OAUTH_ENABLED &&
|
||||
!cachedIdentity &&
|
||||
!hasSessionFlag(WECOM_AUTH_FAILED_KEY) &&
|
||||
isWecomEmbeddedBrowser()
|
||||
) {
|
||||
window.location.replace(
|
||||
buildWecomAuthorizeUrl(
|
||||
buildWecomOAuthState({
|
||||
task,
|
||||
claim,
|
||||
share: delegation,
|
||||
note,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void loadTask(task, claim, delegation);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loadTask]);
|
||||
}, [loadTask, exchangeWecomIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
@@ -464,6 +679,9 @@ export default function Home() {
|
||||
action: "claim",
|
||||
taskToken,
|
||||
claimantIdentifier: claimantName,
|
||||
...(wecomIdentity && !manualEntry
|
||||
? { identityTicket: wecomIdentity.ticket }
|
||||
: {}),
|
||||
quantity,
|
||||
}),
|
||||
});
|
||||
@@ -512,6 +730,9 @@ export default function Home() {
|
||||
action: "recover",
|
||||
taskToken,
|
||||
claimantIdentifier: claimantName,
|
||||
...(wecomIdentity && !manualEntry
|
||||
? { identityTicket: wecomIdentity.ticket }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
const result = (await response.json()) as {
|
||||
@@ -2031,25 +2252,36 @@ export default function Home() {
|
||||
找回领取记录
|
||||
</button>
|
||||
</div>
|
||||
{wecomIdentity && !manualEntry && (
|
||||
<div className="wecom-identity-chip">
|
||||
<b>企微身份已确认</b>
|
||||
<span>{wecomIdentity.name}</span>
|
||||
<button type="button" onClick={() => setManualEntry(true)}>
|
||||
切换为手动输入
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{entryMode === "claim" ? (
|
||||
<form onSubmit={claimNotes}>
|
||||
<div>
|
||||
<p className="micro">01 · 确认领取</p>
|
||||
<h2>你要领取多少{payload.task.type === "screenshot_collect" ? "份" : "篇"}?</h2>
|
||||
</div>
|
||||
<label>
|
||||
<span>微信号 / 手机号</span>
|
||||
<input
|
||||
value={claimantName}
|
||||
onChange={(event) => setClaimantName(event.target.value)}
|
||||
placeholder="请输入微信号或手机号"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
maxLength={64}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{(!wecomIdentity || manualEntry) && (
|
||||
<label>
|
||||
<span>微信号 / 手机号</span>
|
||||
<input
|
||||
value={claimantName}
|
||||
onChange={(event) => setClaimantName(event.target.value)}
|
||||
placeholder="请输入微信号或手机号"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
maxLength={64}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="quantity-control">
|
||||
<button type="button" onClick={() => setQuantity(Math.max(1, quantity - 1))}>−</button>
|
||||
<div><strong>{quantity}</strong><span>{payload.task.type === "screenshot_collect" ? "份" : "篇"}</span></div>
|
||||
@@ -2080,22 +2312,26 @@ export default function Home() {
|
||||
<p className="micro">找回历史记录</p>
|
||||
<h2>继续之前领取的{payload.task.type === "screenshot_collect" ? "任务" : "笔记"}</h2>
|
||||
<p className="recover-intro">
|
||||
输入领取时使用的微信号或手机号;同一任务多次领取会分批展示。
|
||||
{wecomIdentity && !manualEntry
|
||||
? "已用企微身份查找,同一任务多次领取会分批展示。"
|
||||
: "输入领取时使用的微信号或手机号;同一任务多次领取会分批展示。"}
|
||||
</p>
|
||||
</div>
|
||||
<label>
|
||||
<span>领取时的微信号 / 手机号</span>
|
||||
<input
|
||||
value={claimantName}
|
||||
onChange={(event) => setClaimantName(event.target.value)}
|
||||
placeholder="需与领取时完全一致"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
maxLength={64}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{(!wecomIdentity || manualEntry) && (
|
||||
<label>
|
||||
<span>领取时的微信号 / 手机号</span>
|
||||
<input
|
||||
value={claimantName}
|
||||
onChange={(event) => setClaimantName(event.target.value)}
|
||||
placeholder="需与领取时完全一致"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
maxLength={64}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<button className="claim-primary" disabled={working}>
|
||||
{working ? "正在查找…" : "查找我的领取记录"}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user