Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 4 additions & 17 deletions archery/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# 射箭训练标靶生成系统(Cloudflare 可部署)

该项目是一个可直接部署的“前端 + 后端 + PDF”一体化实现,满足以下核心目标:
该项目是一个可直接部署的“前端 + PDF”一体化实现,满足以下核心目标:

- 前端动态配置参数并实时预览(SVG 矢量)
- 严格几何约束下自动求最优排布(最大化 `n × m`)
- 浏览器端生成可打印 PDF(矢量,毫米精确)
- Cloudflare Pages + Functions 无服务器部署
- Cloudflare Pages 无服务器部署

---

Expand Down Expand Up @@ -35,15 +35,6 @@
- 尺寸按 mm 精确映射到 pt(`72 / 25.4`)
- 支持下载打印

### 后端(Cloudflare Functions)

- `POST /api/layout`
- 入参:页面宽高、标靶直径、最小间距
- 出参:最优 `n/m/s/total` 或无解原因
- 前后端共用 `shared/layout.ts`,避免算法漂移

---

## 2. 关键几何约束

设页面尺寸为 `W × H`,标靶直径 `D`,列数 `n`,行数 `m`,统一间距/页边距 `s`,必须同时满足:
Expand All @@ -69,11 +60,8 @@ H = m·D + (m+1)·s

```text
archery/
├─ functions/
│ └─ api/
│ └─ layout.ts # Cloudflare Pages Function(后端API)
├─ shared/
│ └─ layout.ts # 前后端共用的排布算法
│ └─ layout.ts # 排布算法
├─ src/
│ ├─ main.ts # 前端页面与SVG实时预览
│ ├─ pdf.ts # PDF矢量导出
Expand Down Expand Up @@ -114,8 +102,7 @@ npm run build

1. 构建 `archery` 前端
2. 部署 `archery/dist` 静态资源
3. 一并部署 `archery/functions` 的 Serverless API
4. 当主分支部署时,自动确保生产域名为 `archery.taylorhere.com`
3. 当主分支部署时,自动确保生产域名为 `archery.taylorhere.com`

> 说明:`CLOUDFLARE_API_TOKEN` 需包含 Pages 项目与域名管理权限(至少覆盖 Pages 编辑权限)。

Expand Down
66 changes: 0 additions & 66 deletions archery/functions/api/layout.ts

This file was deleted.

2 changes: 1 addition & 1 deletion archery/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"dev:pages": "vite build && wrangler pages dev dist --functions functions"
"dev:pages": "vite build && wrangler pages dev dist"
},
"devDependencies": {
"typescript": "~5.9.3",
Expand Down
2 changes: 1 addition & 1 deletion archery/shared/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ export function ringColorByIndex(ringIndexFromOuter: number, startWithBlack: boo

export function ringScoreByIndex(ringIndexFromOuter: number, ringCount: number): number {
const safeRingCount = Math.max(1, Math.round(ringCount));
return safeRingCount - ringIndexFromOuter;
return Math.min(safeRingCount, ringIndexFromOuter + 1);
}

export function ringFillColor(
Expand Down
90 changes: 1 addition & 89 deletions archery/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ app.innerHTML = `
<span id="zoom-label">100%</span>
</label>
<button id="download-pdf" type="button">下载 PDF(矢量)</button>
<button id="verify-api" type="button" class="secondary">后端 API 复核</button>
</div>

<div class="result" id="result">
Expand Down Expand Up @@ -146,7 +145,6 @@ const refs = {
outTotal: getEl<HTMLElement>('out-total'),
outPage: getEl<HTMLElement>('out-page'),
downloadBtn: getEl<HTMLButtonElement>('download-pdf'),
verifyApiBtn: getEl<HTMLButtonElement>('verify-api'),
};

let currentLayout: LayoutSolution | null = null;
Expand Down Expand Up @@ -210,34 +208,6 @@ refs.downloadBtn.addEventListener('click', async () => {
refs.status.textContent = 'PDF 已生成:矢量图可直接打印,请使用 100% 缩放。';
});

