Add multi-session auth and changelog tracking

这个提交包含在:
cryptocommuniums-afk
2026-03-15 17:30:19 +08:00
父节点 c4ec397ed3
当前提交 a9ea94fb78
修改 27 个文件,包含 1280 行新增89 行删除

查看文件

@@ -0,0 +1,242 @@
export type ActionType =
| "forehand"
| "backhand"
| "serve"
| "volley"
| "overhead"
| "slice"
| "lob"
| "unknown";
export type Point = {
x: number;
y: number;
visibility?: number;
};
export type TrackingState = {
prevTimestamp?: number;
prevRightWrist?: Point;
prevLeftWrist?: Point;
prevHipCenter?: Point;
};
export type ActionObservation = {
action: ActionType;
confidence: number;
};
export type ActionFrame = {
action: ActionType;
confidence: number;
};
export const ACTION_LABELS: Record<ActionType, string> = {
forehand: "正手挥拍",
backhand: "反手挥拍",
serve: "发球",
volley: "截击",
overhead: "高压",
slice: "切削",
lob: "挑高球",
unknown: "未知动作",
};
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function distance(a?: Point, b?: Point) {
if (!a || !b) return 0;
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
function getAngle(a?: Point, b?: Point, c?: Point) {
if (!a || !b || !c) return 0;
const radians = Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(a.y - b.y, a.x - b.x);
let angle = Math.abs((radians * 180) / Math.PI);
if (angle > 180) angle = 360 - angle;
return angle;
}
export function recognizeActionFrame(landmarks: Point[], tracking: TrackingState, timestamp: number): ActionFrame {
const nose = landmarks[0];
const leftShoulder = landmarks[11];
const rightShoulder = landmarks[12];
const leftElbow = landmarks[13];
const rightElbow = landmarks[14];
const leftWrist = landmarks[15];
const rightWrist = landmarks[16];
const leftHip = landmarks[23];
const rightHip = landmarks[24];
const leftKnee = landmarks[25];
const rightKnee = landmarks[26];
const leftAnkle = landmarks[27];
const rightAnkle = landmarks[28];
const hipCenter = {
x: ((leftHip?.x ?? 0.5) + (rightHip?.x ?? 0.5)) / 2,
y: ((leftHip?.y ?? 0.7) + (rightHip?.y ?? 0.7)) / 2,
};
const dtMs = tracking.prevTimestamp ? Math.max(16, timestamp - tracking.prevTimestamp) : 33;
const rightSpeed = distance(rightWrist, tracking.prevRightWrist) * (1000 / dtMs);
const leftSpeed = distance(leftWrist, tracking.prevLeftWrist) * (1000 / dtMs);
const hipSpeed = distance(hipCenter, tracking.prevHipCenter) * (1000 / dtMs);
const rightVerticalMotion = tracking.prevRightWrist ? tracking.prevRightWrist.y - (rightWrist?.y ?? tracking.prevRightWrist.y) : 0;
const shoulderTilt = Math.abs((leftShoulder?.y ?? 0.3) - (rightShoulder?.y ?? 0.3));
const hipTilt = Math.abs((leftHip?.y ?? 0.55) - (rightHip?.y ?? 0.55));
const headOffset = Math.abs((nose?.x ?? 0.5) - (((leftShoulder?.x ?? 0.45) + (rightShoulder?.x ?? 0.55)) / 2));
const kneeBend = ((getAngle(leftHip, leftKnee, leftAnkle) || 165) + (getAngle(rightHip, rightKnee, rightAnkle) || 165)) / 2;
const rightElbowAngle = getAngle(rightShoulder, rightElbow, rightWrist) || 145;
const leftElbowAngle = getAngle(leftShoulder, leftElbow, leftWrist) || 145;
const footSpread = Math.abs((leftAnkle?.x ?? 0.42) - (rightAnkle?.x ?? 0.58));
const shoulderSpan = Math.abs((rightShoulder?.x ?? 0.56) - (leftShoulder?.x ?? 0.44));
const wristSpread = Math.abs((rightWrist?.x ?? 0.62) - (leftWrist?.x ?? 0.38));
const shoulderCenterX = ((leftShoulder?.x ?? 0.45) + (rightShoulder?.x ?? 0.55)) / 2;
const torsoOffset = Math.abs(shoulderCenterX - hipCenter.x);
const rightForward = (rightWrist?.x ?? shoulderCenterX) - hipCenter.x;
const leftForward = hipCenter.x - (leftWrist?.x ?? shoulderCenterX);
const contactHeight = hipCenter.y - (rightWrist?.y ?? hipCenter.y);
const landmarkVisibility = landmarks
.filter((item) => typeof item?.visibility === "number")
.map((item) => item.visibility as number);
const averageVisibility = landmarkVisibility.length > 0
? landmarkVisibility.reduce((sum, item) => sum + item, 0) / landmarkVisibility.length
: 0.8;
tracking.prevTimestamp = timestamp;
tracking.prevRightWrist = rightWrist;
tracking.prevLeftWrist = leftWrist;
tracking.prevHipCenter = hipCenter;
if (averageVisibility < 0.58 || shoulderSpan < 0.08 || footSpread < 0.05 || headOffset > 0.26) {
return { action: "unknown", confidence: 0.28 };
}
const serveConfidence = clamp(
rightVerticalMotion * 2.2 +
Math.max(0, (hipCenter.y - (rightWrist?.y ?? hipCenter.y)) * 3.4) +
(rightWrist?.y ?? 1) < (nose?.y ?? 0.3) ? 0.34 : 0 +
rightElbowAngle > 145 ? 0.12 : 0 -
shoulderTilt * 1.8,
0,
1,
);
const overheadConfidence = clamp(
serveConfidence * 0.62 +
((rightWrist?.y ?? 1) < (nose?.y ?? 0.3) ? 0.22 : 0) +
(rightSpeed > 0.34 ? 0.16 : 0) -
(kneeBend < 150 ? 0.08 : 0),
0,
1,
);
const forehandConfidence = clamp(
(rightSpeed * 1.5) +
Math.max(0, rightForward * 2.3) +
(rightElbowAngle > 120 ? 0.1 : 0) +
(hipSpeed > 0.07 ? 0.08 : 0) +
(footSpread > 0.12 ? 0.05 : 0) -
shoulderTilt * 1.1,
0,
1,
);
const backhandConfidence = clamp(
(leftSpeed * 1.45) +
Math.max(0, leftForward * 2.15) +
(leftElbowAngle > 118 ? 0.1 : 0) +
(wristSpread > shoulderSpan * 1.2 ? 0.08 : 0) +
(torsoOffset > 0.04 ? 0.06 : 0),
0,
1,
);
const volleyConfidence = clamp(
((rightSpeed + leftSpeed) * 0.8) +
(footSpread < 0.12 ? 0.12 : 0) +
(kneeBend < 155 ? 0.12 : 0) +
(Math.abs(contactHeight) < 0.16 ? 0.1 : 0) +
(hipSpeed > 0.08 ? 0.08 : 0),
0,
1,
);
const sliceConfidence = clamp(
forehandConfidence * 0.68 +
((rightWrist?.y ?? 0.5) > hipCenter.y ? 0.12 : 0) +
(contactHeight < 0.05 ? 0.1 : 0),
0,
1,
);
const lobConfidence = clamp(
overheadConfidence * 0.55 +
((rightWrist?.y ?? 1) < (leftShoulder?.y ?? 0.3) ? 0.14 : 0) +
(hipSpeed < 0.08 ? 0.06 : 0),
0,
1,
);
const candidates = ([
["serve", serveConfidence],
["overhead", overheadConfidence],
["forehand", forehandConfidence],
["backhand", backhandConfidence],
["volley", volleyConfidence],
["slice", sliceConfidence],
["lob", lobConfidence],
] as Array<[ActionType, number]>).sort((left, right) => right[1] - left[1]);
const [action, confidence] = candidates[0] || ["unknown", 0];
if (confidence < 0.45) {
return { action: "unknown", confidence: clamp(confidence, 0.18, 0.42) };
}
return { action, confidence: clamp(confidence, 0, 1) };
}
export function stabilizeActionFrame(frame: ActionFrame, history: ActionObservation[]) {
const nextHistory = [...history, { action: frame.action, confidence: frame.confidence }].slice(-6);
history.splice(0, history.length, ...nextHistory);
const weights = nextHistory.map((_, index) => index + 1);
const scores = nextHistory.reduce<Record<ActionType, number>>((acc, sample, index) => {
acc[sample.action] = (acc[sample.action] || 0) + sample.confidence * weights[index];
return acc;
}, {
forehand: 0,
backhand: 0,
serve: 0,
volley: 0,
overhead: 0,
slice: 0,
lob: 0,
unknown: 0,
});
const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]) as Array<[ActionType, number]>;
const [winner = "unknown", winnerScore = 0] = ranked[0] || [];
const [, runnerScore = 0] = ranked[1] || [];
const winnerSamples = nextHistory.filter((sample) => sample.action === winner);
const averageConfidence = winnerSamples.length > 0
? winnerSamples.reduce((sum, sample) => sum + sample.confidence, 0) / winnerSamples.length
: frame.confidence;
const stableAction =
winner === "unknown" && frame.action !== "unknown" && frame.confidence >= 0.52
? frame.action
: winnerScore - runnerScore < 0.2 && frame.confidence >= 0.65
? frame.action
: winner;
return {
action: stableAction,
confidence: clamp(stableAction === frame.action ? Math.max(frame.confidence, averageConfidence) : averageConfidence, 0, 1),
};
}

