From 983217f740b16c3d27fd5fe928fbd52e1956b636 Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 3 May 2026 10:31:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E5=A4=9Aissue=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/photo-delete.yml | 31 +++++ .github/ISSUE_TEMPLATE/photo-update.yml | 53 +++++++++ .github/ISSUE_TEMPLATE/site-suggestion.yml | 64 ++++++++++ .github/workflows/issue-template-review.yml | 71 +++++++++++ .github/workflows/photo-update.yml | 66 +++++++++++ scripts/update-photo-topic-from-issue.mjs | 123 ++++++++++++++++++++ 6 files changed, 408 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/photo-delete.yml create mode 100644 .github/ISSUE_TEMPLATE/photo-update.yml create mode 100644 .github/ISSUE_TEMPLATE/site-suggestion.yml create mode 100644 .github/workflows/issue-template-review.yml create mode 100644 .github/workflows/photo-update.yml create mode 100644 scripts/update-photo-topic-from-issue.mjs diff --git a/.github/ISSUE_TEMPLATE/photo-delete.yml b/.github/ISSUE_TEMPLATE/photo-delete.yml new file mode 100644 index 0000000..f183256 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/photo-delete.yml @@ -0,0 +1,31 @@ +name: 申请删除照片墙照片 +description: 删除照片墙某个专题里的照片 +title: "photo-delete: " +labels: + - photo-delete +body: + - type: markdown + attributes: + value: | + 提交后,将进行人工审核,请勿恶意提交,否则禁用账号。 + 如果该专题里已经有相同 `title` 的照片,会删除它。 + - type: dropdown + id: topic + attributes: + label: topic + description: 要删除的照片所属专题。 + options: + - classroom + - events + - graduation-day + - candid + validations: + required: true + - type: input + id: title + attributes: + label: title + description: 照片标题;同一专题内标题相同会被视为删除已有照片。 + placeholder: 班级节目 + validations: + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/photo-update.yml b/.github/ISSUE_TEMPLATE/photo-update.yml new file mode 100644 index 0000000..d687c96 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/photo-update.yml @@ -0,0 +1,53 @@ +name: 更新照片墙照片 +description: 新增或修改照片墙某个专题里的照片 +title: "photo: " +labels: + - photo-update +body: + - type: markdown + attributes: + value: | + 提交后,自动化会根据 `topic` 找到对应的 `src/data/photo-topics/.ts`。 + 如果该专题里已经有相同 `title` 的照片,会更新它;否则会追加一张新照片。 + - type: dropdown + id: topic + attributes: + label: topic + description: 要写入的照片专题。 + options: + - classroom + - events + - graduation-day + - candid + validations: + required: true + - type: input + id: title + attributes: + label: title + description: 照片标题;同一专题内标题相同会被视为修改已有照片。 + placeholder: 班级节目 + validations: + required: true + - type: textarea + id: caption + attributes: + label: caption + description: 照片说明。 + placeholder: 那天大家一起站在灯光下面。 + validations: + required: true + - type: input + id: image + attributes: + label: image + description: 图片地址,可以是外链,也可以是站内路径,例如 /photos/events/show.jpg。 + placeholder: https://example.com/photo.jpg + validations: + required: true + - type: input + id: cover + attributes: + label: cover + description: 可选。填写后会同时更新该专题封面;留空则不改封面。 + placeholder: https://example.com/cover.jpg diff --git a/.github/ISSUE_TEMPLATE/site-suggestion.yml b/.github/ISSUE_TEMPLATE/site-suggestion.yml new file mode 100644 index 0000000..495b56d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/site-suggestion.yml @@ -0,0 +1,64 @@ +name: 网站建议 +description: 对网站首页、同学资料、照片墙、内容文案或交互体验提出建议 +title: "suggestion: " +labels: + - site-suggestion +body: + - type: markdown + attributes: + value: | + 感谢你愿意帮我们把网站做得更好。 + 请尽量说明建议涉及的位置、你希望改成什么样,以及这样修改的原因。 + - type: dropdown + id: section + attributes: + label: 建议涉及的网站部分 + description: 请选择最接近的部分;如果不确定,可以选择「其他」。 + options: + - 首页 + - 如今的我们 + - 照片墙 + - 班级故事 + - 网站文案 + - 页面布局 + - 移动端体验 + - 交互功能 + - 其他 + validations: + required: true + - type: textarea + id: current + attributes: + label: 当前情况 + description: 目前你看到的问题、觉得不方便的地方,或想改进的位置。 + placeholder: 例如:照片墙某个专题的入口不太明显,手机上需要滑很久才能找到。 + validations: + required: true + - type: textarea + id: suggestion + attributes: + label: 建议内容 + description: 你希望网站如何调整?可以写具体文案、布局、功能或展示方式。 + placeholder: 例如:希望在首页增加照片墙入口,并按「教室 / 活动 / 毕业日」展示几个快捷入口。 + validations: + required: true + - type: textarea + id: reason + attributes: + label: 建议原因 + description: 为什么你觉得这个建议值得做?它会帮助谁,或解决什么问题? + placeholder: 例如:这样第一次访问网站的同学可以更快找到照片,也更容易回忆起不同阶段的活动。 + validations: + required: true + - type: input + id: page + attributes: + label: 相关页面或链接 + description: 可选。填写具体页面地址、截图链接或相关位置说明。 + placeholder: /photos 或 首页顶部 + - type: textarea + id: extra + attributes: + label: 补充信息 + description: 可选。可以补充截图说明、参考网站、更多背景或其他想法。 + placeholder: 其他想补充的内容。 diff --git a/.github/workflows/issue-template-review.yml b/.github/workflows/issue-template-review.yml new file mode 100644 index 0000000..75fdaa0 --- /dev/null +++ b/.github/workflows/issue-template-review.yml @@ -0,0 +1,71 @@ +name: Review Issues Without Template + +on: + issues: + types: + - opened + +permissions: + issues: write + +jobs: + label-without-template: + if: ${{ !github.event.issue.pull_request }} + runs-on: ubuntu-latest + steps: + - name: Add review label when no template is used + uses: actions/github-script@v7 + with: + script: | + const reviewLabel = "needs-review"; + const templateLabels = new Set([ + "people-update", + "photo-update", + "site-suggestion", + ]); + + const issue = context.payload.issue; + const labels = issue.labels.map((label) => label.name); + const body = issue.body || ""; + const title = issue.title || ""; + + const hasTemplateLabel = labels.some((label) => templateLabels.has(label)); + const hasTemplateTitle = + title.startsWith("people:") || + title.startsWith("photo:") || + title.startsWith("suggestion:"); + const hasTemplateFields = + body.includes("### slug") || + body.includes("### topic") || + body.includes("### 建议涉及的网站部分"); + + if (hasTemplateLabel || hasTemplateTitle || hasTemplateFields) { + return; + } + + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: reviewLabel, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: reviewLabel, + color: "fbca04", + description: "Issue did not use a repository template and needs manual review.", + }); + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [reviewLabel], + }); diff --git a/.github/workflows/photo-update.yml b/.github/workflows/photo-update.yml new file mode 100644 index 0000000..71d26c3 --- /dev/null +++ b/.github/workflows/photo-update.yml @@ -0,0 +1,66 @@ +name: Update Photo Data + +on: + issues: + types: + - opened + - edited + - labeled + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + update-photo: + if: contains(github.event.issue.labels.*.name, 'photo-update') || startsWith(github.event.issue.title, 'photo:') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Update photo topic data + env: + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + run: node scripts/update-photo-topic-from-issue.mjs + + - name: Create pull request + id: create-pr + uses: peter-evans/create-pull-request@v6 + with: + commit-message: "chore: update photo data from issue #${{ github.event.issue.number }}" + title: "更新照片墙:#${{ github.event.issue.number }}" + body: | + 由 #${{ github.event.issue.number }} 自动生成。 + + 这个 PR 会根据 issue 表单更新 `src/data/photo-topics/.ts`: + - 如果同一专题内已有相同 `title`,则修改对应照片 + - 如果没有相同 `title`,则追加一张新照片 + - 如果填写了 `cover`,则同步更新专题封面 + branch: photo-update/issue-${{ github.event.issue.number }} + delete-branch: true + labels: photo-update + + - name: Comment on issue + uses: actions/github-script@v7 + with: + script: | + const prUrl = "${{ steps.create-pr.outputs.pull-request-url }}"; + const body = prUrl + ? `已根据这个 issue 创建照片更新 PR:${prUrl}\n\n如果内容还要调整,直接编辑 issue 表单即可更新同一个 PR。` + : "这个 issue 没有产生新的文件改动,所以没有创建 PR。"; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); diff --git a/scripts/update-photo-topic-from-issue.mjs b/scripts/update-photo-topic-from-issue.mjs new file mode 100644 index 0000000..11a3f3f --- /dev/null +++ b/scripts/update-photo-topic-from-issue.mjs @@ -0,0 +1,123 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { relative, resolve } from "node:path"; + +const issueBody = process.env.ISSUE_BODY ?? ""; +const topicRoot = resolve(process.env.PHOTO_TOPIC_ROOT ?? "src/data/photo-topics"); + +const fields = ["topic", "title", "caption", "image", "cover"]; +const requiredFields = ["topic", "title", "caption", "image"]; +const sectionAliases = new Map(fields.map((field) => [field.toLowerCase(), field])); + +function normalizeValue(value) { + const trimmed = value.trim(); + return trimmed === "_No response_" ? "" : trimmed; +} + +function parseIssueForm(body) { + const result = {}; + const sectionPattern = /^###\s+(.+?)\s*$/gm; + const matches = [...body.matchAll(sectionPattern)]; + + for (let index = 0; index < matches.length; index += 1) { + const rawLabel = matches[index][1].trim().toLowerCase(); + const field = sectionAliases.get(rawLabel); + if (!field) continue; + + const start = matches[index].index + matches[index][0].length; + const end = index + 1 < matches.length ? matches[index + 1].index : body.length; + result[field] = normalizeValue(body.slice(start, end)); + } + + return result; +} + +function assertValidInput(input) { + for (const field of requiredFields) { + if (!input[field]) { + throw new Error(`Issue form is missing required field: ${field}`); + } + } + + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.topic)) { + throw new Error("topic must use lowercase letters, numbers, and single hyphens only."); + } +} + +function topicPathFromSlug(slug) { + return resolve(topicRoot, `${slug}.ts`); +} + +function readTopicModule(filePath) { + const source = readFileSync(filePath, "utf8"); + const declarationPattern = /export const\s+(\w+)\s*:\s*PhotoTopic\s*=\s*({[\s\S]*?});\s*$/; + const match = source.match(declarationPattern); + + if (!match) { + throw new Error(`Could not find PhotoTopic export in ${relative(process.cwd(), filePath)}`); + } + + const [, exportName, objectSource] = match; + const topic = Function(`return (${objectSource});`)(); + return { exportName, topic }; +} + +function buildPhoto(input, existing = {}) { + return { + title: input.title || existing.title || "", + caption: input.caption || existing.caption || "", + image: input.image || existing.image || "" + }; +} + +function quote(value) { + return JSON.stringify(value); +} + +function formatPhoto(photo) { + return ` { + title: ${quote(photo.title)}, + caption: ${quote(photo.caption)}, + image: ${quote(photo.image)} + }`; +} + +function formatTopicModule(exportName, topic) { + return `import type { PhotoTopic } from "../photo-types"; + +export const ${exportName}: PhotoTopic = { + slug: ${quote(topic.slug)}, + title: ${quote(topic.title)}, + text: ${quote(topic.text)}, + cover: ${quote(topic.cover ?? "")}, + photos: [ +${topic.photos.map(formatPhoto).join(",\n")} + ] +}; +`; +} + +const input = parseIssueForm(issueBody); +assertValidInput(input); + +const topicPath = topicPathFromSlug(input.topic); +const { exportName, topic } = readTopicModule(topicPath); + +if (topic.slug !== input.topic) { + throw new Error(`Topic slug mismatch: issue asked for ${input.topic}, file contains ${topic.slug}`); +} + +if (input.cover) { + topic.cover = input.cover; +} + +const existingIndex = topic.photos.findIndex((photo) => photo.title === input.title); +if (existingIndex >= 0) { + topic.photos[existingIndex] = buildPhoto(input, topic.photos[existingIndex]); + console.log(`Updated existing photo "${input.title}" in topic: ${input.topic}`); +} else { + topic.photos.push(buildPhoto(input)); + console.log(`Added new photo "${input.title}" to topic: ${input.topic}`); +} + +writeFileSync(topicPath, formatTopicModule(exportName, topic), "utf8"); +console.log(`Wrote ${relative(process.cwd(), topicPath)}`);