refs.verifyApiBtn.addEventListener('click', async () => {
const input = buildLayoutInput();
refs.status.textContent = '正在请求后端 API 复核...';

try {
const response = await fetch('/api/layout', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
});

if (!response.ok) {
refs.status.textContent = `后端 API 异常:HTTP ${response.status}`;
return;
}

const data = (await response.json()) as { ok: boolean; layout: LayoutSolution | null; reason?: string };
if (!data.ok || !data.layout) {
refs.status.textContent = `后端复核:无可行解。${data.reason ? `原因:${data.reason}` : ''}`;
return;
}

refs.status.textContent = `后端复核成功:n=${data.layout.columns}, m=${data.layout.rows}, s=${data.layout.spacingMm.toFixed(4)}mm`;
} catch {
refs.status.textContent = '后端 API 不可达(本地前端计算仍可使用)。部署到 Cloudflare Pages 后可直接复核。';
}
});

function recalculate(editedField: EditedField = 'other'): void {
let input = buildLayoutInput();
let layout = solveOptimalLayout(input);
Expand Down Expand Up @@ -319,17 +289,7 @@ function autoAdjustToFeasible(input: LayoutInput, editedField: EditedField): str
}
}

const customPage = suggestNearestCustomPageForFixedDiameter(input);
if (!customPage) {
return null;
}

refs.pagePreset.value = 'custom';
refs.customWidth.value = `${customPage.widthMm.toFixed(1)}`;
refs.customHeight.value = `${customPage.heightMm.toFixed(1)}`;
toggleCustomSizeInputs();

return `已自动切换为自定义页面 ${customPage.widthMm.toFixed(1)} × ${customPage.heightMm.toFixed(1)} mm。`;
return null;
}

function getMaxStrictSpacingForCurrentDiameter(input: LayoutInput): number | null {
Expand Down Expand Up @@ -367,54 +327,6 @@ function getMaxStrictSpacingForCurrentDiameter(input: LayoutInput): number | nul
return Number.isFinite(maxSpacing) ? maxSpacing : null;
}

function suggestNearestCustomPageForFixedDiameter(
input: LayoutInput,
): { widthMm: number; heightMm: number } | null {
const W = input.pageWidthMm;
const H = input.pageHeightMm;
const D = input.targetDiameterMm;
const s = input.minSpacingMm;
if (!Number.isFinite(W) || !Number.isFinite(H) || !Number.isFinite(D) || !Number.isFinite(s)) {
return null;
}
if (W <= 0 || H <= 0 || D <= 0 || s <= 0) {
return null;
}

const nApprox = Math.max(1, Math.round((W - s) / (D + s)));
const mApprox = Math.max(1, Math.round((H - s) / (D + s)));
const nMin = Math.max(1, nApprox - 8);
const nMax = nApprox + 8;
const mMin = Math.max(1, mApprox - 8);
const mMax = mApprox + 8;

let best: { widthMm: number; heightMm: number; score: number; total: number } | null = null;
for (let n = nMin; n <= nMax; n += 1) {
for (let m = mMin; m <= mMax; m += 1) {
const widthMm = n * D + (n + 1) * s;
const heightMm = m * D + (m + 1) * s;
if (widthMm <= 0 || heightMm <= 0) {
continue;
}

const score = Math.abs(widthMm - W) + Math.abs(heightMm - H);
const total = n * m;
if (
!best ||
score < best.score - EPS ||
(Math.abs(score - best.score) <= EPS && total > best.total)
) {
best = { widthMm, heightMm, score, total };
}
}
}

if (!best) {
return null;
}
return { widthMm: best.widthMm, heightMm: best.heightMm };
}

function resolveEditedField(el: HTMLElement): EditedField {
if (el === refs.pagePreset) {
return 'page';
Expand Down
5 changes: 0 additions & 5 deletions archery/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,6 @@ button {
cursor: pointer;
}

button.secondary {
background: #fff;
color: #1d4ed8;
}

button:hover {
filter: brightness(0.96);
}
Expand Down
Loading