80
client/src/lib/changelog.ts 普通文件
查看文件

@@ -0,0 +1,80 @@
export type ChangeLogEntry = {
version: string;
releaseDate: string;
repoVersion: string;
summary: string;
features: string[];
tests: string[];
};
export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
{
version: "2026.03.15-session-changelog",
releaseDate: "2026-03-15",
repoVersion: "pending-commit",
summary: "多端 session、更新日志页面、录制动作摘要与上海时区显示同步收口。",
features: [
"用户名登录生成独立 sid,同一账号多端登录保持并行有效",
"新增 /changelog 页面和侧边栏入口,展示版本、仓库版本和验证记录",
"训练进度页可展开查看最近训练记录的具体时间、动作统计和录制有效性",
"录制页增加动作抽样摘要、无效录制标记与 media 预归档状态",
"Dashboard、任务中心、管理台、评分、日志、视觉测试、视频库等页面统一使用 Asia/Shanghai 时间显示",
],
tests: [
"pnpm check",
"pnpm test",
"pnpm test:go",
"pnpm build",
"Playwright smoke: https://te.hao.work/ 双上下文登录 H1 后 dashboard 均保持有效;线上 /changelog 仍显示旧构建,待部署后复测",
],
},
{
version: "2026.03.15-recorder-zoom",
releaseDate: "2026-03-15",
repoVersion: "c4ec397",
summary: "补齐录制页与实时分析页的节省流量模式、镜头缩放和移动端控制。",
features: [
"在线录制默认切换为节省流量模式",
"在线录制支持镜头焦距放大缩小",
"实时分析支持镜头焦距放大缩小",
"页面内增加拍摄与流量设置说明",
],
tests: [
"pnpm check",
"pnpm exec vitest run client/src/lib/media.test.ts client/src/lib/camera.test.ts",
"Playwright 真实站点检查 /live-camera 与 /recorder 新控件可见",
],
},
{
version: "2026.03.15-videos-crud",
releaseDate: "2026-03-15",
repoVersion: "bd89981",
summary: "视频库支持新增、编辑、删除训练视频记录。",
features: [
"视频库新增外部视频登记",
"视频库支持编辑标题和动作类型",
"视频库支持删除视频及关联分析引用",
"视频详情读取按当前用户权限收敛",
],
tests: [
"pnpm check",
"pnpm exec vitest run server/features.test.ts -t \"video\\\\.\"",
"Playwright 真实站点完成 /videos 新增-编辑-删除全链路",
],
},
{
version: "v3.0.0",
releaseDate: "2026-03-14",
repoVersion: "历史版本",
summary: "教程库、提醒、通知等学习能力上线。",
features: [
"训练视频教程库",
"教程自评与学习进度",
"训练提醒通知",
"通知历史管理",
],
tests: [
"教程库、提醒、通知相关测试通过",
],
},
];

