+
+
+
视觉标准图库
+
+ 用公网可访问的网球标准图验证多模态纠正链路,并持久化每次测试结果。
+
+
+
+ {user?.role === "admin" ? (
+
+ ) : null}
+
+
+
+
+ {user?.role === "admin" ? (
+
+
+ Admin 视角
+
+ 当前账号可查看全部视觉测试记录。若用户名为 `H1` 且被配置进 `ADMIN_USERNAMES`,登录后会自动拥有此视角。
+
+
+ ) : (
+
+
+ 个人测试视角
+ 当前页面展示标准图库,以及你自己提交的视觉测试结果。
+
+ )}
+
+ {activeTask.data?.status === "queued" || activeTask.data?.status === "running" ? (
+
+
+ 后台执行中
+ {activeTask.data.message || "视觉测试正在后台执行。"}
+
+ ) : null}
+
+
+
+
标准图片库
+ {references.length} 张
+
+
+ {references.map((reference) => (
+
+
+

+
+
+ {reference.title}
+
+ {reference.exerciseType}
+ {reference.license ? {reference.license} : null}
+
+
+
+ {reference.notes ? (
+ {reference.notes}
+ ) : null}
+ {reference.expectedFocus?.length ? (
+
+ {reference.expectedFocus.map((item) => (
+ {item}
+ ))}
+
+ ) : null}
+
+
+ 来源页
+
+
+
+
+
+ ))}
+
+
+
+
+
+
视觉测试记录
+ {runs.length} 条
+
+
+ {runs.map((run) => (
+
+
+
+
+
+
{run.title}
+ {statusBadge(run)}
+ {run.exerciseType}
+
+
+ {new Date(run.createdAt).toLocaleString("zh-CN")}
+ {user?.role === "admin" && run.userName ? ` · 提交人:${run.userName}` : ""}
+
+
+ {run.configuredModel ? (
+
{run.configuredModel}
+ ) : null}
+
+
+ {run.summary ? {run.summary}
: null}
+ {run.warning ? (
+ 降级说明:{run.warning}
+ ) : null}
+ {run.error ? (
+ 错误:{run.error}
+ ) : null}
+
+ {run.expectedFocus?.length ? (
+
+ {run.expectedFocus.map((item) => (
+ {item}
+ ))}
+
+ ) : null}
+
+ {run.corrections ? (
+
+ {run.corrections}
+
+ ) : null}
+
+
+ ))}
+ {runs.length === 0 ? (
+
+
+ 还没有视觉测试记录。先运行一张标准图测试,结果会自动入库并显示在这里。
+
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index c2e335f..7fb63d7 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -20,6 +20,7 @@
- 实时摄像头分析:浏览器端调用 MediaPipe,进行姿势识别和反馈展示
- 动作纠正:支持文本纠正和多模态纠正两条链路,统一通过后台任务执行
- 多模态图片输入:上传关键帧后会转换为公网可访问的绝对 URL,再提交给视觉模型
+- 视觉标准图库:内置网球公网参考图,可直接发起视觉识别测试并保存结果
- 视频库:集中展示录制结果、上传结果和分析摘要
### 在线录制与媒体链路
@@ -51,6 +52,7 @@
- 统一工作台导航
- 仪表盘、训练、视频、录制、分析等模块一致的布局结构
- 全局任务中心:桌面侧边栏和移动端头部都可查看后台任务
+- Admin 视觉测试页:`H1` 这类 admin 用户可查看全部视觉测试数据
- 为后续 PC 粗剪时间线预留媒体域与文档规范
## 架构能力
diff --git a/docs/testing.md b/docs/testing.md
index c730f33..5ffbd9b 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -21,6 +21,7 @@
- Node/tRPC 路由输入校验与权限检查
- LLM 模块请求配置与环境变量回退逻辑
- 视觉模型 per-request model override 能力
+- 视觉标准图库路由与 admin/H1 全量可见性逻辑
- 媒体工具函数,例如录制时长格式化与码率选择
### 3. Go 媒体服务测试
@@ -85,6 +86,14 @@ pnpm exec tsx -e 'import "dotenv/config"; import { invokeLLM } from "./server/_c
如果返回模型与 `LLM_VISION_MODEL` 不一致,说明上游网关忽略了视觉模型选择,业务任务会自动回退到文本纠正结果。
+视觉标准图库的真实 smoke test 可直接复用内置数据:
+
+- 初始化 `ADMIN_USERNAMES=H1`
+- 登录 `H1` 后访问 `/vision-lab`
+- 检查标准图是否已经入库
+- 运行单张或批量测试,确认结果会写入 `vision_test_runs`
+- 若上游视觉网关不可用,记录应显示 `fallback`
+
## Production smoke checks
部署到宿主机后,建议至少补以下联测:
diff --git a/docs/verified-features.md b/docs/verified-features.md
index a630232..db5c049 100644
--- a/docs/verified-features.md
+++ b/docs/verified-features.md
@@ -1,18 +1,19 @@
# Verified Features
-本文档记录当前已经通过自动化验证或构建验证的项目。更新时间:2026-03-15 00:11 CST。
+本文档记录当前已经通过自动化验证或构建验证的项目。更新时间:2026-03-15 00:40 CST。
## 最新完整验证记录
- 通过命令:`pnpm verify`
-- 验证时间:2026-03-15 00:10 CST
-- 结果摘要:`pnpm check` 通过,`pnpm test` 通过(80/80),`pnpm test:go` 通过,`pnpm build` 通过,`pnpm test:e2e` 通过(6/6),`pnpm test:llm` 通过
+- 验证时间:2026-03-15 00:39 CST
+- 结果摘要:`pnpm check` 通过,`pnpm test` 通过(85/85),`pnpm test:go` 通过,`pnpm build` 通过,`pnpm test:e2e` 通过(6/6),`pnpm test:llm` 通过
## 生产部署联测
| 项目 | 验证方式 | 状态 |
|------|----------|------|
| `https://te.hao.work/` HTTPS 访问 | `curl -I https://te.hao.work/` | 通过 |
+| `https://te.hao.work/vision-lab` 视觉测试页访问 | `curl -I https://te.hao.work/vision-lab` | 通过 |
| `http://te.hao.work:8302/` 4 位端口访问 | `curl -I http://te.hao.work:8302/` | 通过 |
| 站点 TLS 证书 | Let’s Encrypt ECDSA 证书已签发并由宿主机 nginx 加载 | 通过 |
| 生产登录与首次进入工作台 | Playwright 登录真实站点并跳转 `/dashboard` | 通过 |
@@ -79,6 +80,9 @@
| `.env` 中的 `LLM_API_URL` / `LLM_API_KEY` / `LLM_MODEL` | `pnpm test:llm` | 通过 |
| `https://one.hao.work/v1/chat/completions` 联通性 | `pnpm test:llm` 实际返回文本 | 通过 |
| 视觉模型独立配置路径 | `server/_core/llm.test.ts` + 手工 smoke 检查 | 通过 |
+| 视觉标准图库入库 | MySQL 中 `vision_reference_images` 已写入 5 张 Commons 网球参考图 | 通过 |
+| 视觉测试结果入库 | MySQL 中 `vision_test_runs` 已写入 3 条真实测试结果 | 通过 |
+| H1 全量可见性 | `H1` 用户已提升为 `admin`,可读取全部视觉测试记录;Playwright 真实站点检查通过 | 通过 |
## 已知非阻断警告
@@ -86,6 +90,7 @@
- `pnpm build` 仍有 Vite 大 chunk 警告;当前属于性能优化待办,不影响本次产物生成
- Playwright 运行依赖 mocked media/network,不等价于真机摄像头、真实弱网和真实 WebRTC 质量验收
- 当前上游视觉网关可能忽略 `LLM_VISION_MODEL` 并回退为文本模型;服务端已实现自动降级,任务不会因此直接失败
+- 2026-03-15 的真实标准图测试中,正手 / 反手 / 发球三条记录均以 `fallback` 完成,说明当前上游视觉网关仍未稳定返回结构化视觉结果
## 当前未纳入自动验证的内容
diff --git a/drizzle/0006_solid_vision_library.sql b/drizzle/0006_solid_vision_library.sql
new file mode 100644
index 0000000..682a225
--- /dev/null
+++ b/drizzle/0006_solid_vision_library.sql
@@ -0,0 +1,43 @@
+CREATE TABLE `vision_reference_images` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `slug` varchar(128) NOT NULL,
+ `title` varchar(256) NOT NULL,
+ `exerciseType` varchar(64) NOT NULL,
+ `imageUrl` text NOT NULL,
+ `sourcePageUrl` text NOT NULL,
+ `sourceLabel` varchar(128) NOT NULL,
+ `author` varchar(128),
+ `license` varchar(128),
+ `expectedFocus` json,
+ `tags` json,
+ `notes` text,
+ `sortOrder` int NOT NULL DEFAULT 0,
+ `isPublished` int NOT NULL DEFAULT 1,
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT `vision_reference_images_id` PRIMARY KEY(`id`),
+ CONSTRAINT `vision_reference_images_slug_unique` UNIQUE(`slug`)
+);
+--> statement-breakpoint
+CREATE TABLE `vision_test_runs` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `taskId` varchar(64) NOT NULL,
+ `userId` int NOT NULL,
+ `referenceImageId` int,
+ `title` varchar(256) NOT NULL,
+ `exerciseType` varchar(64) NOT NULL,
+ `imageUrl` text NOT NULL,
+ `status` enum('queued','succeeded','failed') NOT NULL DEFAULT 'queued',
+ `visionStatus` enum('pending','ok','fallback','failed') NOT NULL DEFAULT 'pending',
+ `configuredModel` varchar(128),
+ `expectedFocus` json,
+ `summary` text,
+ `corrections` text,
+ `report` json,
+ `warning` text,
+ `error` text,
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT `vision_test_runs_id` PRIMARY KEY(`id`),
+ CONSTRAINT `vision_test_runs_taskId_unique` UNIQUE(`taskId`)
+);
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 6ec2fc8..544eb7a 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -43,6 +43,13 @@
"when": 1773504000000,
"tag": "0005_lively_taskmaster",
"breakpoints": true
+ },
+ {
+ "idx": 6,
+ "version": "5",
+ "when": 1773510000000,
+ "tag": "0006_solid_vision_library",
+ "breakpoints": true
}
]
}
diff --git a/drizzle/schema.ts b/drizzle/schema.ts
index 49647b5..c39a637 100644
--- a/drizzle/schema.ts
+++ b/drizzle/schema.ts
@@ -334,3 +334,55 @@ export const backgroundTasks = mysqlTable("background_tasks", {
export type BackgroundTask = typeof backgroundTasks.$inferSelect;
export type InsertBackgroundTask = typeof backgroundTasks.$inferInsert;
+
+/**
+ * Vision reference library - canonical public tennis images used for multimodal evaluation
+ */
+export const visionReferenceImages = mysqlTable("vision_reference_images", {
+ id: int("id").autoincrement().primaryKey(),
+ slug: varchar("slug", { length: 128 }).notNull().unique(),
+ title: varchar("title", { length: 256 }).notNull(),
+ exerciseType: varchar("exerciseType", { length: 64 }).notNull(),
+ imageUrl: text("imageUrl").notNull(),
+ sourcePageUrl: text("sourcePageUrl").notNull(),
+ sourceLabel: varchar("sourceLabel", { length: 128 }).notNull(),
+ author: varchar("author", { length: 128 }),
+ license: varchar("license", { length: 128 }),
+ expectedFocus: json("expectedFocus"),
+ tags: json("tags"),
+ notes: text("notes"),
+ sortOrder: int("sortOrder").default(0).notNull(),
+ isPublished: int("isPublished").default(1).notNull(),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
+});
+
+export type VisionReferenceImage = typeof visionReferenceImages.$inferSelect;
+export type InsertVisionReferenceImage = typeof visionReferenceImages.$inferInsert;
+
+/**
+ * Vision test run history - records each multimodal evaluation against the standard library
+ */
+export const visionTestRuns = mysqlTable("vision_test_runs", {
+ id: int("id").autoincrement().primaryKey(),
+ taskId: varchar("taskId", { length: 64 }).notNull().unique(),
+ userId: int("userId").notNull(),
+ referenceImageId: int("referenceImageId"),
+ title: varchar("title", { length: 256 }).notNull(),
+ exerciseType: varchar("exerciseType", { length: 64 }).notNull(),
+ imageUrl: text("imageUrl").notNull(),
+ status: mysqlEnum("status", ["queued", "succeeded", "failed"]).default("queued").notNull(),
+ visionStatus: mysqlEnum("visionStatus", ["pending", "ok", "fallback", "failed"]).default("pending").notNull(),
+ configuredModel: varchar("configuredModel", { length: 128 }),
+ expectedFocus: json("expectedFocus"),
+ summary: text("summary"),
+ corrections: text("corrections"),
+ report: json("report"),
+ warning: text("warning"),
+ error: text("error"),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
+});
+
+export type VisionTestRun = typeof visionTestRuns.$inferSelect;
+export type InsertVisionTestRun = typeof visionTestRuns.$inferInsert;
diff --git a/server/_core/env.ts b/server/_core/env.ts
index 0529d10..cbeedc5 100644
--- a/server/_core/env.ts
+++ b/server/_core/env.ts
@@ -9,6 +9,12 @@ const parseBoolean = (value: string | undefined, fallback: boolean) => {
return value === "1" || value.toLowerCase() === "true";
};
+const parseList = (value: string | undefined) =>
+ (value ?? "")
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean);
+
export const ENV = {
appId: process.env.VITE_APP_ID ?? "",
appPublicBaseUrl: process.env.APP_PUBLIC_BASE_URL ?? "",
@@ -16,6 +22,7 @@ export const ENV = {
databaseUrl: process.env.DATABASE_URL ?? "",
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
+ adminUsernames: parseList(process.env.ADMIN_USERNAMES),
isProduction: process.env.NODE_ENV === "production",
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
diff --git a/server/_core/index.ts b/server/_core/index.ts
index 7841c7d..e6024fd 100644
--- a/server/_core/index.ts
+++ b/server/_core/index.ts
@@ -9,6 +9,7 @@ import { appRouter } from "../routers";
import { createContext } from "./context";
import { registerMediaProxy } from "./mediaProxy";
import { serveStatic } from "./static";
+import { seedTutorials, seedVisionReferenceImages } from "../db";
function isPortAvailable(port: number): Promise