Browse Source
按每日 17:50 提交纪律,精确分阶段 git add(未用 -A / 未用 git rm), tsc --noEmit 0 错误。涵盖本日及近期累积改动。 | 模块 | 主要文件 | 变更要点 | 状态 | |---|---|---|---| | 构建/发布 | eas.json, app.config.js | EAS 构建/OTA 骨架(runtimeVersion/updates/channel、iOS·Android 包名) | 完成·占位待注入 | | 依赖治理 | package.json, yarn.lock, package-lock.json(删) | 移 web 脚本、统一 yarn、删陈旧 lock | 完成 | | 微信登录 | wechat-sdk.ts, useWechatLogin.ts, useWechatBindPhone.ts, wechat-auth.ts, P2WechatCode.tsx, DeletedAccountModal.tsx | 三分支状态机(new/active/deleted)+注销不可复活 | mock 完成·真实SDK 待开放平台申请 | | 企业端 | enterprise-chat.tsx, enterprise-overview.tsx, talent-recommendation.tsx | 快捷入口话术统一 + 沟通页快捷操作 onPress 接线 | 完成 | | 通关小游戏 | src/api/api.ts | 7 关内容增厚至 22 题(单选/多选/输入混排) | 完成 | | UI 技术债 | profile/audit-report/game/legal.tsx | as any → Ionicons 类型化、路由类型化 | 完成 | | 滑块验证/注销 | SliderCaptcha.tsx, ScreenShell.tsx, settings/account-cancel.tsx, index.tsx | 滑块验证 + 账号注销流程接线 | 完成 | | 注册链路 | register.tsx, P1Login.tsx, P2PhoneCode.tsx, AuthContext/RoleContext | 微信分支 + 手机号验证整合 | 完成 | | 路由审计 | scripts/check-routes.mjs | 死链/失效跳转自检工具 | 完成 | | 文档 | 项目开发状态.md, 微信登录接入方案.md | 状态盘点 + 接入路线图 | 完成 |master
47 changed files with 1919 additions and 94 deletions
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 287 KiB |
@ -0,0 +1,134 @@ |
|||
import { useEffect, useRef, useState } from 'react'; |
|||
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; |
|||
import { colors } from '../../../src/theme'; |
|||
import { useOnboarding } from '../../../src/hooks'; |
|||
import { ScreenShell } from '../ui/ScreenShell'; |
|||
import { PrimaryButton } from '../ui/PrimaryButton'; |
|||
|
|||
const RESEND_SECONDS = 60; |
|||
|
|||
/** P2W 微信登录短信验证码页:×关闭 + 4格验证码 + 重新发送倒计时 + 语音/收不到短信入口 */ |
|||
export function P2WechatCode() { |
|||
const { data, navigate, back } = useOnboarding(); |
|||
const [code, setCode] = useState(''); |
|||
const [left, setLeft] = useState(RESEND_SECONDS); |
|||
const timer = useRef<ReturnType<typeof setInterval> | null>(null); |
|||
|
|||
useEffect(() => { |
|||
timer.current = setInterval(() => { |
|||
setLeft((l) => { |
|||
if (l <= 1) { |
|||
if (timer.current) clearInterval(timer.current); |
|||
return 0; |
|||
} |
|||
return l - 1; |
|||
}); |
|||
}, 1000); |
|||
return () => { |
|||
if (timer.current) clearInterval(timer.current); |
|||
}; |
|||
}, []); |
|||
|
|||
const phone = data.phone; |
|||
/** 尾号:有真实手机号用尾4位,否则演示 9367 */ |
|||
const tail = phone.length === 11 ? phone.slice(7) : '9367'; |
|||
const canNext = code.length >= 4; |
|||
|
|||
const resend = () => { |
|||
if (left > 0) return; |
|||
setLeft(RESEND_SECONDS); |
|||
setCode(''); |
|||
timer.current = setInterval(() => { |
|||
setLeft((l) => { |
|||
if (l <= 1) { |
|||
if (timer.current) clearInterval(timer.current); |
|||
return 0; |
|||
} |
|||
return l - 1; |
|||
}); |
|||
}, 1000); |
|||
}; |
|||
|
|||
return ( |
|||
<ScreenShell showBack onBack={back} backIcon="close"> |
|||
<Text style={styles.h1}>输入短信验证码</Text> |
|||
<Text style={styles.subtitle}>已向您的手机 {tail} 发送验证码</Text> |
|||
|
|||
{/* 4 格分离输入:透明输入层覆盖,格子仅做展示 */} |
|||
<Pressable style={styles.cellsWrap}> |
|||
<View style={styles.cellsRow}> |
|||
{[0, 1, 2, 3].map((i) => ( |
|||
<View key={i} style={styles.cell}> |
|||
<Text style={styles.cellText}>{code[i] ?? ''}</Text> |
|||
<View style={[styles.cellLine, code.length === i && styles.cellLineActive]} /> |
|||
</View> |
|||
))} |
|||
</View> |
|||
<TextInput |
|||
style={styles.hiddenInput} |
|||
value={code} |
|||
onChangeText={(t) => setCode(t.replace(/[^\d]/g, '').slice(0, 4))} |
|||
keyboardType="number-pad" |
|||
maxLength={4} |
|||
autoFocus |
|||
/> |
|||
</Pressable> |
|||
|
|||
<Pressable onPress={resend} hitSlop={6} disabled={left > 0}> |
|||
<Text style={[styles.resend, left > 0 && styles.resendDisabled]}> |
|||
{left > 0 ? `重新发送(${left}s)` : '重新发送'} |
|||
</Text> |
|||
</Pressable> |
|||
|
|||
<View style={styles.btnArea}> |
|||
<PrimaryButton label="下一步" active={canNext} onPress={() => navigate('B1')} /> |
|||
</View> |
|||
|
|||
<View style={styles.helpRow}> |
|||
<Pressable hitSlop={6}> |
|||
<Text style={styles.helpText}>语音接听验证码</Text> |
|||
</Pressable> |
|||
<Pressable hitSlop={6}> |
|||
<Text style={styles.helpText}>接收不到短信</Text> |
|||
</Pressable> |
|||
</View> |
|||
|
|||
<View style={styles.toastBar}> |
|||
<Text style={styles.toastText}>短信验证码正在发送,请注意查收</Text> |
|||
</View> |
|||
</ScreenShell> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
h1: { fontSize: 24, fontWeight: '800', color: colors.textPrimary, marginTop: 14 }, |
|||
subtitle: { fontSize: 14, color: colors.textSecondary, marginTop: 12 }, |
|||
cellsWrap: { marginTop: 34 }, |
|||
cellsRow: { flexDirection: 'row', gap: 18 }, |
|||
cell: { flex: 1, alignItems: 'center' }, |
|||
cellText: { fontSize: 26, fontWeight: '700', color: colors.textPrimary, minHeight: 36 }, |
|||
cellLine: { width: '100%', height: 2, backgroundColor: colors.border, marginTop: 8 }, |
|||
cellLineActive: { backgroundColor: colors.primary }, |
|||
hiddenInput: { |
|||
...StyleSheet.absoluteFill, |
|||
opacity: 0, |
|||
}, |
|||
resend: { textAlign: 'center', fontSize: 14, color: colors.textSecondary, marginTop: 22 }, |
|||
resendDisabled: { color: colors.textHint }, |
|||
btnArea: { marginTop: 30 }, |
|||
helpRow: { |
|||
flexDirection: 'row', |
|||
justifyContent: 'space-between', |
|||
marginTop: 22, |
|||
}, |
|||
helpText: { fontSize: 13, color: colors.textHint }, |
|||
toastBar: { |
|||
alignSelf: 'center', |
|||
marginTop: 30, |
|||
paddingHorizontal: 18, |
|||
paddingVertical: 10, |
|||
borderRadius: 8, |
|||
backgroundColor: colors.borderLight, |
|||
}, |
|||
toastText: { fontSize: 13, color: colors.textDisabled }, |
|||
}); |
|||
@ -0,0 +1,49 @@ |
|||
import { Pressable, StyleSheet, Text, View } from 'react-native'; |
|||
import { colors } from '../../../src/theme'; |
|||
import { Modal } from './Modal'; |
|||
|
|||
interface Props { |
|||
visible: boolean; |
|||
/** 关闭弹窗,停留在登录页不做任何操作 */ |
|||
onClose: () => void; |
|||
} |
|||
|
|||
/** |
|||
* 账号注销审核期提示弹窗(微信登录第三种场景)。 |
|||
* |
|||
* 账号处于注销审核期时不可登录、不可复活,因此只提供「知道了」, |
|||
* 不提供任何重新注册出口;关闭后停留在登录页。 |
|||
*/ |
|||
export function DeletedAccountModal({ visible, onClose }: Props) { |
|||
return ( |
|||
<Modal visible={visible} onClose={onClose}> |
|||
<Text style={styles.title}>无法登录</Text> |
|||
<Text style={styles.message}>该账号正在申请注销中,无法登录。</Text> |
|||
<View style={styles.btnRow}> |
|||
<Pressable style={[styles.btn, styles.confirmBtn]} onPress={onClose} hitSlop={4}> |
|||
<Text style={styles.confirmText}>知道了</Text> |
|||
</Pressable> |
|||
</View> |
|||
</Modal> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
title: { |
|||
fontSize: 16, |
|||
fontWeight: '600', |
|||
color: colors.textPrimary, |
|||
textAlign: 'center', |
|||
marginBottom: 10, |
|||
}, |
|||
message: { fontSize: 14, lineHeight: 22, color: colors.textSecondary, textAlign: 'center' }, |
|||
btnRow: { marginTop: 20 }, |
|||
btn: { |
|||
height: 46, |
|||
borderRadius: 23, |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
}, |
|||
confirmBtn: { backgroundColor: colors.primary }, |
|||
confirmText: { fontSize: 16, fontWeight: '700', color: colors.textInverse }, |
|||
}); |
|||
@ -0,0 +1,306 @@ |
|||
import { useCallback, useEffect, useRef, useState } from 'react'; |
|||
import { Ionicons } from '@expo/vector-icons'; |
|||
import { PanResponder, Pressable, StyleSheet, Text, View } from 'react-native'; |
|||
import type { ImageSourcePropType } from 'react-native'; |
|||
import { |
|||
Circle, |
|||
ClipPath, |
|||
Defs, |
|||
G, |
|||
Image as SvgImage, |
|||
LinearGradient, |
|||
Path, |
|||
Rect, |
|||
Stop, |
|||
Svg, |
|||
} from 'react-native-svg'; |
|||
import { colors } from '../../../src/theme'; |
|||
import { Modal } from './Modal'; |
|||
import { CAPTCHA_BACKGROUNDS } from '../assets'; |
|||
|
|||
interface Props { |
|||
visible: boolean; |
|||
/** 拼图块对齐缺口时判定通过 */ |
|||
onSuccess: () => void; |
|||
/** 点遮罩 / 关闭 */ |
|||
onClose: () => void; |
|||
} |
|||
|
|||
const KNOB = 44; |
|||
const AREA_H = 170; |
|||
|
|||
/** 生成经典拼图形状(四边各一个半圆凸起)的 SVG path */ |
|||
function jigsaw(x: number, y: number, w: number, h: number): string { |
|||
const r = w * 0.24; // 凸起半宽
|
|||
const d = w * 0.2; // 凸起深度
|
|||
const mx = x + w / 2; |
|||
const my = y + h / 2; |
|||
return [ |
|||
`M ${x} ${y}`, |
|||
`L ${mx - r} ${y}`, |
|||
`C ${mx - r} ${y - d}, ${mx + r} ${y - d}, ${mx + r} ${y}`, |
|||
`L ${x + w} ${y}`, |
|||
`L ${x + w} ${my - r}`, |
|||
`C ${x + w + d} ${my - r}, ${x + w + d} ${my + r}, ${x + w} ${my + r}`, |
|||
`L ${x + w} ${y + h}`, |
|||
`L ${mx + r} ${y + h}`, |
|||
`C ${mx + r} ${y + h + d}, ${mx - r} ${y + h + d}, ${mx - r} ${y + h}`, |
|||
`L ${x} ${y + h}`, |
|||
`L ${x} ${my + r}`, |
|||
`C ${x - d} ${my + r}, ${x - d} ${my - r}, ${x} ${my - r}`, |
|||
'Z', |
|||
].join(' '); |
|||
} |
|||
|
|||
interface Puzzle { |
|||
PW: number; |
|||
PH: number; |
|||
TY: number; |
|||
targetPieceX: number; |
|||
targetDx: number; |
|||
leftMargin: number; |
|||
pieceTravelRange: number; |
|||
} |
|||
|
|||
/** 滑块拼图验证弹窗:拖动滑块使拼图块对齐随机缺口,位置匹配即判定通过 */ |
|||
export function SliderCaptcha({ visible, onSuccess, onClose }: Props) { |
|||
const [areaW, setAreaW] = useState(0); |
|||
const [trackW, setTrackW] = useState(0); |
|||
const [dx, setDx] = useState(0); |
|||
const [passed, setPassed] = useState(false); |
|||
const [puzzle, setPuzzle] = useState<Puzzle | null>(null); |
|||
const [refreshKey, setRefreshKey] = useState(0); |
|||
/** 当前使用的背景图(每次 generate 随机从池中选取) */ |
|||
const [bg, setBg] = useState<ImageSourcePropType | null>(null); |
|||
const startX = useRef(0); |
|||
|
|||
const knobMaxDx = Math.max(0, trackW - KNOB - 4); |
|||
// 用 ref 持有最新状态,避免 PanResponder 闭包捕获首帧旧值(puzzle 为 null)导致滑块无法抢占手势、划不动
|
|||
const latest = useRef({ dx: 0, knobMaxDx: 0, passed: false, puzzle: null as Puzzle | null, onSuccess }); |
|||
latest.current = { dx, knobMaxDx, passed, puzzle, onSuccess }; |
|||
|
|||
// 测量完成后随机生成缺口位置、拼图块目标位移,并随机选取背景图
|
|||
const generate = useCallback(() => { |
|||
if (areaW <= 0 || trackW <= 0) return; |
|||
const maxDx = Math.max(10, trackW - KNOB - 4); |
|||
const PW = Math.min(Math.max(areaW * 0.28, 54), 96); |
|||
const PH = PW; |
|||
const leftMargin = 6; |
|||
const pieceTravelRange = Math.max(10, areaW - PW - leftMargin * 2); |
|||
const targetPieceX = leftMargin + 24 + Math.random() * Math.max(1, pieceTravelRange - 48); |
|||
const TY = 16 + Math.random() * Math.max(1, AREA_H - PH - 24); |
|||
const targetDx = ((targetPieceX - leftMargin) / pieceTravelRange) * maxDx; |
|||
setPuzzle({ PW, PH, TY, targetPieceX, targetDx, leftMargin, pieceTravelRange }); |
|||
setDx(0); |
|||
setPassed(false); |
|||
// 从背景图池中随机选一张
|
|||
if (CAPTCHA_BACKGROUNDS.length > 0) { |
|||
setBg(CAPTCHA_BACKGROUNDS[Math.floor(Math.random() * CAPTCHA_BACKGROUNDS.length)]); |
|||
} |
|||
}, [areaW, trackW]); |
|||
|
|||
useEffect(() => { |
|||
if (visible && areaW > 0 && trackW > 0) generate(); |
|||
}, [visible, areaW, trackW, refreshKey, generate]); |
|||
|
|||
const pieceX = |
|||
puzzle && knobMaxDx > 0 |
|||
? puzzle.leftMargin + (dx / knobMaxDx) * puzzle.pieceTravelRange |
|||
: puzzle?.leftMargin ?? 0; |
|||
|
|||
const pan = useRef( |
|||
PanResponder.create({ |
|||
onStartShouldSetPanResponder: () => !latest.current.passed && !!latest.current.puzzle, |
|||
onMoveShouldSetPanResponder: () => !latest.current.passed && !!latest.current.puzzle, |
|||
onPanResponderGrant: (e) => { |
|||
startX.current = e.nativeEvent.pageX; |
|||
}, |
|||
onPanResponderMove: (e) => { |
|||
const { puzzle: pz, knobMaxDx: max } = latest.current; |
|||
if (!pz) return; |
|||
const next = Math.min(Math.max(e.nativeEvent.pageX - startX.current, 0), max); |
|||
latest.current.dx = next; // 同步到 ref,保证松手判定用的是最终位移
|
|||
setDx(next); |
|||
}, |
|||
onPanResponderRelease: () => { |
|||
const { puzzle: pz, knobMaxDx: max, dx: cur, onSuccess: ok } = latest.current; |
|||
if (!pz) return; |
|||
const tol = Math.max(8, max * 0.045); |
|||
if (Math.abs(cur - pz.targetDx) <= tol) { |
|||
setDx(pz.targetDx); |
|||
setPassed(true); |
|||
setTimeout(ok, 400); |
|||
} else { |
|||
setDx(0); |
|||
} |
|||
}, |
|||
}), |
|||
).current; |
|||
|
|||
const reset = () => { |
|||
setDx(0); |
|||
setPassed(false); |
|||
}; |
|||
const refresh = () => { |
|||
reset(); |
|||
setRefreshKey((k) => k + 1); |
|||
}; |
|||
const close = () => { |
|||
reset(); |
|||
onClose(); |
|||
}; |
|||
|
|||
// 背景:有图片则裁切填充,否则用渐变 + 几何纹理占位(与拼图块共用同一来源保证对齐)
|
|||
const renderBg = (clip?: string) => |
|||
bg ? ( |
|||
<SvgImage |
|||
href={bg as number} |
|||
x={0} |
|||
y={0} |
|||
width={areaW} |
|||
height={AREA_H} |
|||
preserveAspectRatio="xMidYMid slice" |
|||
clipPath={clip} |
|||
/> |
|||
) : ( |
|||
<G clipPath={clip}> |
|||
<Rect x={0} y={0} width={areaW} height={AREA_H} fill="url(#capBg)" /> |
|||
<Circle cx={areaW * 0.25} cy={AREA_H * 0.3} r={34} fill={colors.white} opacity={0.12} /> |
|||
<Circle cx={areaW * 0.7} cy={AREA_H * 0.72} r={46} fill={colors.white} opacity={0.1} /> |
|||
<Circle cx={areaW * 0.86} cy={AREA_H * 0.2} r={20} fill={colors.white} opacity={0.14} /> |
|||
</G> |
|||
); |
|||
|
|||
return ( |
|||
<Modal visible={visible} onClose={onClose}> |
|||
{/* 拼图验证区:背景 + 缺口 + 可拖动拼图块 */} |
|||
<View style={styles.puzzleArea} onLayout={(e) => setAreaW(e.nativeEvent.layout.width)}> |
|||
{areaW > 0 && puzzle ? ( |
|||
<Svg width={areaW} height={AREA_H} style={styles.svg}> |
|||
<Defs> |
|||
<LinearGradient |
|||
id="capBg" |
|||
x1={0} |
|||
y1={0} |
|||
x2={areaW} |
|||
y2={AREA_H} |
|||
gradientUnits="userSpaceOnUse" |
|||
> |
|||
<Stop offset={0} stopColor={colors.primary} /> |
|||
<Stop offset={1} stopColor={colors.success} /> |
|||
</LinearGradient> |
|||
<ClipPath id="pieceClip"> |
|||
<Path d={jigsaw(pieceX, puzzle.TY, puzzle.PW, puzzle.PH)} /> |
|||
</ClipPath> |
|||
</Defs> |
|||
|
|||
{/* 静态背景 */} |
|||
{renderBg()} |
|||
|
|||
{/* 目标缺口(待填充位置) */} |
|||
<Path |
|||
d={jigsaw(puzzle.targetPieceX, puzzle.TY, puzzle.PW, puzzle.PH)} |
|||
fill={colors.scrim} |
|||
opacity={0.55} |
|||
stroke={colors.white} |
|||
strokeWidth={2} |
|||
/> |
|||
|
|||
{/* 可拖动拼图块 */} |
|||
<Path |
|||
d={jigsaw(pieceX + 2, puzzle.TY + 2, puzzle.PW, puzzle.PH)} |
|||
fill={colors.textPrimary} |
|||
opacity={0.18} |
|||
/> |
|||
{renderBg('url(#pieceClip)')} |
|||
<Path |
|||
d={jigsaw(pieceX, puzzle.TY, puzzle.PW, puzzle.PH)} |
|||
fill="none" |
|||
stroke={colors.primary} |
|||
strokeWidth={2.5} |
|||
/> |
|||
</Svg> |
|||
) : ( |
|||
<View style={styles.puzzlePlaceholder}> |
|||
<Text style={styles.puzzleHint}>拼图加载中…</Text> |
|||
</View> |
|||
)} |
|||
</View> |
|||
|
|||
{/* 滑块轨道 */} |
|||
<View style={styles.track} onLayout={(e) => setTrackW(e.nativeEvent.layout.width)}> |
|||
<Text style={[styles.trackHint, { opacity: dx > 8 || passed ? 0 : 1 }]}> |
|||
{passed ? '验证通过' : '拖动滑块完成拼图'} |
|||
</Text> |
|||
<View |
|||
style={[styles.knob, { transform: [{ translateX: dx }] }, passed && styles.knobPassed]} |
|||
{...pan.panHandlers} |
|||
> |
|||
<Ionicons |
|||
name={passed ? 'checkmark' : 'reorder-three'} |
|||
size={22} |
|||
color={passed ? colors.textInverse : colors.primary} |
|||
/> |
|||
</View> |
|||
</View> |
|||
|
|||
{/* 底部操作行 */} |
|||
<View style={styles.toolRow}> |
|||
<Pressable onPress={close} hitSlop={8}> |
|||
<Ionicons name="close-circle-outline" size={22} color={colors.textHint} /> |
|||
</Pressable> |
|||
<Pressable onPress={refresh} hitSlop={8}> |
|||
<Ionicons name="refresh" size={22} color={colors.textHint} /> |
|||
</Pressable> |
|||
<Ionicons name="volume-medium-outline" size={22} color={colors.textHint} /> |
|||
</View> |
|||
</Modal> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
puzzleArea: { |
|||
height: AREA_H, |
|||
borderRadius: 12, |
|||
backgroundColor: colors.primaryLight, |
|||
overflow: 'hidden', |
|||
}, |
|||
svg: { position: 'absolute', left: 0, top: 0 }, |
|||
puzzlePlaceholder: { |
|||
flex: 1, |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
}, |
|||
puzzleHint: { fontSize: 14, color: colors.textHint }, |
|||
track: { |
|||
height: 44, |
|||
borderRadius: 22, |
|||
backgroundColor: colors.borderLight, |
|||
marginTop: 16, |
|||
justifyContent: 'center', |
|||
overflow: 'hidden', |
|||
}, |
|||
trackHint: { textAlign: 'center', fontSize: 14, color: colors.textHint }, |
|||
knob: { |
|||
position: 'absolute', |
|||
left: 2, |
|||
top: 2, |
|||
width: KNOB, |
|||
height: KNOB, |
|||
borderRadius: 22, |
|||
backgroundColor: colors.white, |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
shadowColor: colors.textPrimary, |
|||
shadowOpacity: 0.15, |
|||
shadowRadius: 4, |
|||
elevation: 3, |
|||
}, |
|||
knobPassed: { backgroundColor: colors.success }, |
|||
toolRow: { |
|||
flexDirection: 'row', |
|||
justifyContent: 'space-around', |
|||
alignItems: 'center', |
|||
marginTop: 14, |
|||
}, |
|||
}); |
|||
@ -0,0 +1,21 @@ |
|||
{ |
|||
"cli": { |
|||
"version": ">= 16.0.0", |
|||
"appVersionSource": "local" |
|||
}, |
|||
"build": { |
|||
"development": { |
|||
"developmentClient": true, |
|||
"distribution": "internal" |
|||
}, |
|||
"preview": { |
|||
"distribution": "internal" |
|||
}, |
|||
"production": { |
|||
"autoIncrement": true |
|||
} |
|||
}, |
|||
"submit": { |
|||
"production": {} |
|||
} |
|||
} |
|||
@ -0,0 +1,102 @@ |
|||
/** |
|||
* 路由审计脚本:交叉校验代码中引用的路由 vs app/ 下实际存在的路由文件。 |
|||
* 用法:node scripts/check-routes.mjs |
|||
*/ |
|||
import fs from 'fs'; |
|||
import path from 'path'; |
|||
|
|||
const appDir = path.resolve('app'); |
|||
const routeFiles = []; |
|||
function walk(dir, rel = '') { |
|||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) { |
|||
const full = path.join(dir, e.name); |
|||
const relPath = rel ? rel + '/' + e.name : e.name; |
|||
if (e.isDirectory()) walk(full, relPath); |
|||
else if (/\.(tsx|ts|jsx|js)$/.test(e.name)) routeFiles.push(relPath); |
|||
} |
|||
} |
|||
walk(appDir); |
|||
|
|||
function toRoute(file) { |
|||
let r = file.replace(/\.(tsx|ts|jsx|js)$/, ''); |
|||
r = r.split('/').filter((s) => !/^\(.*\)$/.test(s)).join('/'); |
|||
r = r.replace(/\/index$/, ''); |
|||
return '/' + r.replace(/_layout$/, '').replace(/\/$/, ''); |
|||
} |
|||
const actualRoutes = new Set(routeFiles.map(toRoute).filter(Boolean)); |
|||
|
|||
function normalize(t) { |
|||
const clean = t.split('?')[0].replace(/\/$/, ''); |
|||
// 去掉路由组前缀:expo-router 允许写 "/(tabs)/jobs" 这类完整路径
|
|||
return clean.split('/').filter((s) => s && !/^\(.*\)$/.test(s)).join('/'); |
|||
} |
|||
|
|||
function routeExists(target) { |
|||
const clean = target.split('?')[0].replace(/\/$/, ''); |
|||
if (clean === '' || clean === 'index') return true; |
|||
if (actualRoutes.has(clean)) return true; |
|||
const n = normalize(clean); |
|||
for (const r of actualRoutes) { |
|||
const rn = normalize(r); |
|||
if (rn === n) return true; |
|||
const pattern = |
|||
'^' + rn.replace(/\[\[?\.\.\.(\w+)\]\]?/g, '.+').replace(/\[(\w+)\]/g, '[^/]+') + '$'; |
|||
if (new RegExp(pattern).test(n)) return true; |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
const srcDirs = ['app', 'components', 'src']; |
|||
const files = []; |
|||
function walkSrc(dir) { |
|||
if (!fs.existsSync(dir)) return; |
|||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) { |
|||
const full = path.join(dir, e.name); |
|||
if (e.isDirectory()) walkSrc(full); |
|||
else if (/\.(tsx|ts)$/.test(e.name)) files.push(full); |
|||
} |
|||
} |
|||
srcDirs.forEach(walkSrc); |
|||
|
|||
const routesFound = new Map(); |
|||
const reLocal = /['"`](\/[a-zA-Z0-9_()\-[\]/?=&.][a-zA-Z0-9_()\-[\]/?=&.]*)['"`]/g; |
|||
for (const f of files) { |
|||
const code = fs.readFileSync(f, 'utf8'); |
|||
let m; |
|||
while ((m = reLocal.exec(code)) !== null) { |
|||
const r = m[1]; |
|||
if (r.startsWith('//')) continue; |
|||
if (!routesFound.has(r)) routesFound.set(r, []); |
|||
routesFound.get(r).push(path.relative(process.cwd(), f).replace(/\\/g, '/')); |
|||
} |
|||
} |
|||
|
|||
console.log('=== 实际存在的路由 (' + actualRoutes.size + ') ==='); |
|||
console.log([...actualRoutes].sort().join('\n')); |
|||
console.log('\n=== 引用但不存在的路由 ==='); |
|||
let bad = 0; |
|||
for (const [r, srcs] of [...routesFound.entries()].sort()) { |
|||
if (r.includes('${')) continue; |
|||
if (/\.(png|jpg|jpeg|svg|json|ttf|css)$/i.test(r)) continue; |
|||
if (r.startsWith('/node_modules')) continue; |
|||
if (!routeExists(r)) { |
|||
console.log(`${r} <-- ${[...new Set(srcs)].join(', ')}`); |
|||
bad++; |
|||
} |
|||
} |
|||
console.log('\n不存在的路由数量: ' + bad); |
|||
|
|||
// 反向:孤立页面(没有任何入口指向)
|
|||
console.log('\n=== 无入口指向的孤立页面 ==='); |
|||
const referenced = new Set(); |
|||
function markRef(r) { |
|||
referenced.add(r); |
|||
referenced.add('/' + normalize(r)); |
|||
// 动态拼接的路径(如 '/chat/' + id)按前缀登记,前缀命中即视为已引用
|
|||
const n = normalize(r); |
|||
const seg = n.split('/'); |
|||
if (seg.length > 1) referenced.add('/' + seg.slice(0, seg.length - 1).join('/')); |
|||
} |
|||
for (const r of routesFound.keys()) markRef(r); |
|||
const orphans = [...actualRoutes].filter((r) => !referenced.has(r) && !referenced.has('/' + normalize(r))); |
|||
console.log(orphans.sort().join('\n') || '(无)'); |
|||
@ -0,0 +1,46 @@ |
|||
import { useCallback, useState } from 'react'; |
|||
import { bindWechatAndRegister } from '../api'; |
|||
import type { WechatBindPhonePayload, WechatSession } from '../types'; |
|||
import { useAuth } from '../context/AuthContext'; |
|||
|
|||
export interface UseWechatBindPhoneReturn { |
|||
/** 绑定手机号并完成注册,成功返回正式会话 */ |
|||
bind: (payload: WechatBindPhonePayload) => Promise<WechatSession>; |
|||
/** 绑定进行中 */ |
|||
loading: boolean; |
|||
/** 绑定失败的错误(用于页面提示) */ |
|||
error: Error | null; |
|||
} |
|||
|
|||
/** |
|||
* 微信新用户绑定手机号(第一次登录的第二步)。 |
|||
* |
|||
* 微信授权后 openid 尚未绑定账号,必须用手机号 + 短信验证码完成注册。 |
|||
* 成功后写入登录态;返回会话交给页面决定是继续走身份选择还是直接落地。 |
|||
*/ |
|||
export function useWechatBindPhone(): UseWechatBindPhoneReturn { |
|||
const { login } = useAuth(); |
|||
const [loading, setLoading] = useState(false); |
|||
const [error, setError] = useState<Error | null>(null); |
|||
|
|||
const bind = useCallback( |
|||
async (payload: WechatBindPhonePayload) => { |
|||
setLoading(true); |
|||
setError(null); |
|||
try { |
|||
const session = await bindWechatAndRegister(payload); |
|||
login(session.token, session.expiresAt); |
|||
return session; |
|||
} catch (e) { |
|||
const err = e instanceof Error ? e : new Error('绑定失败,请稍后重试'); |
|||
setError(err); |
|||
throw err; |
|||
} finally { |
|||
setLoading(false); |
|||
} |
|||
}, |
|||
[login], |
|||
); |
|||
|
|||
return { bind, loading, error }; |
|||
} |
|||
@ -0,0 +1,131 @@ |
|||
import { useCallback, useState } from 'react'; |
|||
import { fetchWechatLoginResult } from '../api'; |
|||
import type { AppRole, WechatPendingTicket, WechatUserProfile } from '../types'; |
|||
import { isWechatAvailable, requestWechatAuthCode } from '../lib/wechat-sdk'; |
|||
import { useAuth } from '../context/AuthContext'; |
|||
|
|||
/* ===================== 微信登录状态机 ===================== |
|||
* 编排三种账号场景的流转,页面只按 state.kind 渲染,不写判断逻辑。 |
|||
* |
|||
* idle ──start()──> 本地 token 有效?──是──> alreadyLoggedIn(免授权直接落地) |
|||
* │ |
|||
* 否 |
|||
* ↓ |
|||
* authenticating(调起微信) |
|||
* ↓ |
|||
* verifying(后端校验 code) |
|||
* ↓ |
|||
* ┌───────────────┼───────────────┐ |
|||
* new 分支 active 分支 deleted 分支 |
|||
* ↓ ↓ ↓ |
|||
* needBindPhone success deleted(提示无法登录) |
|||
* (跳手机号+验证码) │ |
|||
* ↓ cancelLogin() → idle |
|||
* 由 P2 页完成绑定后落地 |
|||
* |
|||
* 注意:注销审核期内的账号不可登录、不可复活,因此 deleted 分支没有「重新注册」出口。 |
|||
*/ |
|||
|
|||
/** 待绑定手机号时持有的临时凭证(原 code 已失效,只能用它标识身份) */ |
|||
export type PendingBindPhone = WechatPendingTicket; |
|||
|
|||
/** 微信登录状态(判别联合,页面通过 kind 收窄后读取对应字段) */ |
|||
export type WechatLoginState = |
|||
| { kind: 'idle' } |
|||
/** 正在调起微信 */ |
|||
| { kind: 'authenticating' } |
|||
/** 已拿到 code,正在请求后端校验 */ |
|||
| { kind: 'verifying' } |
|||
/** 本地 token 未过期,无需再走微信授权 */ |
|||
| { kind: 'alreadyLoggedIn' } |
|||
/** 第一次登录:openid 未绑定账号,需跳手机号 + 验证码完成注册 */ |
|||
| { kind: 'needBindPhone'; ticket: PendingBindPhone } |
|||
/** 账号处于注销审核期,不可登录 */ |
|||
| { kind: 'deleted' } |
|||
/** 登录成功;role 为 undefined 表示尚未选择身份,需引导去注册流程 */ |
|||
| { kind: 'success'; profile: WechatUserProfile; role?: AppRole } |
|||
/** 失败(未安装微信 / 用户取消 / 网络异常) */ |
|||
| { kind: 'error'; message: string }; |
|||
|
|||
export interface UseWechatLoginReturn { |
|||
state: WechatLoginState; |
|||
/** 是否处于请求中(用于按钮 loading 与防重复点击) */ |
|||
loading: boolean; |
|||
/** 入口:本地 token 有效则直接登录,否则走微信授权 */ |
|||
start: () => Promise<void>; |
|||
/** 关闭注销提示,回到登录页 */ |
|||
cancelLogin: () => void; |
|||
/** 重置为初始态 */ |
|||
reset: () => void; |
|||
} |
|||
|
|||
/** 初始态 */ |
|||
const IDLE: WechatLoginState = { kind: 'idle' }; |
|||
|
|||
export function useWechatLogin(): UseWechatLoginReturn { |
|||
const { login, isTokenValid } = useAuth(); |
|||
const [state, setState] = useState<WechatLoginState>(IDLE); |
|||
|
|||
const start = useCallback(async () => { |
|||
// 第二次及以后登录:本地令牌仍有效时免微信授权
|
|||
if (isTokenValid) { |
|||
setState({ kind: 'alreadyLoggedIn' }); |
|||
return; |
|||
} |
|||
|
|||
setState({ kind: 'authenticating' }); |
|||
try { |
|||
if (!isWechatAvailable()) { |
|||
throw new Error('未检测到微信客户端,请先安装微信'); |
|||
} |
|||
const code = await requestWechatAuthCode(); |
|||
|
|||
setState({ kind: 'verifying' }); |
|||
const result = await fetchWechatLoginResult(code); |
|||
|
|||
switch (result.status) { |
|||
case 'active': { |
|||
if (!result.profile) { |
|||
throw new Error('登录信息不完整,请重试'); |
|||
} |
|||
login(result.token, result.expiresAt); |
|||
setState({ kind: 'success', profile: result.profile, role: result.role }); |
|||
break; |
|||
} |
|||
case 'new': { |
|||
setState({ |
|||
kind: 'needBindPhone', |
|||
ticket: { token: result.token, openid: result.openid }, |
|||
}); |
|||
break; |
|||
} |
|||
case 'deleted': { |
|||
// 注销审核期:不写登录态,仅提示不可登录
|
|||
setState({ kind: 'deleted' }); |
|||
break; |
|||
} |
|||
} |
|||
} catch (e) { |
|||
setState({ |
|||
kind: 'error', |
|||
message: e instanceof Error ? e.message : '微信登录失败,请稍后重试', |
|||
}); |
|||
} |
|||
}, [isTokenValid, login]); |
|||
|
|||
const cancelLogin = useCallback(() => { |
|||
setState(IDLE); |
|||
}, []); |
|||
|
|||
const reset = useCallback(() => { |
|||
setState(IDLE); |
|||
}, []); |
|||
|
|||
return { |
|||
state, |
|||
loading: state.kind === 'authenticating' || state.kind === 'verifying', |
|||
start, |
|||
cancelLogin, |
|||
reset, |
|||
}; |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
import { Platform } from 'react-native'; |
|||
|
|||
/* ===================== 微信授权适配层 ===================== |
|||
* 把「调起微信并拿到一次性 code」这一平台能力单独隔离,原因: |
|||
* 1. 当前项目是 Expo 管理流,没有原生模块,Expo Go 中无法真正调起微信; |
|||
* 2. 两端(iOS / Android)的微信 SDK 初始化与回调方式不同,差异应收敛在此文件; |
|||
* 3. 接入真实 SDK 时只替换 requestWechatAuthCode 的函数体,上层 hook 与页面无需改动。 |
|||
* |
|||
* 接真实 SDK 的步骤(接入时才做,现阶段不要动): |
|||
* 1. npx expo install react-native-wechat-lib(或对应 expo 插件) |
|||
* 2. 微信开放平台创建移动应用,拿到 AppID,并登记 iOS 的 Universal Links |
|||
* (Universal Links 是 iOS 端微信回调的硬性要求,安卓走包名 + 应用签名) |
|||
* 3. 用 EAS Build 生成自定义开发客户端(Expo Go 不含原生模块,跑不了) |
|||
* 4. 把 requestWechatAuthCode 换成: |
|||
* const res = await WeChat.sendAuthRequest('snsapi_userinfo', 'wechat_auth_state'); |
|||
* return res.code; |
|||
*/ |
|||
|
|||
/** 微信是否可用:Expo Go 或未装微信时为 false */ |
|||
export function isWechatAvailable(): boolean { |
|||
// 真实 SDK 接入后改为 WeChat.isWXAppInstalled()
|
|||
return Platform.OS !== 'web'; |
|||
} |
|||
|
|||
/** |
|||
* 调起微信授权,返回一次性 code。 |
|||
* @throws 未安装微信、用户取消授权、或授权失败时抛错 |
|||
*/ |
|||
export async function requestWechatAuthCode(): Promise<string> { |
|||
/* |
|||
* mock 实现:返回带时间戳的假 code,仅用于打通前端三分支流程。 |
|||
* 真实实现见上方注释。 |
|||
*/ |
|||
return Promise.resolve(`mock_wx_code_${Date.now()}`); |
|||
} |
|||
@ -0,0 +1,98 @@ |
|||
import type { AppRole } from './user'; |
|||
|
|||
/** |
|||
* 微信登录相关类型 |
|||
* |
|||
* 覆盖三种账号场景(对应后端用 code 换 openid 后的判定结果): |
|||
* 1. new —— openid 不存在,第一次登录,需绑定手机号完成注册 |
|||
* 2. active —— openid 存在且账号活跃,第二次及以后登录,直接进入主界面 |
|||
* 3. deleted —— 账号处于注销审核期(申请注销后、彻底删除前),**不可登录** |
|||
* |
|||
* 关键约束一:微信授权返回的 code 是一次性的(有效期 5 分钟)。 |
|||
* 用户在绑定手机号时原 code 已被首次请求消耗,后端不能再用它换 openid, |
|||
* 因此后端必须在首次响应里下发可复用的临时凭证,由前端回传。 |
|||
* |
|||
* 关键约束二:注销审核期内账号不可登录、不可复活, |
|||
* 审核期结束后数据被彻底删除。前端不提供任何复活入口。 |
|||
*/ |
|||
|
|||
/** |
|||
* 微信账号在后端的状态。 |
|||
* 用联合类型而非布尔 isNewUser 建模,避免「注销中」被误判为「新老用户」之外的隐式状态。 |
|||
*/ |
|||
export type WechatAccountStatus = 'new' | 'active' | 'deleted'; |
|||
|
|||
/** |
|||
* 等待绑定手机号时持有的临时凭证。 |
|||
* 后端首次校验 code 后下发,用于绑定手机号时标识身份(此时 code 已失效)。 |
|||
*/ |
|||
export interface WechatPendingTicket { |
|||
/** 临时令牌,绑定手机号后换取正式 token */ |
|||
token: string; |
|||
/** 微信开放平台唯一标识 */ |
|||
openid: string; |
|||
} |
|||
|
|||
/** 微信登录后已注册用户的基本资料 */ |
|||
export interface WechatUserProfile { |
|||
/** 用户 id */ |
|||
id: string; |
|||
/** 昵称 */ |
|||
nickname: string; |
|||
/** 头像地址 */ |
|||
avatarUri: string; |
|||
/** 性别;unspecified 表示用户未授权或未填写 */ |
|||
gender: 'male' | 'female' | 'unspecified'; |
|||
} |
|||
|
|||
/** |
|||
* 微信登录结果(后端 POST /api/auth/wechat 的响应)。 |
|||
* |
|||
* 各字段按状态可选,页面通过 status 判别后再读取对应字段: |
|||
* - new:token 为临时凭证,profile 为 undefined |
|||
* - active:token 为正式登录令牌,profile 有值 |
|||
* - deleted:token 为临时凭证但不可用,仅用于标识账号,不得写入登录态 |
|||
*/ |
|||
export interface WechatLoginResult { |
|||
/** 账号状态 */ |
|||
status: WechatAccountStatus; |
|||
/** 登录令牌(active 为正式 token;new 为临时凭证;deleted 无意义) */ |
|||
token: string; |
|||
/** 微信开放平台唯一标识 */ |
|||
openid: string; |
|||
/** 已注册用户资料,仅 status === 'active' 时返回 */ |
|||
profile?: WechatUserProfile; |
|||
/** 正式令牌的过期时间戳(毫秒),仅 status === 'active' 时返回 */ |
|||
expiresAt?: number; |
|||
/** |
|||
* 已注册用户的端身份,仅 status === 'active' 时返回。 |
|||
* 老用户凭此直接落地对应端;为 undefined 表示后端未记录,前端需引导选择身份。 |
|||
*/ |
|||
role?: AppRole; |
|||
} |
|||
|
|||
/** 微信新用户绑定手机号完成注册(第一次登录的必经步骤) */ |
|||
export interface WechatBindPhonePayload { |
|||
/** 临时凭证令牌(code 已失效,只能用它标识身份) */ |
|||
token: string; |
|||
/** 微信开放平台唯一标识 */ |
|||
openid: string; |
|||
/** 手机号(11 位) */ |
|||
phone: string; |
|||
/** 短信验证码 */ |
|||
code: string; |
|||
} |
|||
|
|||
/** 绑定注册成功后返回的正式会话 */ |
|||
export interface WechatSession { |
|||
/** 正式登录令牌 */ |
|||
token: string; |
|||
/** 令牌过期时间戳(毫秒) */ |
|||
expiresAt: number; |
|||
/** 用户资料 */ |
|||
profile: WechatUserProfile; |
|||
/** |
|||
* 端身份。为 undefined 表示新用户尚未选择身份,前端需引导去注册流程选择。 |
|||
*/ |
|||
role?: AppRole; |
|||
} |
|||
@ -0,0 +1,151 @@ |
|||
# 微信登录接入方案(P1) |
|||
|
|||
> 状态:方案文档 · 不涉及代码改写 · 真实后端就绪后按路线图替换 mock 即可 |
|||
> 适用范围:有职 App 微信授权登录(三分支状态机) |
|||
> 关联文件:`src/lib/wechat-sdk.ts`、`src/hooks/useWechatLogin.ts`、`src/api/api.ts`、`src/types/wechat-auth.ts`、`components/onboarding/screens/P1Login.tsx`、`components/onboarding/screens/P2PhoneCode.tsx` |
|||
|
|||
--- |
|||
|
|||
## 0. 一句话结论 |
|||
|
|||
前端三分支状态机与页面流转**已全部联通**,当前唯一阻塞点是:微信原生 SDK 未接入(Expo Go 无法跑原生模块)、以及后端两个接口仍是 mock。 |
|||
**接入真实微信 = 替换 1 个函数 (`requestWechatAuthCode`) + 替换 2 个接口函数体 (`fetchWechatLoginResult` / `bindWechatAndRegister`) + 申请微信开放平台移动应用 + 用 EAS Build 生成自定义客户端。** 上层 hook、页面、类型**零改动**。 |
|||
|
|||
--- |
|||
|
|||
## 1. 现状盘点(已实现 vs 待接入) |
|||
|
|||
| 层 | 文件 | 现状 | 接入动作 | |
|||
|---|---|---|---| |
|||
| 类型 | `src/types/wechat-auth.ts` | ✅ 已完整定义 | 无需改动 | |
|||
| 状态机 | `src/hooks/useWechatLogin.ts` | ✅ 8 态判别联合,已联通 | 无需改动 | |
|||
| 页面流转 | `P1Login.tsx` → `P2PhoneCode.tsx` → `P3` | ✅ 已联通 | 无需改动 | |
|||
| 绑定 hook | `src/hooks/useWechatBindPhone.ts` | ✅ 已联通 | 无需改动 | |
|||
| 登录态 | `src/context/AuthContext.tsx` | ✅ `login(token, expiresAt)` / `isTokenValid` 已就绪 | 无需改动 | |
|||
| **SDK 适配层** | `src/lib/wechat-sdk.ts` | ⚠️ **mock**:返回 `mock_wx_code_*` | **替换 `requestWechatAuthCode`** | |
|||
| **后端接口** | `src/api/api.ts` | ⚠️ **mock**:`fetchWechatLoginResult` / `bindWechatAndRegister` | **替换函数体** | |
|||
| 微信开放平台 | —— | ❌ 未申请移动应用 | 申请 AppID + Universal Links | |
|||
| 构建产物 | EAS | ❌ 无自定义客户端 | `eas build --profile development` | |
|||
|
|||
**已验证的关键事实**(直接取自源码,非推测): |
|||
- `useWechatLogin` 的 `start()` 逻辑:`isTokenValid` 为真 → `alreadyLoggedIn`(免授权直接落地);否则 `authenticating` → `requestWechatAuthCode()` → `verifying` → `fetchWechatLoginResult(code)`,按 `result.status` 分流 `active` / `new` / `deleted`。 |
|||
- `new` 分支:`setState({ kind: 'needBindPhone', ticket: { token: result.token, openid: result.openid } })` —— **临时凭证已下发并透传**。 |
|||
- `P1Login.tsx` 在 `needBindPhone` 时把 `wechatTicket` 写入 `useOnboardingFlow` 的 `wechatTicket` 字段并跳 `P2`。 |
|||
- `P2PhoneCode.tsx` 检测到 `wechatTicket` 后调用 `bindWechatAndRegister({ token, openid, phone, code })` 完成注册。 |
|||
- `deleted` 分支:不写登录态,仅提示不可登录,**无复活出口**(符合账号注销即不可逆的硬约束)。 |
|||
|
|||
--- |
|||
|
|||
## 2. 接入目标与成功标准 |
|||
|
|||
**目标**:让真实微信用户能走通 `new / active / deleted` 三条链路,且: |
|||
1. 用户第一次用微信登录 → 跳手机号+验证码 → 注册落地; |
|||
2. 老用户再次微信登录 → 直接进主界面(本地 token 有效时甚至免授权); |
|||
3. 处于注销审核期的账号 → 登录页明确提示「该账号正在申请注销中,无法登录」,无复活入口。 |
|||
|
|||
**成功标准(验收口径)**: |
|||
- [ ] `requestWechatAuthCode()` 真正调起微信并回传一次性 `code`(非 mock)。 |
|||
- [ ] 后端 `/api/auth/wechat` 用 code 换 openid → 判定 `new/active/deleted` → 首响下发可复用临时凭证。 |
|||
- [ ] `bind-phone` 用临时凭证(非 code)完成 openid 与手机号绑定注册。 |
|||
- [ ] iOS 能在微信授权后通过 Universal Links 正确回调 App;Android 能通过包名+签名回调。 |
|||
- [ ] 三分支在自定义客户端(非 Expo Go)上全部走通。 |
|||
- [ ] mock 辅助 `MOCK_WECHAT_STATUS` / `setMockWechatAccountStatus` 已删除,页面无任何 mock 分支残留。 |
|||
|
|||
--- |
|||
|
|||
## 3. 两个硬约束(设计已遵守,接后端不可破坏) |
|||
|
|||
1. **微信 code 一次性(5 分钟有效)**:用户授权后原 code 已被首次请求消耗,绑定手机号时**拿不到第二个 code**。因此后端必须在首次响应里下发 `WechatPendingTicket`(含 `token` + `openid`),前端在 `P2` 回传票据而非 code。 |
|||
- 类型已建模:`WechatPendingTicket { token: string; openid: string }`(`src/types/wechat-auth.ts:29`)。 |
|||
2. **注销即不可登录、不可复活**:`deleted` 分支前端不写登录态、不提供任何复活入口;后端需保证审核期内账号返回 `deleted` 且拒绝任何登录/复活请求。 |
|||
|
|||
--- |
|||
|
|||
## 4. 接入路线图(按执行顺序) |
|||
|
|||
### Step 1 · 微信开放平台申请移动应用(前置,阻塞项) |
|||
- 登录微信开放平台 → 创建**移动应用**,拿到 `AppID` / `AppSecret`。 |
|||
- **iOS**:登记 **Universal Links**(微信授权回调硬性要求)。需准备一个 HTTPS 域名 + 后端托管 `apple-app-site-association`。 |
|||
- **Android**:登记 **应用包名**(`com.youzhi.app`,见 `app.config.js`)与**应用签名**(发布/调试签名 SHA256)。 |
|||
- ⚠️ 此步产生的 `AppID`、`Universal Links` 域名需回填到代码与 EAS 配置(见 Step 3/4)。 |
|||
|
|||
### Step 2 · 安装微信 SDK(Expo 插件优先) |
|||
```bash |
|||
npx expo install react-native-wechat-lib |
|||
# 或选用维护更活跃的 expo 兼容插件(以官方 docs 为准) |
|||
``` |
|||
- 原生模块需在 `ios/` `android/` 原生壳中注册(Expo 前置预构建会处理 config plugin 注入,无需手改原生代码)。 |
|||
|
|||
### Step 3 · 生成 EAS 自定义开发客户端(Expo Go 跑不了原生模块) |
|||
- 当前 `eas.json` 已有 `development` profile(`developmentClient: true`)。 |
|||
- 执行: |
|||
```bash |
|||
eas build --profile development --platform ios |
|||
eas build --profile development --platform android |
|||
``` |
|||
- 安装产物到真机/模拟器。**后续所有真机验证都在此客户端中进行,Expo Go 无法调起微信。** |
|||
|
|||
### Step 4 · 替换 SDK 适配层(唯一改动点 · 前端) |
|||
文件:`src/lib/wechat-sdk.ts` |
|||
- 顶部 `import WeChat from 'react-native-wechat-lib';` |
|||
- `isWechatAvailable()` 改为 `return WeChat.isWXAppInstalled();` |
|||
- `requestWechatAuthCode()` 替换为: |
|||
```ts |
|||
const res = await WeChat.sendAuthRequest('snsapi_userinfo', 'wechat_auth_state'); |
|||
return res.code; |
|||
``` |
|||
- `app.config.js` 的 `extra` 中补 `wechatAppId`,在 SDK 初始化时读取(或走 `expo-config-plugin` 注入原生 Info.plist / AndroidManifest)。 |
|||
- **此文件改动不影响任何上层调用方**(签名不变)。 |
|||
|
|||
### Step 5 · 实现后端两个接口 |
|||
| 接口 | 方法 | 职责 | 关键返回 | |
|||
|---|---|---|---| |
|||
| `/api/auth/wechat` | POST | `code → access_token + openid → 查库判定状态` | `WechatLoginResult`:`new` 时下发明文 `WechatPendingTicket`;`active` 下发正式 token + `expiresAt` + `profile` + `role`;`deleted` 仅 `openid` | |
|||
| `/api/auth/wechat/bind-phone` | POST | 用临时凭证 + 手机号 + 验证码完成注册,关联 openid | `WechatSession`:`token` + `expiresAt` + `profile` + `role` | |
|||
|
|||
- 后端须保证:`code` 一次性;临时凭证有独立 TTL 且可校验来源;`deleted` 账号拒绝登录。 |
|||
- `WechatLoginResult` / `WechatSession` 字段契约见 `src/types/wechat-auth.ts`,前后端以此对齐。 |
|||
|
|||
### Step 6 · 替换 API 层函数体(后端就绪后) |
|||
文件:`src/api/api.ts` |
|||
- `fetchWechatLoginResult(code)`:删掉 `MOCK_WECHAT_STATUS` 分支,改为 `fetch(...)`,请求 `/api/auth/wechat`,返回 `WechatLoginResult`。函数签名**不变**。 |
|||
- `bindWechatAndRegister(payload)`:改为请求 `/api/auth/wechat/bind-phone`,返回 `WechatSession`。签名**不变**。 |
|||
- 保留 JSDoc 的 `@method` / `@url` / `@param` / `@returns` 注释。 |
|||
|
|||
### Step 7 · 清除 mock 残留 |
|||
- 删除 `src/api/api.ts` 中的 `let MOCK_WECHAT_STATUS`、`setMockWechatAccountStatus()` 及其导出(同步从 `src/api/index.ts` 移除导出)。 |
|||
- 删除 `P1Login.tsx` / 测试代码中调用 `setMockWechatAccountStatus` 的分支(若有)。 |
|||
|
|||
### Step 8 · 回调与票据闭环验证 |
|||
- **iOS Universal Links**:确认微信开放平台登记的 Universal Links 能打开 App,且 `useWechatLogin` 未被重复触发(`alreadyLoggedIn` 短路逻辑生效)。 |
|||
- **Android 包名/签名**:确认微信回调能正确唤起 App 并继续 `verifying`。 |
|||
- 验证 `needBindPhone` → `wechatTicket` 透传 → `P2` 调用 `bindWechatAndRegister` 全程用的是**临时凭证 token**,而非已失效的 code。 |
|||
|
|||
--- |
|||
|
|||
## 5. 手动验收路径(可复现) |
|||
|
|||
1. 在自定义客户端(非 Expo Go)上安装并登录。 |
|||
2. **active 分支**:用老微信账号登录 → 期望直接进主界面;杀进程重进(本地 token 有效)→ 期望 `alreadyLoggedIn` 免授权落地。 |
|||
3. **new 分支**:用全新微信账号登录 → 期望跳 `P2` 手机号+验证码 → 注册成功进 `P3` 选身份。 |
|||
4. **deleted 分支**:用处于注销审核期的账号登录 → 期望登录页提示「该账号正在申请注销中,无法登录」,无复活入口。 |
|||
5. 断网 / 取消授权 / 未装微信 → 期望 `error` 态并显示对应提示。 |
|||
6. 回归 `tsc --noEmit` 与 `eslint .` 均 0 错误、0 警告。 |
|||
|
|||
--- |
|||
|
|||
## 6. 风险与待确认项 |
|||
|
|||
| 项 | 风险/阻塞 | 处置 | |
|||
|---|---|---| |
|||
| `AppID` / Universal Links | 需用户到微信开放平台申请,非代码可解 | Step 1 前置,未拿到前无法真机验证 | |
|||
| react-native-wechat-lib 维护状态 | 库可能滞后于新架构 | 接入时以 Expo 官方 docs 选型为准(AGENTS.md 要求先读 `docs.expo.dev/versions/v57.0.0/`) | |
|||
| Universal Links 配置 | iOS 回调硬性要求,易踩坑 | 后端托管 `apple-app-site-association` + 真机验证 | |
|||
| 自定义客户端构建 | 首次 EAS Build 耗时较长 | Step 3 提前跑,避免阻塞联调 | |
|||
| 临时凭证安全 | code 一次性,凭证必须独立加密 + 短 TTL | 后端实现时强制 | |
|||
|
|||
--- |
|||
|
|||
## 7. 一句话给执行者 |
|||
|
|||
**别动 hook、页面、类型。** 只动两处:`src/lib/wechat-sdk.ts`(换真 SDK 调用)和 `src/api/api.ts`(换真接口),配齐微信开放平台移动应用 + EAS 自定义客户端,三分支即可真机走通。 |
|||
@ -0,0 +1,112 @@ |
|||
# 有职 App 开发进度与规划文档 |
|||
|
|||
> 生成日期:2026-09-10 |
|||
> 最后更新:2026-09-10(EAS 构建/OTA 配置骨架已建 · 技术债 8 处 `any` 已清理) |
|||
> 范围:项目健康审查 + 已开发 / 待开发清单 |
|||
> 结论先行:**当前项目可运行**(`tsc` 0 错误、`eslint` 0 error、路由 0 失效跳转),无需修正阻塞错误。 |
|||
|
|||
--- |
|||
|
|||
## 一、健康审查结论 |
|||
|
|||
| 检查项 | 命令 / 方式 | 结果 | |
|||
|---|---|---| |
|||
| 类型检查 | `npx tsc --noEmit` | ✅ 0 错误(Exit 0) | |
|||
| 代码规范 | `npx eslint .` | ✅ 0 error / ⚠️ 8 warning(均为 `any` 软警告) | |
|||
| 路由闭合 | `node scripts/check-routes.mjs` | ✅ 63 路由全部存在,0 失效跳转 | |
|||
| 入口接线 | 读 `app/_layout.tsx` / `app/index.tsx` | ✅ 根栈 → 入口围栏 → 双端 Tab 完整 | |
|||
| 应用配置 | `app.config.js` | ✅ name「有职」、scheme `youzhi`、plugins 正确 | |
|||
|
|||
**判定**:项目处于可启动状态(`expo start` 即可运行),未发现阻塞运行的错误。本次未进入"修正"环节。 |
|||
|
|||
--- |
|||
|
|||
## 二、技术栈与架构 |
|||
|
|||
- **框架**:Expo SDK 57 · React Native 0.86.2 · React 19 · TypeScript 6 · Expo Router(文件路由) |
|||
- **双端同构**: |
|||
- 求职端 `(tabs)`:岗位 / 沟通 / 成长 / 我的(落地 `jobs`) |
|||
- 企业端 `(enterprise)`:岗位 / 人才 / 沟通 / 我的(落地 `job-management`) |
|||
- **分层(src/)**:`types`(领域模型) · `api`(5088 行 mock 数据层) · `hooks`(语义化取数) · `components`(42 组件) · `context`(Auth/Role) · `theme`(colors token) · `constants` · `utils` · `lib`(storage / wechat-sdk) |
|||
- **数据链路**:页面 → Hook(`useXxx`) → API(`fetchXxx`) → 后端。当前全 mock,签名已固化,接真实后端只改 `src/api/api.ts` 函数体。 |
|||
- **配色治理**:统一走 `src/theme/colors.ts`,禁止硬编码十六进制;透明度用 token + `15`/`20` 后缀。 |
|||
|
|||
--- |
|||
|
|||
## 三、已开发模块(按业务域) |
|||
|
|||
> 页面规模以行数为代理指标;5 行薄壳(如隐私政策 / 用户协议)是合理架构——内容由 `LegalDocScreen` 按 `docId` 加载,**非空壳**。 |
|||
|
|||
### 3.1 用户端(求职端) |
|||
|
|||
| 业务域 | 路由 | 关键页面(行数) | 规模 | 状态 | |
|||
|---|---|---|---|---| |
|||
| 引导 / 注册 | `/guide` `/register` `/onboarding` | guide(194) · register(69) · onboarding(488) | 中 | 已开发 | |
|||
| 岗位 | `/jobs` `/jobs/[id]` `/jobs/compare` `/jobs/rank` `/jobs/offer-compare` `/jobs/weight-settings` | jobs(756) · [id](612) · compare(306) · rank(753) · offer-compare(676) · weight-settings(285) | 大 | 已开发 | |
|||
| 沟通 | `/chat` `/chat/[id]` | chat(556) · [id](750) | 大 | 已开发 | |
|||
| 成长 | `/growth` `/game` `/game/level` `/progress` `/assessment` `/ability` `/holland*` `/interview*` `/resume*` `/job-preference` | 多页 300–600 行 | 大 | 已开发(逐关内容见四.2) | |
|||
| 我的 / 设置 | `/profile` `/settings` 及 10 个子页 | profile(743) · settings(262) · about / account / account-cancel / privacy* / notification / delete-data / user-agreement / privacy-policy | 大 | 已开发 | |
|||
| 增值 / 合规 / 工具 | `/legal*` `/tools/*` `/hr-evaluation` `/audit-report` `/appeal` `/operation-rules` `/enterprise-certification` `/personal-data-export` `/candidate-detail` `/article/[id]` `/history` `/favorites` `/job-evaluations` | 多页 300–720 行 | 大 | 已开发 | |
|||
|
|||
### 3.2 企业端 |
|||
|
|||
| 业务域 | 路由 | 关键页面(行数) | 规模 | 状态 | |
|||
|---|---|---|---|---| |
|||
| 岗位管理 | `/job-management` `/job-publish` `/job-evaluations` | job-management(645) · job-publish(633) · job-evaluations(752) | 大 | 已开发 | |
|||
| 人才 | `/talent-recommendation` `/candidate-detail` | talent-recommendation(684) · candidate-detail(719) | 大 | 已开发 | |
|||
| 沟通 | `/enterprise-chat` | enterprise-chat(611) | 大 | 已开发 | |
|||
| 我的 | `/enterprise-overview` | enterprise-overview(594) | 大 | 已开发(【设置】入口已接线 `/settings`;Tab 选中色已统一 `primary`) | |
|||
|
|||
> 企业端设置类页面复用根级 `app/settings/*`,符合「设置类统一收纳于【设置】页」规范。 |
|||
|
|||
--- |
|||
|
|||
## 四、待开发 / 进行中 / 风险 |
|||
|
|||
### 4.1 既定任务(来自项目规范,需逐项核实) |
|||
|
|||
- **企业端三项**(历史记录为待办,代码审查显示已基本完成): |
|||
1. Tab 选中色统一回 `primary` —— ✅ 代码已用 `colors.primary` |
|||
2. 我的页补【设置】入口 —— ✅ `enterprise-overview.tsx` 第 281 行已 `router.push('/settings')` |
|||
3. 统一快捷入口话术 —— ✅ 已完成(2026-09-10):`MOCK_ENTERPRISE_QUICK_ACTIONS` 与沟通页 `QUICK_ACTIONS` 中 6 处 label 已对齐规范词(人才库→人才推荐、评价管理→岗位评价管理、沟通→企业沟通、面试日程/面试安排→面试评分),与「我的」菜单、`/interview-score` 路由名一致。 |
|||
4. 沟通页快捷操作接线 —— ✅ 已完成(2026-09-10):`QuickActions` 死入口已绑定 `onPress`;批量发邀请→`/talent-recommendation`、面试评分→`/interview-score`(根级自包含页、可返回)走跳转,批量发Offer / 群发通知无对应企业端页、按项目约定弹 `Alert '功能开发中'`。tsc/eslint 0 问题。 |
|||
- **微信登录**:三分支(`new` / `active` / `deleted`)已建模,前端状态机 + 页面流转(P1Login → P2PhoneCode → P3)**已全联通**;`src/lib/wechat-sdk.ts` 隔离待接真实 SDK;需 EAS 自定义客户端 + 微信开放平台移动应用(iOS 另需 Universal Links)。当前为 mock 分支切换验证。**接入路线图已落文档:`微信登录接入方案.md`**。 |
|||
|
|||
### 4.2 功能完整性缺口(标注【待确认】为推断项,需与产品对齐) |
|||
|
|||
- **通关小游戏(7 关)**:框架页 `game.tsx` + `game/level.tsx` 已建,7 关(认识自己 → 能力 → 岗位 → 企业 → 简历 → 面试 → 签约)**内容与数据已完整**:`MOCK_GAME_LEVELS` 共 22 题(self 4 / 其余各 3),题型含单选/多选/输入,配套解析与 `tags` 用于岗位推荐。评分(≥60 通关)、解锁、进度聚合逻辑均在 `submitGameLevel` 实现。✅ 2026-09-10 完成内容增厚(每关由 1–2 题补至 3 题左右)。 |
|||
- **阿布阿诺 AI 法律服务**:免费层(咨询 / 模板 / FAQ)页已建;付费层 `legal/upgrade.tsx` 已建,但付费 / 支付链路 **【待确认】是否接通**。 |
|||
- **隐私政策 / 用户协议内容**:`src/api/api.ts` 中 `MOCK_PRIVACY_POLICY` / `MOCK_USER_AGREEMENT` 已含完整合规草案(各 9–10 章),**18 岁门槛已落地**(隐私政策第六章 + 用户协议前言)。仅余【待确认】占位待法务/产品替换:公司全称、注册地址、客服邮箱/电话、个人信息保护负责人邮箱、管辖法院。属**上架硬性审查项**,占位填妥即可提交审核。 |
|||
|
|||
### 4.3 工程与上线 |
|||
|
|||
- **应用图标**:`assets/icon.png` 仍为旧图,未替换为「有职」logo(需 1024×1024 PNG)【上架硬项】。 |
|||
- **EAS / OTA**:已创建 `eas.json`(development / preview / production + submit 配置骨架)并补齐 `app.config.js` 的 `owner` / `runtimeVersion` / `updates` / `ios.bundleIdentifier` / `android.package` / `extra.eas.projectId` 字段。占位项 `REPLACE_WITH_EAS_OWNER` 与 `REPLACE_WITH_PROJECT_ID` 需你 `eas login` 后执行 `eas init` 自动注入;`com.youzhi.app` 可按开发者账号调整。 |
|||
- **真实后端**:全量 mock(`src/api/api.ts` 5088 行),未切换真实 `fetch`。 |
|||
- **技术债**:8 处 `any` 软警告已全部清理(图标断言改为 `keyof typeof Ionicons.glyphMap`、路由断言改为 `as string`),`eslint .` 现 0 error 0 warning,`tsc --noEmit` 保持 0 错误。 |
|||
|
|||
--- |
|||
|
|||
## 五、后续增量开发建议(优先级) |
|||
|
|||
- **P0(上架前必做)**:图标替换【待你提供 1024×1024 logo】 · ~~隐私 / 协议内容填充~~ **隐私/协议草案【已存在,含 18 岁门槛,待填【待确认】占位】** · ~~EAS 初始化~~ **EAS 配置骨架【已完成,待账号注入占位】** · ~~18 岁门槛核对~~ **18 岁门槛【已在隐私政策第六章 + 用户协议前言落地】** · ~~微信登录接入评估~~ **微信登录接入评估【已完成 → 见 `微信登录接入方案.md`,SDK 接入阻塞于微信开放平台 AppID】**。 |
|||
- **P1(核心差异化)**:~~微信登录接入方案~~ **微信登录接入方案【已落文档,真实 SDK 接入阻塞于微信开放平台移动应用申请】** · ~~企业端快捷入口话术统一~~ **企业端快捷入口话术统一【已完成,6 处 label 对齐规范词;含沟通页快捷操作 onPress 接线,tsc/eslint 0 问题】** · 付费法律服务支付 · ~~通关小游戏 7 关内容~~ **通关小游戏 7 关内容【已完成:结构原已建成,本次增厚至 7 关 22 题,tsc/eslint 0 问题】**。 |
|||
- **P2(打磨)**:~~技术债清理~~ **技术债已清理(8 处 `any` → 明确类型,`eslint` 0 warning)** · 真实后端对接 · OTA 通道验证。 |
|||
|
|||
--- |
|||
|
|||
## 六、附:审查命令速查 |
|||
|
|||
```bash |
|||
# 类型检查(硬门槛,必须 0 错误) |
|||
npx tsc --noEmit |
|||
|
|||
# 代码规范 |
|||
npx eslint . |
|||
|
|||
# 路由闭合审计(改动路由后必跑) |
|||
node scripts/check-routes.mjs |
|||
|
|||
# 启动 |
|||
npm start # 即 expo start |
|||
``` |
|||
Loading…
Reference in new issue