查看文件

@@ -14,6 +14,12 @@ export type ArchiveStatus =
| "completed"
| "failed";
export type PreviewStatus =
| "idle"
| "processing"
| "ready"
| "failed";
export type MediaMarker = {
id: string;
type: string;
@@ -29,6 +35,7 @@ export type MediaSession = {
title: string;
status: MediaSessionStatus;
archiveStatus: ArchiveStatus;
previewStatus: PreviewStatus;
format: string;
mimeType: string;
qualityPreset: string;
@@ -37,8 +44,10 @@ export type MediaSession = {
reconnectCount: number;
uploadedSegments: number;
uploadedBytes: number;
previewSegments: number;
durationMs: number;
lastError?: string;
previewUpdatedAt?: string;
streamConnected: boolean;
lastStreamAt?: string;
playback: {

57
client/src/lib/time.ts 普通文件
查看文件

@@ -0,0 +1,57 @@
const APP_TIME_ZONE = "Asia/Shanghai";
type DateLike = string | number | Date | null | undefined;
function toDate(value: DateLike) {
if (value == null) return null;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date;
}
export function formatDateTimeShanghai(
value: DateLike,
options?: Intl.DateTimeFormatOptions,
) {
const date = toDate(value);
if (!date) return "";
return date.toLocaleString("zh-CN", {
timeZone: APP_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: options?.second,
...options,
});
}
export function formatDateShanghai(
value: DateLike,
options?: Intl.DateTimeFormatOptions,
) {
const date = toDate(value);
if (!date) return "";
return date.toLocaleDateString("zh-CN", {
timeZone: APP_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
...options,
});
}
export function formatMonthDayShanghai(value: DateLike) {
const date = toDate(value);
if (!date) return "";
return date.toLocaleDateString("zh-CN", {
timeZone: APP_TIME_ZONE,
month: "short",
day: "numeric",
});
}
export function getAppTimeZoneLabel() {
return APP_TIME_ZONE;
}