Compare commits

...

56 Commits

Author SHA1 Message Date
codex-bot 51347669a4 Adopt Turborepo in the existing pnpm workspace 2026-11-04 22:49:09 +08:00
Zayden-Jung 6e8dc8942a Merge pull request '合并 Wiki 与 Studio,精简为单应用仓库' (#49) from codex/unify-and-prune into main
Reviewed-on: #49
Reviewed-by: Zayden-Jung <zayden.jung@entroia.com>
2026-11-04 14:37:17 +00:00
codex-bot 5d74251032 Unify Wiki and Studio and remove deployment scaffolding 2026-11-04 22:14:35 +08:00
Zayden-Jung ef290f769b Merge pull request 'fix(ci): 以应用就绪验证生产页面并前置完整线上验收' (#45) from codex/production-navigation-ready into main
creative workspace CI and deployment / verify-and-deploy (push) Successful in 17m23s
Reviewed-on: #45
2026-11-02 09:06:22 +00:00
codex-bot 38a71e04ca fix(ci): accept hydrated markers hidden by responsive layouts
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 3m24s
creative workspace review checks / verify (pull_request) Successful in 25m23s
2026-11-02 16:32:37 +08:00
codex-bot 7f397fd3a2 fix(ci): verify production readiness without network idle
creative workspace review checks / verify-deployed-canvas (pull_request) Failing after 2m45s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-02 16:20:08 +08:00
Zayden-Jung 5a317008df Merge pull request 'feat(home): 手绘场景与编辑式内容丰富首页' (#44) from codex/landing-editorial into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 15m54s
Reviewed-on: #44
2026-11-02 07:56:43 +00:00
codex-bot 602263084f feat(home): add illustrated editorial landing sections
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 55s
creative workspace review checks / verify (pull_request) Successful in 27m17s
2026-11-02 14:56:44 +08:00
Zayden-Jung 5b4644749d Merge pull request 'feat(auth): 啡跃品牌视觉与居中表单的分屏登录页' (#42) from codex/auth-layout-polish into main
creative workspace CI and deployment / verify-and-deploy (push) Successful in 15m2s
Reviewed-on: #42
2026-11-01 17:13:07 +00:00
Zayden-Jung 7656f0438c Merge pull request 'feat(auth): 邮件重设密码与旧会话撤销' (#43) from codex/account-recovery into main
creative workspace CI and deployment / verify-and-deploy (push) Successful in 16m6s
Reviewed-on: #43
2026-11-01 16:45:16 +00:00
codex-bot ca08f44137 merge: integrate password recovery dependency for combined auth validation
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 1m5s
creative workspace review checks / verify (pull_request) Successful in 28m38s
2026-11-02 00:13:07 +08:00
codex-bot b31a4835ee test(auth): verify recovery endpoints over HTTP with real storage
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 53s
creative workspace review checks / verify (pull_request) Successful in 25m37s
2026-11-02 00:02:32 +08:00
codex-bot fd96b6d9e9 fix(auth): preserve form state after email-only actions
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 59s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 23:54:21 +08:00
codex-bot ee72835ea3 feat(auth): add recovery UI and stable viewport login interactions
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 52s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 23:50:32 +08:00
codex-bot 6800a1b1c4 feat(auth): add single-use email password recovery and session revocation
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 58s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 23:43:26 +08:00
codex-bot 1da1114d52 fix(auth): clarify verification resend eligibility
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 56s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 23:33:25 +08:00
codex-bot 8c48812a11 test(auth): capture deployed split login layouts in release acceptance
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 47s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 23:24:31 +08:00
codex-bot 88d68f3d3c feat(auth): introduce immersive split-screen login design
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 54s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 23:19:23 +08:00
codex-bot 6819db40a8 fix(auth): rebalance account introduction typography and spacing
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 59s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 23:10:36 +08:00
Zayden-Jung b2381a1cdf Merge pull request 'fix(ci): 以客户端就绪判定脚本取消是否阻断启动' (#41) from codex/ci-bootstrap-readiness into main
creative workspace CI and deployment / verify-and-deploy (push) Successful in 15m57s
Reviewed-on: #41
2026-11-01 14:45:13 +00:00
codex-bot 150e4b3af8 fix(ci): accept hydrated documents with cancelled preload requests
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 1m7s
creative workspace review checks / verify (pull_request) Successful in 26m21s
2026-11-01 22:14:37 +08:00
Zayden-Jung e36b8aea6c Merge pull request 'fix(ci): 等待浏览器脚本与账户状态就绪后再操作' (#40) from codex/ci-browser-readiness into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 10m12s
Reviewed-on: #40
2026-11-01 13:58:14 +00:00
codex-bot 517c31ca04 fix(ci): wait for document bootstrap before browser interactions
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 1m23s
creative workspace review checks / verify (pull_request) Successful in 24m43s
2026-11-01 21:36:24 +08:00
Zayden-Jung fafdae66bb Merge pull request 'fix(auth): 独立登录注册页面并弱化主题切换' (#39) from codex/auth-page-fix into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 9m31s
Reviewed-on: #39
2026-11-01 13:16:22 +00:00
codex-bot a45d43f5f7 fix(auth): separate account page from workspace navigation
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 1m13s
creative workspace review checks / verify (pull_request) Failing after 20m8s
2026-11-01 20:40:01 +08:00
Zayden-Jung 5b68c5b84e Merge pull request 'fix(ci): 统一画布验收、导航恢复与 review 资源清理' (#38) from codex/ci-acceptance-stability into main
creative workspace CI and deployment / verify-and-deploy (push) Successful in 15m58s
Reviewed-on: #38
2026-11-01 09:34:53 +00:00
codex-bot 5c068ace81 fix(ci): complete deferred script loading before brand assertions
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 55s
creative workspace review checks / verify (pull_request) Successful in 26m46s
2026-11-01 17:02:38 +08:00
codex-bot f4397db869 fix(ci): preserve dependency parent layers during review cleanup
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 1m5s
creative workspace review checks / verify (pull_request) Failing after 26m4s
2026-11-01 16:31:56 +08:00
codex-bot 9e5c5abbdf fix(ci): wait for restored document filters before asserting values
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 54s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 16:10:57 +08:00
codex-bot 8b949484c1 fix(ci): wait for canvas hydration instead of the window load event
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 58s
creative workspace review checks / verify (pull_request) Failing after 25m52s
2026-11-01 15:40:33 +08:00
codex-bot 9887a5958a fix(ci): bound navigation recovery and retire review images safely
creative workspace review checks / verify-deployed-canvas (pull_request) Failing after 59s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 15:35:23 +08:00
codex-bot cc6b376f56 fix(ci): verify persistent canvas themes on the root element 2026-11-01 15:32:13 +08:00
Zayden-Jung cfa3d426a0 Merge pull request 'fix(ci): 按当前 Studio 入口调整发布验收范围' (#36) from codex/fix-release-acceptance into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 11m37s
Reviewed-on: #36
2026-10-31 20:50:02 +00:00
codex-bot f7c6681c47 fix(ci): align release acceptance with active Studio entry
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 04:46:20 +08:00
Zayden-Jung b76642e06d Merge pull request 'fix(ci): 修复生产浏览器验收的依赖路径' (#35) from codex/fix-acceptance-deps into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 11m5s
Reviewed-on: #35
2026-10-31 20:20:48 +00:00
codex-bot 2570ea13bd fix(ci): resolve browser acceptance dependencies in workspace
creative workspace review checks / verify (pull_request) Successful in 11m9s
2026-11-01 04:18:44 +08:00
Zayden-Jung d316b0de03 Merge pull request 'fix(ui): 完成啡跃主题与浏览器图标的生产接入' (#34) from codex/brand-ui into main
Endoleap brand checks / verify (push) Successful in 6s
creative workspace CI and deployment / verify-and-deploy (push) Failing after 10m53s
Reviewed-on: #34
2026-10-31 19:38:12 +00:00
codex-bot 2a324c4d19 fix(ui): wait for rendered browser state during CI navigation
Endoleap brand checks / verify (pull_request) Successful in 6s
creative workspace review checks / verify (pull_request) Successful in 26m0s
2026-11-01 03:09:33 +08:00
codex-bot 628449735b fix(ui): fit the mark into compact library navigation
Endoleap brand checks / verify (pull_request) Successful in 6s
creative workspace review checks / verify (pull_request) Failing after 21m24s
2026-11-01 02:43:55 +08:00
codex-bot 7dca4495bb fix(ui): keep the Endoleap wordmark visible on mobile
Endoleap brand checks / verify (pull_request) Successful in 6s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 02:42:42 +08:00
codex-bot 30ac83cd85 fix(ui): serve approved brand icons on public login pages
Endoleap brand checks / verify (pull_request) Successful in 32s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 02:14:39 +08:00
codex-bot aa3c0cf11d fix(ui): align standalone tracing and workspace runtime paths
Endoleap brand checks / verify (pull_request) Has been cancelled
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 02:12:34 +08:00
codex-bot ada5338af5 fix(ui): resolve linked brand styles from the monorepo root
creative workspace review checks / verify (pull_request) Failing after 5m8s
2026-11-01 02:04:51 +08:00
codex-bot a3935c5adc fix(ui): preserve authored sheets and keyboard link contrast
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 02:00:00 +08:00
codex-bot abc6eafd0c fix(ui): install locked workspace dependencies in Docker
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 01:52:10 +08:00
Zayden-Jung 861f1b13f6 Merge pull request 'feat(ui): 全站接入啡跃配色、Logo 与浏览器图标' (#33) from codex/brand-ui into main
Endoleap brand checks / verify (push) Successful in 6s
creative workspace CI and deployment / verify-and-deploy (push) Failing after 1m12s
Reviewed-on: #33
2026-10-31 17:48:29 +00:00
codex-bot 8062dbc878 feat(ui): apply Endoleap themes, brand marks and favicons
Endoleap brand checks / verify (pull_request) Successful in 2m25s
creative workspace review checks / verify (pull_request) Failing after 2m20s
2026-11-01 01:44:51 +08:00
Zayden-Jung ff91a281f1 Merge pull request 'feat(brand): 引入啡跃品牌资源与 Tailwind / shadcn 主题包' (#32) from codex/brand-system into main
Endoleap brand checks / verify (push) Successful in 6s
creative workspace CI and deployment / verify-and-deploy (push) Failing after 6m1s
Reviewed-on: #32
2026-10-31 16:30:12 +00:00
codex-bot 74e058bb44 fix(ci): support ces-control legacy Docker builder
Endoleap brand checks / verify (pull_request) Successful in 3m55s
creative workspace review checks / verify (pull_request) Failing after 5m40s
2026-11-01 00:25:01 +08:00
codex-bot c405f47916 feat(brand): add Endoleap assets and production theme package
Endoleap brand checks / verify (pull_request) Failing after 5s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 00:22:43 +08:00
Zayden-Jung 0849268232 Merge pull request 'Fix canvas placement and CI browser verification' (#30) from codex/canvas-node-placement into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 6m49s
Reviewed-on: #30
2026-10-30 19:06:45 +00:00
codex-bot ab5ea7876b Synchronize guidance navigation with fulfilled session and rendered state
creative workspace review checks / verify (pull_request) Successful in 23m45s
2026-10-31 02:24:22 +08:00
codex-bot a589f51698 Clean disposable CI stacks on interruption and job completion
creative workspace review checks / verify (pull_request) Failing after 7m18s
2026-10-31 02:14:35 +08:00
codex-bot 40c1023a86 Verify canvas text formatting and expansion on the deployed server
creative workspace review checks / verify (pull_request) Failing after 13m57s
2026-10-31 01:52:45 +08:00
codex-bot 2cc6f1f543 Place derived canvas nodes without covering existing content
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-31 01:23:41 +08:00
Zayden-Jung a3435c463f Merge pull request 'Add canvas references, selection editing and external agent tasks' (#29) from codex/canvas-node-workflow into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 16m25s
Reviewed-on: #29
2026-10-30 16:07:23 +00:00
341 changed files with 5336 additions and 19153 deletions
-24
View File
@@ -1,24 +0,0 @@
.git
**/.next
**/node_modules
**/dist
coverage
.env
.env.*
!.env.example
*.tsbuildinfo
.DS_Store
.agents
.codex
# Documentation and CI metadata are not application build inputs.
/docs
/.gitea
/README.md
/AGENTS.md
**/__pycache__
**/.venv
# Local authoring assets are uploaded via APIs, never application build inputs.
/content
-37
View File
@@ -1,37 +0,0 @@
name: creative workspace review checks
on:
pull_request:
branches: [main]
paths:
- 'apps/studio/**'
- 'apps/waterfall-wiki/**'
- '.gitea/workflows/**'
- 'scripts/*studio*'
- 'compose.creative-review.yml'
- 'scripts/integration.sh'
concurrency:
group: creative-workspace-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
verify:
runs-on: ces-control
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check patch formatting
run: git diff --check HEAD^ HEAD
- name: Verify Studio types, tests, build and smoke
run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" .
- name: Build Wiki integration and runtime images
run: |
docker build --target integration -t "creative-wiki-tests:${{ gitea.sha }}" .
docker build --target runtime -t "creative-wiki-review:${{ gitea.sha }}" .
- name: Verify isolated PostgreSQL, S3 and browser integration
env:
STUDIO_REVIEW_IMAGE: creative-studio-review:${{ gitea.sha }}
WATERFALL_WIKI_IMAGE: creative-wiki-review:${{ gitea.sha }}
WATERFALL_WIKI_TEST_IMAGE: creative-wiki-tests:${{ gitea.sha }}
GITEA_RUN_ID: ${{ gitea.run_id }}
run: bash scripts/integration.sh
-23
View File
@@ -1,23 +0,0 @@
name: python CI and deployment
on:
push:
branches: [main]
paths:
- 'apps/python/**'
- 'scripts/deploy-python.sh'
- '.gitea/workflows/python.yml'
jobs:
verify-and-deploy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build and verify python
run: docker build -f apps/python/Dockerfile -t "prompt-python:${{ gitea.sha }}" .
- name: Release python
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
run: bash scripts/deploy-python.sh "${{ gitea.sha }}"
-62
View File
@@ -1,62 +0,0 @@
name: creative workspace CI and deployment
on:
push:
branches: [main]
paths:
- 'apps/waterfall-wiki/**'
- 'apps/studio/**'
- 'scripts/*studio*'
- 'content/studio-examples/**'
- 'content/studio-tools/layouts-api.md'
- '.gitea/workflows/waterfall-wiki.yml'
- 'Dockerfile'
- 'compose.yml'
- 'scripts/deploy.sh'
- 'scripts/wiki-image-lifecycle.py'
- 'scripts/infra.sh'
- 'scripts/auth-admin.sh'
- 'scripts/integration.sh'
- 'pnpm-workspace.yaml'
jobs:
verify-and-deploy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check shared content and documentation contracts
run: |
node scripts/check-studio-contracts.mjs
node --check scripts/export-studio-series.cjs
node --check scripts/save-studio-series.mjs
bash -n scripts/publish-studio-guide.sh scripts/deliver-studio-series.sh
- name: Build and verify Studio
run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" .
- name: Build, verify, deploy and retain wiki images
env:
RELEASE_SHA: ${{ gitea.sha }}
run: |
if [ "$(id -u)" -eq 0 ]; then
python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
else
sudo -n python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
fi
- name: Release Studio after Wiki routes are deployed
run: bash scripts/deploy-studio.sh "${{ gitea.sha }}"
- name: Verify the deployed creative workspace
run: bash scripts/verify-studio-production.sh
- name: Save server acceptance evidence
id: acceptance_evidence
if: always()
uses: actions/upload-artifact@v3
with:
name: studio-production-acceptance
path: artifacts/studio-acceptance/
if-no-files-found: ignore
retention-days: 7
- name: Remove temporary acceptance output after upload
if: always() && steps.acceptance_evidence.outcome == 'success'
run: rm -rf artifacts/studio-acceptance
@@ -1,19 +0,0 @@
name: Wiki image retention checks
on:
push:
branches: [main]
paths:
- 'scripts/wiki-image-lifecycle.py'
- 'tests/test_wiki_image_lifecycle.py'
- '.gitea/workflows/wiki-image-retention-checks.yml'
jobs:
retention-policy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check retention without building or accessing the Docker daemon
run: |
bash -n scripts/deploy.sh
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_wiki_image_lifecycle.py' -v
+12
View File
@@ -1,4 +1,6 @@
node_modules/
.pnpm-store/
.turbo/
.next/
coverage/
.env*
@@ -18,3 +20,13 @@ __pycache__/
*.webp
*.gif
*.avif
# Approved, versioned brand resources are package source assets.
!packages/brand/assets/**/*.png
!packages/brand/docs/logo-preview.png
/apps/studio/public/brand/
/apps/studio/public/favicon.ico
/apps/studio/public/apple-touch-icon.png
/apps/waterfall-wiki/public/brand/
/apps/waterfall-wiki/public/favicon.ico
/apps/waterfall-wiki/public/apple-touch-icon.png
+2 -5
View File
@@ -8,9 +8,6 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
<!-- END:nextjs-agent-rules -->
## 交付与视觉验收
## 项目维护
- 用户指定服务器为唯一运行与验收入口:http://8.211.154.134:18131/。
- 页面改动必须先 push,等待 ces-control 的 CI/CD 成功部署,再直接访问服务器检查效果。
- 不启动本地网页预览,不使用 localhost / 127.0.0.1 页面进行视觉验收,也不把本地预览链接交付给用户。
- 类型检查、单元测试等不提供网页预览的检查可以本地执行;完整运行检查由 Docker CI 完成。
- Wiki 和 Studio 共用 `apps/waterfall-wiki` 的 Pages Router、登录保护和品牌包。保留已发布的路由、媒体标识和数据库迁移。
-40
View File
@@ -1,40 +0,0 @@
FROM node:24-bookworm-slim AS base
LABEL io.entroia.project="waterfall-wiki"
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates chromium fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
FROM base AS dependencies
LABEL io.entroia.wiki.build="true"
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
COPY apps/waterfall-wiki/package.json ./
RUN pnpm install --offline --frozen-lockfile
FROM dependencies AS build
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
COPY apps/waterfall-wiki/ .
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
FROM build AS integration
USER node
CMD ["npm", "run", "test:integration"]
FROM base AS runtime
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
COPY --from=build --chown=node:node /app/.next/standalone/ ./
COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs /app/dist/auth-cli.cjs ./
COPY --from=build --chown=node:node /app/db/ ./db/
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
COPY --chown=node:node apps/waterfall-wiki/src/content/managed-media.json ./src/content/managed-media.json
USER node
EXPOSE 3000
CMD ["node", "server.js"]
+21 -87
View File
@@ -1,104 +1,38 @@
# PROMPT
# 啡跃 Endoleap
把提示词、参考图、分步文档和创作工具收进同一个可连接的知识库。人可以浏览瀑布流、搜索和编辑;AI 可以从同一篇 post 读取纯文本、图片与 API 说明,再调用 Studio 生成作品。
啡跃把笔记、参考素材和创作工作台放在同一个 Next.js 应用里。应用位于 `apps/waterfall-wiki`,共享主题与品牌资源位于 `packages/brand`。
**PROMPT 是网站与项目品牌;`waterfall-wiki` 是知识库应用的内部名称。** 目录、包、CI、镜像和 Compose 服务使用内部名称,网站导航与页面标题使用 PROMPT。
线上入口:[PROMPT](http://8.211.154.134:18131/)。内容、媒体、API 与 Studio 共用登录入口。
## 从一篇 post 开始
每篇内容都有稳定的 `/explore/<id>` 地址。标题、标签、正文和封面可以修改,链接保持不变。数据库内容可以保留 24 位 public ID,内部仍使用 UUID;旧 `/p/<slug>`、`/skills/...` 等地址保留兼容跳转。
- **浏览**:首页按不同笔记的反向引用数优先排序。重复引用和自引用不重复计数,代码、图片与外站链接不冒充笔记引用。详情可以查看引用来源。
- **搜索**:标题、简介、正文、普通标签和系统标签共同匹配。支持多个关键词及双引号短语,先按相关性、再按引用数和时间排序;先筛选再分页。
- **标签**:普通标签使用 `#中国风`、`#贴纸` 等名称,可以创建、添加或移除。系统标签是固定列表,目前为“文档入口”和“参考图”,只能搜索并勾选,不能通过 `#` 创建。系统标签显示在图片角标,普通标签显示在文字区;分类页顶部按系统标签筛选。
- **编辑**:卡片与详情都能直接编辑标签;完整编辑器可以修改标题、简介、Markdown。保存使用 revision 检测并发冲突,避免旧窗口覆盖新修改。
- **素材**:原件和加工结果都是普通资产,可被多篇 post 引用。文档封面与实际参考素材分开,子文档可以有自己的封面,同时保留原参考图。
## 给 AI 的入口
在同一个 post 地址加 `?agent_mode=True` 可以读取纯文本正文和媒体链接。业务 API 的相同参数会先返回接口说明;已有查询参数时使用 `&agent_mode=True`。这些入口同样需要登录或有效 Bearer JWT。
| 地址 | 用途 |
| 入口 | 用途 |
| --- | --- |
| `/explore/<id>` | 人看的 post;加 `?agent_mode=True` 读纯文本 |
| `/` | 浏览内容,打开 `/explore/<id>` 查看笔记 |
| `/guide` | 浏览指导文档 |
| `/graphic` | 图文排版 |
| `/infinite_canvas` | 无限画布 |
| `/api?agent_mode=True` | 业务 API 目录 |
| `/api/explore-feed` | 统一搜索、系统/普通标签筛选与分页 |
| `/api/notes/<id>` | GET 读取;PUT 编辑全文;PATCH 仅更新标签 |
| `/api/tags` | GET 查看普通/系统标签;POST 创建或复用普通标签 |
| `/api/upload` | 上传媒体;`assetOnly=true` 独立保存,不创建 post |
| `/api/posts` | 用正文和已有资产创建普通 post |
| `/api/media/<id>` | 媒体原件与衍生文件,支持 Range |
| `/api/preview` | 有尺寸上限的 WebP 预览,不接受任意远程 URL |
| `/studio/api` | Studio API 目录 |
标签 PATCH 请求为 `{ "tags": ["标签 UUID"], "revision": "GET 返回的 revision" }`。它不会要求用户重输正文。Tag 对象的 `kind` 为 `normal` 或 `system`;系统标签还有稳定的 `system_key`。普通创建接口不允许提交 `kind` 或 `system_key`。
## 运行
Wiki 中的教程是普通 post 与媒体数据,不需要为每份资料增加专用网页路由、JSON 文件端点或代码注册。
准备 Node.js 24、pnpm 11.25.0、PostgreSQL、S3 兼容对象存储和 FFmpeg。参考 [环境变量示例](apps/waterfall-wiki/.env.example),将变量注入 Web、初始化和媒体 worker 进程。邮箱注册与找回密码需要可用的 SMTP 服务。
## Studio:从教程进入工具
Studio 负责交互式排版与无状态渲染,知识库负责教程、来源和入口。`/studio` 返回 PROMPT 首页;请从对应 post 打开具体工具。
27 套排版工具:`/studio/layouts/ref-01` 至 `/studio/layouts/ref-27`,每套对应原图 T/I/D 构件。目录与参数 API 为 `/studio/api/layouts` 和 `/studio/api/layouts/<id>`;POST 参数生成 SVG。效果通过 `/api/studio/designs` 存入 Wiki 现有 PostgreSQL,返回独立 ID,`/studio/designs/<id>` 可再次打开;PATCH 携带 revision 更新同一效果。Studio 不需要数据库连接或第二套数据库。详见 [27 套排版 API 教程](content/studio-tools/layouts-api.md)。
其他试版工具:
- `/studio/ideas/learning-skills-template`:ref-17 同一母版的四张占位封面。
- `/studio/ideas/layout-selection`:ref-01、ref-07、ref-26 三种构图;可修改文字与配色,核对构件边界。
- `/studio/api/export/<slug>`:GET 读取方案或初始 SVG;POST 校验自定义参数并生成 SVG/JSON。详细说明读取该地址的 `?agent_mode=True`。
渲染不自动保存作品,也不自动发布 post。需要保留的结果应下载后再通过普通媒体和 post API 发布。原图的 T/I/D 构件、画板比例与来源保留为可读取数据;文字替换和矢量重建的范围应在教程中明确。
## 仓库结构
| 路径 | 职责 |
| --- | --- |
| `apps/waterfall-wiki` | Next.js Pages Router 知识库、搜索、标签、媒体 API、认证与后台视频加工 |
| `apps/studio` | 独立 Next.js 创作工具与封面渲染 API |
| `apps/python` | 按需运行的 PNG/视频导出 CLI,没有常驻网络服务 |
| `content` | 本地作者资料、教程及排版源图,不打入 Wiki 运行镜像 |
| `scripts` | CI 部署、运维、API 发布与作者工具 |
| `docs` | 数据模型、接口、认证与部署说明 |
两个 JavaScript 应用分别维护冻结的 pnpm 锁文件。根 Dockerfile 是 waterfall-wiki 的构建入口;应用目录的 Dockerfile 与它保持一致。
## 环境与检查
运行环境:Node.js 24、pnpm 11.25.0、PostgreSQL 17、S3 兼容对象存储;视频处理使用 FFmpeg,部分导出使用 Chromium。Python 工具需要 Python 3.12+。
在仓库根目录运行 `pnpm install --frozen-lockfile` 和 `pnpm build`。然后在 `apps/waterfall-wiki` 目录准备数据库和存储,初始化管理员认证并启动 Web。
```sh
pnpm install
pnpm --dir apps/waterfall-wiki typecheck
pnpm --dir apps/waterfall-wiki test
pnpm --dir apps/studio typecheck
bash -n scripts/deploy.sh scripts/integration.sh scripts/deploy-studio.sh
node dist/bootstrap.cjs
node dist/auth-cli.cjs init
pnpm start
```
完整集成测试只针对显式隔离的数据库、对象存储和生产容器运行,覆盖存储、视频加工、编辑冲突、真实搜索与引用、标签规则和登录保护。不要把生产存储配置拿来运行集成测试。
媒体 worker 在同一目录作为独立进程运行 `node dist/worker.cjs`。绑定管理员验证器时,在远程交互终端执行 `node dist/auth-cli.cjs qr`。已有实例复用原数据库、对象存储和私有认证文件。
本项目以指定服务器为唯一网页运行与验收入口:先推送,再等待 ces-control CI/CD,最后直接检查服务器。不要启动本地网页预览,不用 localhost 页面作为视觉验收或交付入口。具体约定见 [AGENTS.md](AGENTS.md)。
## Monorepo 与 Turborepo
## 部署与持久化
仓库在现有 pnpm workspace 上接入 Turborepo,无需运行面向新项目的 `npx create-turbo@latest`。应用仍位于 `apps/waterfall-wiki`,源码品牌包仍位于 `packages/brand`;子包脚本和应用产物路径保持原样。依赖统一锁定在根目录 `pnpm-lock.yaml`,安装从仓库根目录执行。
Web 应用由 `waterfall-wiki.yml` 统一发布,两个应用的路径都能触发它:先检查并构建 Studio,再完成 Wiki 的类型检查、单元测试、生产构建、冒烟及隔离 PostgreSQL/S3 集成测试并部署 Wiki,随后部署 Studio,最后在指定服务器验收。顺序在同一个 ces-control 作业内保证,避免独立作业互相等待。PR 检查也运行隔离集成测试,但不部署。Python 检查仍由 `python.yml` 承担;文档与作者数据本身不触发应用部署。
根目录命令由 Turbo 调度:`pnpm dev` 启动开发任务,`pnpm dev:waterfall-wiki` 仅启动 Wiki,`pnpm build` 构建应用及 worker/CLI,`pnpm typecheck` 生成 Next.js 类型并检查 TypeScript。`pnpm start` 继续直接启动现有应用。也可以在子包目录使用原有命令。
| 服务 | 入口/端口 |
| --- | --- |
| `waterfall-wiki` | 公网 `18131`,也是 Studio 的统一入口 |
| PostgreSQL | `127.0.0.1:18132` |
| 对象存储 | `127.0.0.1:18133` |
| Studio | `127.0.0.1:18134`;编辑器公网入口为 `/graphic`、`/infinite_canvas`,资源和 API 保留 `/studio` 代理 |
| Python | 无网络端口,按需运行 |
锁文件合并保留应用原有依赖解析。唯一重叠差异是 `@tailwindcss/postcss` 的间接 PostCSS 依赖:品牌包原为 8.5.32,现在沿用应用的 8.5.31;品牌包直接声明的 PostCSS 8.5.6 不变。
生产 Compose 项目仍为 `prompt`,部署根目录仍为 `/opt/prompt`。既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷与数据库身份保留;应用更名不会重新创建一套空存储。Compose 应用服务改为 `waterfall-wiki` 和 `waterfall-wiki-worker`,镜像参数为 `WATERFALL_WIKI_IMAGE` / `WATERFALL_WIKI_TEST_IMAGE`。
迁移阶段所有任务禁用缓存,根命令使用 `--env-mode=loose` 透传既有环境变量,保留品牌资源准备步骤的每次执行。后续启用缓存前,需明确环境变量输入、跨包资源和所有输出。当前不配置 CI/CD,也不自动发布。
部署在停止写入后备份数据库,再运行迁移和启动新版。失败时恢复兼容旧镜像的备份并回滚服务。生产不执行 `down -v`,也不清空对象文件。早期合集验证会遍历分页,不假定旧文章一定在第一页。
## 登录与自动化
共享验证器的 6 位 TOTP 动态码登录,JWT 保存在 HttpOnly Cookie 中;这属于单因素动态码登录,不是 2FA。密钥保存在服务器认证卷,不进入 Git、镜像或 HTTP 二维码接口。
服务器管理员在交互式 SSH 终端运行 `sudo /opt/prompt/auth qr` 绑定验证器。不要把二维码、动态密钥或 JWT 粘贴到聊天、日志和仓库。服务端自动化可使用 `node auth-cli.cjs run <脚本>`,在进程内为本机 API 请求附加短期凭据,不输出令牌。
更多说明:[登录与 SSH 绑定](docs/dynamic-code-login.md) · [API](docs/api.md) · [数据模型](docs/data-model.md) · [项目结构与部署](docs/monorepo.md)。
仅检查任务计划、不执行编译或生成应用产物时,可运行 `pnpm build --dry=json` 和 `pnpm typecheck --dry=json`。完整构建与运行验证另行执行。
-8
View File
@@ -1,8 +0,0 @@
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-noto-cjk && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY apps/python/ .
RUN pip install --no-cache-dir . && python -m unittest discover -s tests -v
RUN useradd --create-home exporter
USER exporter
ENTRYPOINT ["prompt-export"]
-12
View File
@@ -1,12 +0,0 @@
[build-system]
requires = ["setuptools==75.8.0"]
build-backend = "setuptools.build_meta"
[project]
name = "prompt-tools"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["Pillow==11.1.0"]
[project.scripts]
prompt-export = "prompt_tools.export:main"
[tool.setuptools.packages.find]
where = ["src"]
-70
View File
@@ -1,70 +0,0 @@
"""Render a Studio JSON bundle into PNG slides and optionally MP4."""
import argparse
import json
import subprocess
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
def validate(bundle):
if bundle.get("version") != 1 or not isinstance(bundle.get("slides"), list) or not bundle["slides"]:
raise ValueError("Expected a version 1 Studio bundle with slides")
for slide in bundle["slides"]:
if not all(isinstance(slide.get(k), str) for k in ("heading", "body", "color")):
raise ValueError("Invalid slide")
return bundle["slides"]
def wrap(draw, text, font, width):
lines = []
for paragraph in text.split("\n"):
line = ""
for char in paragraph:
if line and draw.textlength(line + char, font=font) > width:
lines.append(line)
line = ""
line += char
lines.append(line)
return lines
def render(bundle, output, font_path):
slides = validate(bundle)
output.mkdir(parents=True, exist_ok=True)
heading = ImageFont.truetype(str(font_path), 76)
body = ImageFont.truetype(str(font_path), 38)
for index, slide in enumerate(slides, 1):
image = Image.new("RGB", (1080, 1440), slide["color"])
draw = ImageDraw.Draw(image)
draw.text((80, 80), f"PROMPT / {index:02}", font=body, fill="#353535")
y = 330
for text, font, gap in [(slide["heading"], heading, 108), (slide["body"], body, 65)]:
for line in wrap(draw, text, font, 920):
if y + gap > 1360:
raise ValueError(f"Slide {index} overflows; split the text into more slides")
draw.text((80, y), line, font=font, fill="#242424")
y += gap
y += 70
image.save(output / f"slide-{index:03}.png")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("bundle", type=Path)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--font", type=Path, required=True, help="CJK font TTF/OTF/TTC")
parser.add_argument("--video", action="store_true")
args = parser.parse_args()
bundle = json.loads(args.bundle.read_text())
render(bundle, args.output, args.font)
if args.video:
# Use an explicit temporary input sequence so old PNGs cannot leak into a video.
import tempfile
import shutil
with tempfile.TemporaryDirectory() as staging:
for index in range(1, len(bundle["slides"]) + 1):
shutil.copy(args.output / f"slide-{index:03}.png", Path(staging) / f"slide-{index:03}.png")
subprocess.run(["ffmpeg", "-y", "-framerate", "1/4", "-i", str(Path(staging) / "slide-%03d.png"), "-vf", "fps=30,format=yuv420p", "-c:v", "libx264", "-movflags", "+faststart", str(args.output / "video.mp4")], check=True)
if __name__ == "__main__":
main()
-13
View File
@@ -1,13 +0,0 @@
import unittest
from prompt_tools.export import validate, wrap
class ExportTests(unittest.TestCase):
def test_bad_bundle(self):
for bundle in [{}, {"version": 1, "slides": []}, {"version": 1, "slides": [{}]}]:
with self.assertRaises(ValueError): validate(bundle)
def test_wrap_preserves_content(self):
class Draw:
def textlength(self, text, font): return len(text)
self.assertEqual(wrap(Draw(), "中文内容测试", None, 3), ["中文内", "容测试"])
def test_valid_bundle(self):
slides = [{"heading": "标题", "body": "正文", "color": "#fff"}]
self.assertEqual(validate({"version": 1, "slides": slides}), slides)
-23
View File
@@ -1,23 +0,0 @@
FROM node:24-bookworm-slim AS build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/studio/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
COPY apps/studio/package.json ./
RUN pnpm install --offline --frozen-lockfile
COPY apps/studio/ .
# Compare the two public parameter validators inside the isolated CI build.
COPY apps/waterfall-wiki/src/lib/layout-parameters.ts /waterfall-wiki/src/lib/layout-parameters.ts
COPY apps/waterfall-wiki/src/lib/series.ts /waterfall-wiki/src/lib/series.ts
COPY apps/waterfall-wiki/src/lib/canvas-document.ts /waterfall-wiki/src/lib/canvas-document.ts
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
FROM node:24-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
COPY --from=build --chown=node:node /app/.next/standalone/ ./
COPY --from=build --chown=node:node /app/.next/static/ ./.next/static/
USER node
EXPOSE 3000
CMD ["node", "server.js"]
-163
View File
@@ -1,163 +0,0 @@
{
"schemaVersion": 2,
"name": "把秋天装进一页 · 银杏观察手记",
"document": {
"version": 1,
"brief": {
"topic": "银杏与秋日观察手记",
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
},
"style": {
"version": "autumn-journal/1",
"palette": {
"paper": "#F3EFE5",
"ink": "#253C32",
"muted": "#718074",
"accent": "#C3A050"
},
"typography": {
"title": {
"fontFamily": "serif",
"fontSize": 8.4,
"fontWeight": 600,
"lineHeight": 1.15,
"letterSpacing": 0
},
"body": {
"fontFamily": "sans",
"fontSize": 3.1,
"fontWeight": 400,
"lineHeight": 1.65,
"letterSpacing": 0
},
"caption": {
"fontFamily": "sans",
"fontSize": 1.7,
"fontWeight": 500,
"lineHeight": 1.2,
"letterSpacing": 0
}
},
"spacing": {
"margin": 10,
"gap": 4
}
},
"sources": [],
"pages": [
{
"key": "cover",
"role": "cover",
"layoutId": "standard-01",
"intent": "用银杏静物与两行大标题建立主题",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "把秋天\n装进一页"
},
"T02": {
"value": "不必走很远。\n一片银杏,就是今天的风景。"
},
"T03": {
"value": "SEASONAL NOTES / 01"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 50,
"cropY": 50,
"zoom": 1
}
},
"decorations": {}
}
},
{
"key": "observe",
"role": "body",
"layoutId": "standard-01",
"intent": "用三个观察动作提供可实践的方法",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "先看见\n再记录"
},
"T02": {
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
},
"T03": {
"value": "FIELD NOTES / 02"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 40,
"zoom": 1.25
}
},
"decorations": {}
}
},
{
"key": "ending",
"role": "ending",
"layoutId": "standard-01",
"intent": "以轻量行动和提问收尾",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "留一点白\n给下一次",
"align": "middle"
},
"T02": {
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
"align": "middle"
},
"T03": {
"value": "YOUR TURN / 03"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 50,
"zoom": 1
}
},
"decorations": {},
"layers": {
"I01": {
"x": 40,
"y": 55,
"width": 40,
"height": 40,
"radius": 20
}
}
}
}
]
}
}
-7
View File
@@ -1,7 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
-3
View File
@@ -1,3 +0,0 @@
import type { NextConfig } from "next";
const config: NextConfig = { basePath: "/studio", output: "standalone", outputFileTracingRoot: process.cwd(), poweredByHeader: false };
export default config;
-27
View File
@@ -1,27 +0,0 @@
{
"name": "@prompt/studio",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"typecheck": "next typegen && tsc --noEmit",
"test": "node --test tests/*.test.mjs",
"test:smoke": "node scripts/smoke.mjs"
},
"dependencies": {
"next": "16.4.2",
"react": "19.3.1",
"react-dom": "19.3.1",
"lucide-react": "1.61.0"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.3",
"@types/node": "24.19.2",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"tailwindcss": "4.3.3",
"typescript": "5.9.3"
},
"packageManager": "pnpm@11.25.0"
}
-1030
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
export default {
plugins: { "@tailwindcss/postcss": {} },
};
-24
View File
@@ -1,24 +0,0 @@
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.route('**/api/account/session',r=>r.fulfill({json:{user:null}}));
await page.goto((process.env.STUDIO_TEST_ORIGIN||'http://8.211.154.134:18131')+'/graphic');
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
assert.equal(await page.getByRole('button',{name:'图层',exact:true}).isVisible(),false);
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
assert.equal(await page.getByRole('button',{name:'备份设计 JSON',exact:true}).isVisible(),false);
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
await page.getByRole('button',{name:'添加文字',exact:true}).click();
await page.getByRole('textbox',{name:'文字内容',exact:true}).fill('设计从这里开始');
await page.getByRole('button',{name:'添加矩形',exact:true}).click();
await page.getByRole('button',{name:'撤销',exact:true}).click();
await page.getByRole('button',{name:'重做',exact:true}).click();
await page.reload();await page.getByRole('button',{name:'文字',exact:true}).first().waitFor();
assert.ok(await page.evaluate(()=>localStorage.getItem('studio.graphic.v1').includes('设计从这里开始')));
const file=page.waitForEvent('download');await page.getByRole('button',{name:'导出 SVG',exact:true}).click();assert.ok((await file).suggestedFilename().endsWith('.svg'));
await page.getByRole('button',{name:'邮箱登录',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});
await page.screenshot({path:process.env.STUDIO_QA_SCREENSHOT||'graphic-qa.png',fullPage:true});
await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
assert.deepEqual(errors,[]);console.log('Graphic editing, history, restore, SVG download, account dialog and mobile overflow checks passed.');
}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
@@ -1,93 +0,0 @@
// Isolated browser context against the designated deployed server only.
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
const fs=require('node:fs/promises');
(async()=>{
const base=process.env.BASE_URL||'http://8.211.154.134:18131';
assert.equal(base,'http://8.211.154.134:18131');
const browser=await chromium.launch(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH,headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}:{channel:'chrome',headless:true});
try{
const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined});
const page=await context.newPage(),errors=[];let choosers=0;
page.on('pageerror',e=>errors.push(e.message));page.on('filechooser',()=>choosers++);
const snapshot=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')));
const node=id=>page.locator(`[data-node-id="${id}"]`);
const content=id=>node(id).locator('[data-node-content]');
const waitField=(id,key,value)=>page.waitForFunction(({id,key,value})=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id===id)?.[key]===value,{id,key,value});
const composer=page.getByRole('region',{name:'节点编辑器'});
const close=(a,b)=>assert.ok(Math.abs(a-b)<2,`${a} ≈ ${b}`);
async function drag(handle,dx,dy){const b=await handle.boundingBox();assert.ok(b);await page.mouse.move(b.x+b.width/2,b.y+b.height/2);await page.mouse.down();await page.mouse.move(b.x+b.width/2+dx,b.y+b.height/2+dy,{steps:12});await page.mouse.up()}
await page.goto(base+'/infinite_canvas');
await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click();
await page.getByRole('button',{name:'适应画布',exact:true}).click();
await content('brief').click({position:{x:20,y:20}});
assert.equal(await node('brief').getAttribute('data-selected'),'true');assert.equal(await node('brief').getAttribute('data-editing'),'false');
await page.waitForTimeout(700);
await content('brief').click({position:{x:20,y:20}});
await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).fill('选择后再次点击进入编辑');
await waitField('brief','text','选择后再次点击进入编辑');
await node('brief').getByRole('button',{name:'加粗',exact:true}).click();
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').style.bold,true);
await page.keyboard.press('Escape');
const before=(await snapshot()).cards.find(n=>n.id==='brief');
const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a);
await drag(content('brief'),45,25);
const after=(await snapshot()).cards.find(n=>n.id==='brief');close(after.x-before.x,45/z);close(after.y-before.y,25/z);
assert.equal(await node('brief').getAttribute('data-editing'),'false');
await content('reference').click();assert.equal(await composer.count(),0);
await page.waitForTimeout(700);await content('reference').click();await composer.waitFor();assert.equal(choosers,0,'Entering image editing must not force a file picker');
const panelBefore=await composer.boundingBox(),imageBefore=await node('reference').boundingBox();
await page.getByRole('button',{name:'缩小',exact:true}).click();
const panelAfter=await composer.boundingBox(),imageAfter=await node('reference').boundingBox();
close(panelBefore.width,panelAfter.width);close(panelBefore.height,panelAfter.height);assert.ok(imageAfter.width<imageBefore.width);
const chooser=page.waitForEvent('filechooser');await composer.getByRole('button',{name:'上传图片',exact:true}).click();
await (await chooser).setFiles({name:'reference.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=','base64')});
await page.waitForFunction(()=>!!JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='reference').image);
await node('reference').getByRole('button',{name:'图片高清 ↗',exact:true}).click();
const upscale=(await snapshot()).cards.find(n=>n.operation==='upscale');assert.ok(upscale);
const download=page.waitForEvent('download');await composer.getByRole('button',{name:'导出任务',exact:true}).click();
const task=JSON.parse(await fs.readFile(await (await download).path(),'utf8'));
assert.equal(task.target.id,upscale.id);assert.equal(task.references[0].id,'reference');assert.ok(task.references[0].image);
await page.keyboard.press('Escape');await page.getByRole('button',{name:'适应画布',exact:true}).click();
await content('brief').click({position:{x:20,y:20}});
await node('brief').getByRole('button',{name:'引用 01 / 创意简报 生成',exact:true}).click();
await page.getByRole('dialog',{name:'画布操作'}).getByRole('button',{name:'脚本',exact:true}).click();
const script=(await snapshot()).cards.find(n=>n.kind==='script');assert.ok(script);
await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('基于简报写六个分镜');
await waitField(script.id,'instruction','基于简报写六个分镜');assert.equal((await snapshot()).cards.find(n=>n.id===script.id).text,'');
await composer.getByRole('button',{name:'画布参考',exact:true}).click();
await composer.getByRole('group',{name:'选择引用节点'}).getByRole('button',{name:'02 / 视觉参考',exact:true}).click();
assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id));
await composer.getByRole('button',{name:'移除引用 01 / 创意简报',exact:true}).click();
assert.ok(!(await snapshot()).links.some(([a,b])=>a==='brief'&&b===script.id));
await page.keyboard.press('Escape');
await page.getByRole('button',{name:'适应画布',exact:true}).click();
await content('reference').click();await node('reference').getByRole('button',{name:'02 / 视觉参考 操作',exact:true}).click();
await page.getByRole('button',{name:'删除节点',exact:true}).click();
assert.ok((await snapshot()).cards.some(n=>n.id===script.id));assert.ok((await snapshot()).links.every(l=>!l.includes('reference')));
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id));
await page.keyboard.press('Escape');
await content('brief').click({position:{x:20,y:20}});
const priorWidth=(await snapshot()).cards.find(n=>n.id==='brief').width;
await drag(node('brief').getByRole('button',{name:'调整 01 / 创意简报 尺寸',exact:true}),50,30);
assert.ok((await snapshot()).cards.find(n=>n.id==='brief').width>(priorWidth||320));
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,priorWidth);
await page.getByRole('button',{name:'重做',exact:true}).click();
await page.getByRole('button',{name:'移动画布',exact:true}).click();
await page.mouse.move(15,300);await page.mouse.down();await page.mouse.move(75,340,{steps:8});await page.mouse.up();
assert.equal(await page.evaluate(()=>{const path=[...document.querySelectorAll('svg path')].find(p=>p.getAttribute('d')?.startsWith('M '));const d=path.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number),point=new DOMPoint(d[0],d[1]).matrixTransform(path.getScreenCTM()),card=document.querySelector('[data-node-id="brief"]').getBoundingClientRect();return Math.abs(point.x-card.right)<3&&Math.abs(point.y-(card.top+card.height/2))<3}),true);
const worldAt=()=>page.locator('[class*=world]').evaluate(el=>{const m=new DOMMatrix(getComputedStyle(el).transform);return {x:(1450-m.e)/m.a,y:(150-m.f)/m.d,z:m.a}});
const anchorBefore=await worldAt();await page.mouse.move(1450,150);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control');
await page.waitForFunction(z=>new DOMMatrix(getComputedStyle(document.querySelector('[class*=world]')).transform).a!==z,anchorBefore.z);
const anchorAfter=await worldAt();close(anchorBefore.x,anchorAfter.x);close(anchorBefore.y,anchorAfter.y);
const saved=await snapshot();await page.reload();await page.waitForFunction(()=>!!localStorage.getItem('studio.infinite-canvas.v1'));assert.deepEqual(await snapshot(),saved);
await page.getByRole('button',{name:'切换明暗主题',exact:true}).click();await page.reload();await page.waitForFunction(()=>document.querySelector('main').dataset.theme==='light');
if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-desktop.png'});}
await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
const viewport=await page.locator('[class*=viewport]').boundingBox();assert.equal(viewport.width,390);assert.equal(viewport.height,844);
if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-mobile.png'});
assert.deepEqual(errors,[]);console.log('Passed: selected-node re-click editing, drag, formatting, fixed composer, upload, references, task export, deletion/undo, reload and mobile bounds');
}finally{await browser.close()}
})().catch(e=>{console.error(e);process.exitCode=1});
-29
View File
@@ -1,29 +0,0 @@
/** CI-only isolated runtime checks, never a local preview workflow. */
import {readFileSync} from 'node:fs';
import {spawn} from 'node:child_process';import {once} from 'node:events';import {createServer} from 'node:net';import {createServer as createHttpServer} from 'node:http';import assert from 'node:assert/strict';import {setTimeout} from 'node:timers/promises';
const designId='00000000-0000-4000-8000-000000000001',missingId='00000000-0000-4000-8000-000000000002';
const standardId='00000000-0000-4000-8000-000000000003';
const seriesId='00000000-0000-4000-8000-000000000004';
const doc=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url))).document;
const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,author:'CI fixture',license:'test only',url:'generated',prompt:'test image',tool:'fixture'}];for(const p of doc.pages)p.parameters.images.I01.assetId=assetId;
let reads=0,mediaReads=0;
const wiki=createHttpServer((req,res)=>{assert.equal(req.headers.cookie,'session=smoke-test');if(/^\/api\/media\/[0-9a-f-]{36}$/.test(req.url)){mediaReads++;res.setHeader('Content-Type','image/png');return res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j3ioAAAAASUVORK5CYII=','base64'))}reads++;res.setHeader('Content-Type','application/json');if(req.url.endsWith(seriesId))return res.end(JSON.stringify({design:{id:seriesId,name:'完整三页作品',layoutId:'standard-01',revision:1,url:'/studio/template-layout?id='+seriesId,parameters:doc.pages[0].parameters,document:doc}}));const standard=req.url.endsWith(standardId);if(!standard&&!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:standard?standardId:designId,name:'已保存的实际页面',layoutId:standard?'standard-01':'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))});
wiki.listen(0,'127.0.0.1');await once(wiki,'listening');
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,[process.env.STUDIO_STANDALONE_SERVER || '.next/standalone/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port;
try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const entry=await fetch(base+'/studio',{redirect:'manual'});assert.equal(entry.status,307);assert.equal(entry.headers.get('location'),'/studio/graphic');const library=await fetch(base+'/studio/graphic').then(r=>r.text());assert.ok(library.includes('GRAPHIC'));assert.ok(!library.includes('ref-27')); const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28);
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg',{headers:{cookie:'session=smoke-test'}}).then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json',cookie:'session=smoke-test'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('&lt;'));}
assert.equal(reads,0,'Default pages never load a saved design');assert.ok(mediaReads>0,'SVG exports load isolated media fixtures');
const headers={cookie:'session=smoke-test'};
for(const id of [designId,standardId]){
const saved=await fetch(base+'/studio/template-layout?id='+id,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('新增元素已恢复'));assert.ok(savedHtml.includes('data-element="user-smoke"'));assert.ok(savedHtml.includes('rotate(25deg)'));assert.ok(savedHtml.includes('opacity:0.7'));assert.ok(savedHtml.includes('data-design-id="'+id+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image'));assert.ok(!/page-tools|workbench-top|contenteditable|编辑页面/.test(savedHtml));
}
const complete=await fetch(base+'/studio/template-layout?id='+seriesId,{headers});assert.equal(complete.status,200);const completeHtml=await complete.text(),artwork=completeHtml.match(/<main[\s\S]*?<\/main>/)[0];assert.equal((artwork.match(/data-page-key=/g)||[]).length,3);assert.ok(artwork.includes('data-style-version="autumn-journal/1"'));assert.ok(artwork.includes('把秋天'));assert.ok(artwork.includes('先看见'));assert.ok(artwork.includes('留一点白'));assert.ok(!/button|contenteditable|workbench-top/.test(artwork));
const second=await fetch(base+'/studio/template-layout?id='+seriesId+'&page=2&export=1',{headers});assert.equal(second.status,200);const secondHtml=(await second.text()).match(/<main[\s\S]*?<\/main>/)[0];assert.equal((secondHtml.match(/data-page-key=/g)||[]).length,1);assert.ok(secondHtml.includes('data-page="2"'));assert.ok(secondHtml.includes('height:1440px'));assert.ok(secondHtml.includes('width=2160'));assert.ok(secondHtml.includes('profile=export'));assert.ok(secondHtml.includes('Noto Serif CJK SC'));
for(const suffix of ['0','4','bad'])assert.equal((await fetch(base+'/studio/template-layout?id='+seriesId+'&page='+suffix,{headers})).status,404);
for(const path of ['/studio/template-layout','/studio/template-layout?id=invalid','/studio/template-layout?id='+missingId,'/studio/template-layout?id='+designId+'&id='+standardId])assert.equal((await fetch(base+path,{headers})).status,404);
assert.equal((await fetch(base+'/studio/layouts/ref-02?id='+designId,{headers})).status,404);
assert.equal((await fetch(base+'/studio/layouts/ref-01?id='+missingId,{headers})).status,404);
const beforeInvalid=reads;assert.equal((await fetch(base+'/studio/layouts/ref-01?id=invalid',{headers})).status,404);assert.equal(reads,beforeInvalid);
const legacy=await fetch(base+'/studio/designs/'+designId,{headers,redirect:'manual'});assert.equal(legacy.status,307);assert.equal(legacy.headers.get('location'),'/studio/template-layout?id='+designId);
const old=await fetch(base+'/studio/layouts/ref-01?id='+designId,{headers,redirect:'manual'});assert.equal(old.status,307);assert.equal(old.headers.get('location'),'/studio/template-layout?id='+designId);
assert.equal((await fetch(base+'/studio/layouts/ref-28')).status,404);assert.equal((await fetch(base+'/studio/api/layouts/ref-01',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{I01:{value:'bad'}}})})).status,400);console.log('All 27 native pages, server-loaded saved IDs, legacy redirects and render APIs passed');}finally{child.kill('SIGTERM');await once(child,'exit');await new Promise(r=>wiki.close(r))}
@@ -1,14 +0,0 @@
import {referencePreview} from '../lib/layout-images-client';
import {useState} from 'react';
import Head from 'next/head';
import {SourceCover,selections,type CoverValues} from './SourceCover';
import {tutorialPost} from '../content/tool-links';
export function LayoutSelection(){
const [active,setActive]=useState(0),[values,setValues]=useState<CoverValues[]>(selections.map(l=>({fields:Object.fromEntries(Object.entries(l.fields).filter(([,v])=>v!==undefined)) as Record<string,string>,color:'#83918a',accent:'#c3cec5'}))),[guides,setGuides]=useState(false),[status,setStatus]=useState('');
const layout=selections[active],current=values[active];
function update(patch:Partial<CoverValues>){setValues(old=>old.map((v,i)=>i===active?{...v,...patch}:v));}
async function download(format:'svg'|'json'){
try{setStatus('正在生成…');const r=await fetch('/studio/api/export/layout-selection?format='+format,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({layoutId:layout.id,...current})});if(!r.ok)throw Error();const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=layout.id+'.'+format;a.click();setTimeout(()=>URL.revokeObjectURL(url),500);setStatus('已导出当前模板。')}catch{setStatus('导出失败,请重试。')}
}
return <main className="template-trial"><Head><title>三种排版 · Studio 工具</title></Head><header className="trial-nav"><a href={tutorialPost}>← PROMPT 使用教程</a><span>STUDIO / LAYOUT SELECTION</span></header><section className="trial-intro"><div><small>REF-01 / REF-07 / REF-26</small><h1>三个构图,各有位置。</h1><p>切换真实原图对应的模板,分别试文字与配色。图位与装饰按教程构件重建为干净矢量;当前只放占位内容。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><h2>选择与编辑</h2><label>排版<select value={active} onChange={e=>{setActive(Number(e.target.value));setStatus('')}}>{selections.map((l,i)=><option key={l.id} value={i}>{l.id} · {l.name}</option>)}</select></label>{layout.slots.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<textarea rows={current.fields[s.id]?.includes('\n')?3:1} maxLength={120} value={current.fields[s.id]||''} onChange={e=>update({fields:{...current.fields,[s.id]:e.target.value}})}/></label>)}<div className="trial-colors"><label>主图色<input type="color" value={current.color} onChange={e=>update({color:e.target.value})}/></label><label>副图色<input type="color" value={current.accent} onChange={e=>update({accent:e.target.value})}/></label></div><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示构件边界</label><div className="trial-actions"><button className="trial-primary" onClick={()=>void download('svg')}>下载当前 SVG</button><button onClick={()=>void download('json')}>下载参数 JSON</button></div><p className="trial-help">每张模板单独保留本页修改,刷新恢复初始值。下载 SVG 为无边界线的正式图;原始低分辨率图层在 JSON 中保留作对照。</p><p role="status">{status}</p></aside><div><div className="selection-preview"><SourceCover layout={layout} values={current} guides={guides}/></div><div className="selection-thumbs">{selections.map((l,i)=><button key={l.id} aria-pressed={active===i} onClick={()=>setActive(i)}><SourceCover layout={l} values={values[i]}/><span>{l.id}</span></button>)}</div><details className="trial-reference"><summary>原图与构件坐标</summary><div className="trial-reference-body"><img src={referencePreview(layout.referenceUrl)} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>阅读这张原图的教程 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>x,y,w,h</th></tr></thead><tbody>{layout.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
}
-72
View File
@@ -1,72 +0,0 @@
export type NodeKind = 'text' | 'image' | 'prompt' | 'script';
export type TextStyle = {bold?:boolean;italic?:boolean;color?:string;size?:number};
export type CanvasNode = {
id:string;kind:NodeKind;title:string;text:string;x:number;y:number;
image?:string;width?:number;height?:number;style?:TextStyle;
instruction?:string;
operation?:'generate'|'upscale'|'script';
};
export type CanvasDocument = {version:1;cards:CanvasNode[];links:[string,string][]};
const kinds:NodeKind[]=['text','image','prompt','script'];
const object=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==='object'&&!Array.isArray(v);
const bounded=(v:unknown,min:number,max:number)=>typeof v==='number'&&Number.isFinite(v)&&v>=min&&v<=max;
const text=(v:unknown,max:number)=>typeof v==='string'&&v.length<=max;
const image=(v:unknown)=>typeof v==='string'&&v.length<3000000&&/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(v);
export function parseCanvasDocument(value:unknown):CanvasDocument {
const d=typeof value==='string'?JSON.parse(value):value;
if(!object(d)||d.version!==1||!Array.isArray(d.cards)||d.cards.length>80||!Array.isArray(d.links)||d.links.length>200)throw Error('画布格式不正确');
const ids=new Set<string>();
const cards:CanvasNode[]=d.cards.map((n:unknown)=>{
if(!object(n)||!text(n.id,100)||!n.id||ids.has(n.id as string)||!kinds.includes(n.kind as NodeKind)||!text(n.title,200)||!text(n.text,10000)||!bounded(n.x,-100000,100000)||!bounded(n.y,-100000,100000))throw Error('节点格式不正确');
if(n.width!==undefined&&!bounded(n.width,180,1200)||n.height!==undefined&&!bounded(n.height,160,1200)||n.image!==undefined&&!image(n.image))throw Error('节点尺寸或图片不正确');
if(n.instruction!==undefined&&!text(n.instruction,10000))throw Error('创作要求格式不正确');
if(n.operation!==undefined&&!['generate','upscale','script'].includes(n.operation as string))throw Error('生成任务类型不正确');
let style:TextStyle|undefined;
if(n.style!==undefined){
if(!object(n.style)||n.style.bold!==undefined&&typeof n.style.bold!=='boolean'||n.style.italic!==undefined&&typeof n.style.italic!=='boolean'||n.style.color!==undefined&&(typeof n.style.color!=='string'||!/^#[a-f\d]{6}$/i.test(n.style.color))||n.style.size!==undefined&&!bounded(n.style.size,10,72))throw Error('文字样式不正确');
style={...(n.style.bold===undefined?{}:{bold:n.style.bold as boolean}),...(n.style.italic===undefined?{}:{italic:n.style.italic as boolean}),...(n.style.color===undefined?{}:{color:n.style.color as string}),...(n.style.size===undefined?{}:{size:n.style.size as number})};
}
ids.add(n.id as string);
return {id:n.id as string,kind:n.kind as NodeKind,title:n.title as string,text:n.text as string,x:n.x as number,y:n.y as number,...(n.width===undefined?{}:{width:n.width as number}),...(n.height===undefined?{}:{height:n.height as number}),...(n.image===undefined?{}:{image:n.image as string}),...(style?{style}:{}),...(n.instruction===undefined?{}:{instruction:n.instruction as string}),...(n.operation===undefined?{}:{operation:n.operation as CanvasNode['operation']})};
});
const pairs=new Set<string>();
const links:[string,string][]=d.links.map((l:unknown)=>{
if(!Array.isArray(l)||l.length!==2||l[0]===l[1]||!l.every(id=>typeof id==='string'&&ids.has(id)))throw Error('引用节点不存在');
const key=JSON.stringify(l);if(pairs.has(key))throw Error('引用重复');pairs.add(key);return [l[0],l[1]];
});
const outgoing=new Map<string,string[]>(),indegree=new Map(cards.map(n=>[n.id,0]));
for(const [from,to] of links){outgoing.set(from,[...(outgoing.get(from)||[]),to]);indegree.set(to,indegree.get(to)!+1)}
const roots=cards.filter(n=>indegree.get(n.id)===0).map(n=>n.id);let visited=0;
while(roots.length){const id=roots.pop()!;visited++;for(const to of outgoing.get(id)||[]){const count=indegree.get(to)!-1;indegree.set(to,count);if(!count)roots.push(to)}}
if(visited!==cards.length)throw Error('引用不能形成循环');
return {version:1,cards,links};
}
export function referencedNodes(doc:CanvasDocument,target:string):CanvasNode[]{
return doc.links.filter(([,to])=>to===target).map(([from])=>doc.cards.find(n=>n.id===from)!).filter(Boolean);
}
export function connectNodes(doc:CanvasDocument,from:string,to:string):CanvasDocument {
if(from===to||![from,to].every(id=>doc.cards.some(n=>n.id===id)))throw Error('请选择不同的有效节点');
if(doc.links.some(([a,b])=>a===from&&b===to))return doc;
if(doc.links.length>=200)throw Error('最多 200 条引用');
const seen=new Set<string>();const pending=[to];
while(pending.length){const id=pending.pop()!;if(id===from)throw Error('引用不能形成循环');if(seen.has(id))continue;seen.add(id);pending.push(...doc.links.filter(([a])=>a===id).map(([,b])=>b));}
return {...doc,links:[...doc.links,[from,to]]};
}
export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument {
return {...doc,cards:doc.cards.filter(n=>n.id!==id),links:doc.links.filter(l=>!l.includes(id))};
}
export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument {
if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确');
const result=connectNodes({...doc,cards:[...doc.cards,node]},source,node.id);
return parseCanvasDocument(result);
}
export function agentTask(doc:CanvasDocument,id:string){
const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在');
const instruction=node.instruction??node.text;
if(!instruction.trim()&&node.operation!=='upscale')throw Error('请先填写创作要求');
const references=referencedNodes(doc,id);
if(node.operation==='upscale'&&!references.some(n=>n.kind==='image'&&n.image))throw Error('请先引用一张图片');
return {schema:'studio.canvas-task/1',target:{id:node.id,kind:node.kind,title:node.title},operation:node.operation||'generate',instruction,...(node.image?{sourceImage:node.image}:{}),references:references.map(n=>({id:n.id,kind:n.kind,title:n.title,text:n.text,...(n.image?{image:n.image}:{}),...(n.style?{style:n.style}:{})})),result:{targetId:node.id,allowedFields:node.kind==='image'?['image','text']:['text']}};
}
-32
View File
@@ -1,32 +0,0 @@
export type Slot={id:string;kind:string;role:string;box:number[];direction:string;text:string|null;reading:string};
export type Layout={id:string;name:string;width:number;height:number;elements:Slot[]};
export type TextValue={value:string;color?:string;scale?:number;align?:'start'|'middle'|'end';fontFamily?:'sans'|'serif'|'mono';fontSize?:number;fontWeight?:number;lineHeight?:number;letterSpacing?:number};
export type ImageValue={assetId?:string;color?:string;fit?:'cover'|'contain';cropX?:number;cropY?:number;zoom?:number};
export type DecorationValue={color?:string;hidden?:boolean};
export type LayerValue={x?:number;y?:number;width?:number;height?:number;rotation?:number;opacity?:number;radius?:number;z?:number;hidden?:boolean;locked?:boolean};
export type AddedSlot=Slot & {shape?:'rectangle'|'ellipse'};
export type Parameters={texts:Record<string,TextValue>;images:Record<string,ImageValue>;decorations:Record<string,DecorationValue>;paper?:string;layers?:Record<string,LayerValue>;added?:AddedSlot[]};
export class LayoutParameterError extends Error{}
export const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v);
function fail(message:string):never{throw new LayoutParameterError(message)}
function object(v:unknown):Record<string,unknown>{if(!v||typeof v!=='object'||Array.isArray(v))fail('参数必须为 JSON 对象');return v as Record<string,unknown>}
function keys(v:Record<string,unknown>,allowed:string[]){if(Object.keys(v).some(k=>!allowed.includes(k)))fail('未知参数或构件 ID')}
function number(v:unknown,min:number,max:number){if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max)fail(`数值范围 ${min}–${max}`);return v}
function color(v:unknown){if(typeof v!=='string'||!/^#[a-f0-9]{6}$/i.test(v))fail('颜色使用 #RRGGBB');return v}
export function parameters(layout:Layout,input:unknown):Parameters{
const raw=object(input);keys(raw,['texts','images','decorations','paper','layers','added']);const result:Parameters={texts:{},images:{},decorations:{}};
const added:AddedSlot[]=[];
if(raw.added!==undefined){if(!Array.isArray(raw.added)||raw.added.length>100)fail('最多新增100个元素');for(const entry of raw.added){const v=object(entry);keys(v,['id','kind','role','box','direction','text','reading','shape']);if(typeof v.id!=='string'||!/^user-[a-zA-Z0-9-]{1,60}$/.test(v.id)||added.some(s=>s.id===v.id))fail('新增元素 ID 无效或重复');if(!['text','image','decoration'].includes(String(v.kind)))fail('新增元素类型无效');if(typeof v.role!=='string'||v.role.length>80)fail('图层名称最多80字');if(!Array.isArray(v.box)||v.box.length!==4)fail('box 为 x,y,width,height');const box=v.box.map((n,i)=>number(n,i<2?-10000:.1,10000));if(v.direction!=='horizontal'||v.reading!=='custom'||(v.text!==null&&(typeof v.text!=='string'||v.text.length>500)))fail('新增元素内容无效');if(v.shape!==undefined&&!['rectangle','ellipse'].includes(String(v.shape)))fail('无效形状');added.push({id:v.id,kind:String(v.kind),role:v.role,box,direction:'horizontal',text:v.text as string|null,reading:'custom',...(v.shape?{shape:v.shape as AddedSlot['shape']}:{})})}result.added=added}
const elements=[...layout.elements,...added];
if(raw.layers!==undefined){const entries=object(raw.layers);keys(entries,elements.map(e=>e.id));result.layers={};for(const [id,entry] of Object.entries(entries)){const v=object(entry);keys(v,['x','y','width','height','rotation','opacity','radius','z','hidden','locked']);const layer:Record<string,number|boolean>={};for(const [key,value] of Object.entries(v)){if(key==='hidden'||key==='locked'){if(typeof value!=='boolean')fail('图层开关必须为布尔值');layer[key]=value}else{const range:Record<string,number[]>={x:[-10000,10000],y:[-10000,10000],width:[.1,10000],height:[.1,10000],rotation:[-360,360],opacity:[0,1],radius:[0,5000],z:[-1000,1000]};layer[key]=number(value,range[key][0],range[key][1])}}result.layers[id]=layer}}
if(raw.paper!==undefined)result.paper=color(raw.paper);
for(const [group,kind] of [['texts','text'],['images','image'],['decorations','decoration']] as const){
const entries=object(raw[group]??{});keys(entries,elements.filter(e=>e.kind===kind).map(e=>e.id));
for(const [id,value] of Object.entries(entries)){const v=object(value);
if(group==='texts'){keys(v,['value','color','scale','align','fontFamily','fontSize','fontWeight','lineHeight','letterSpacing']);if(typeof v.value!=='string'||Array.from(v.value).length>500||v.value.split('\n').length>8)fail('文字最多 500 字 / 8 行');const t:TextValue={value:v.value};if(v.color!==undefined)t.color=color(v.color);if(v.scale!==undefined){if(typeof v.scale!=='number'||!Number.isFinite(v.scale)||v.scale<.25||v.scale>2)fail('scale 范围 0.25–2');t.scale=v.scale}if(v.align!==undefined){if(!['start','middle','end'].includes(String(v.align)))fail('无效 align');t.align=v.align as TextValue['align']}if(v.fontFamily!==undefined){if(!['sans','serif','mono'].includes(String(v.fontFamily)))fail('无效字体');t.fontFamily=v.fontFamily as TextValue['fontFamily']}for(const [key,min,max] of [['fontSize',.1,1000],['fontWeight',100,900],['lineHeight',.5,4],['letterSpacing',-20,100]] as const)if(v[key]!==undefined)t[key]=number(v[key],min,max);result.texts[id]=t;
}else if(group==='images'){keys(v,['assetId','color','fit','cropX','cropY','zoom']);const image:ImageValue={};if(v.assetId!==undefined){if(!uuid(v.assetId))fail('assetId 必须为已上传图片 UUID');image.assetId=v.assetId}if(v.color!==undefined)image.color=color(v.color);if(v.fit!==undefined){if(v.fit!=='cover'&&v.fit!=='contain')fail('fit 使用 cover 或 contain');image.fit=v.fit}for(const [key,min,max] of [['cropX',0,100],['cropY',0,100],['zoom',1,5]] as const)if(v[key]!==undefined)image[key]=number(v[key],min,max);result.images[id]=image;
}else{keys(v,['color','hidden']);const d:DecorationValue={};if(v.color!==undefined)d.color=color(v.color);if(v.hidden!==undefined){if(typeof v.hidden!=='boolean')fail('hidden 必须为布尔值');d.hidden=v.hidden}result.decorations[id]=d}
}
}
return result;
}
-42
View File
@@ -1,42 +0,0 @@
import {parameters,LayoutParameterError,type Layout,type Parameters,type TextValue,uuid} from './layout-parameters';
export type Typography=Required<Pick<TextValue,'fontFamily'|'fontSize'|'fontWeight'|'lineHeight'|'letterSpacing'>>;
export type Series={version:1;brief:{topic:string;audience:string;intent:string;designIntent:string;imageStrategy:string};style:{version:string;palette:{paper:string;ink:string;muted:string;accent:string};typography:Record<'title'|'body'|'caption',Typography>;spacing:{margin:number;gap:number}};sources:{assetId:string;author:string;license:string;url:string;prompt:string;tool:string}[];pages:{key:string;role:'cover'|'body'|'ending';layoutId:string;intent:string;bindings:Record<string,'title'|'body'|'caption'>;parameters:Parameters}[]};
function fail(message:string):never{throw new LayoutParameterError(message)}
const obj=(v:unknown)=>{if(!v||typeof v!=='object'||Array.isArray(v))return fail('作品字段必须是对象');return v as Record<string,unknown>};
function exact(v:unknown,allowed:string[]){const o=obj(v);if(Object.keys(o).some(k=>!allowed.includes(k)))fail('作品包含未知字段');return o}
function str(v:unknown,max=1000){if(typeof v!=='string'||!v.trim()||v.length>max)fail('作品文字缺失或超长');return v as string}
function num(v:unknown,min:number,max:number){if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max)fail('作品数值超出范围');return v as number}
/** Closed, bounded content document. The same validator runs in persistence and SSR. */
export function series(input:unknown,layouts:Layout[]):Series{
const raw=exact(input,['version','brief','style','sources','pages']);if(raw.version!==1)fail('不支持的作品版本');
const brief=exact(raw.brief,['topic','audience','intent','designIntent','imageStrategy']);for(const k of ['topic','audience','intent','designIntent','imageStrategy'])str(brief[k],2000);
const style=exact(raw.style,['version','palette','typography','spacing']);str(style.version,80);
const palette=exact(style.palette,['paper','ink','muted','accent']);for(const k of ['paper','ink','muted','accent'])if(!/^#[a-f\d]{6}$/i.test(String(palette[k])))fail('作品颜色使用 #RRGGBB');
const typography=exact(style.typography,['title','body','caption']);
for(const role of ['title','body','caption']){const t=exact(typography[role],['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing']);if(!['sans','serif','mono'].includes(String(t.fontFamily)))fail('无效作品字体');num(t.fontSize,.1,40);num(t.fontWeight,100,900);num(t.lineHeight,.5,4);num(t.letterSpacing,-5,10)}
const spacing=exact(style.spacing,['margin','gap']);num(spacing.margin,4,16);num(spacing.gap,1,10);if(Number(spacing.margin)*2+Number(spacing.gap)*4>48)fail('页边距与间距组合过大');
if(!Array.isArray(raw.sources)||raw.sources.length>100)fail('素材来源最多100条');
const sources=raw.sources.map(v=>{const s=exact(v,['assetId','author','license','url','prompt','tool']);if(!uuid(str(s.assetId,36)))fail('来源需素材 UUID');for(const k of ['author','license','url','prompt','tool'])str(s[k],4000);if(s.url!=='generated'&&!/^https?:\/\//.test(String(s.url)))fail('来源使用 http(s) 地址或 generated');return s as Series['sources'][number]});
if(!Array.isArray(raw.pages)||raw.pages.length<3||raw.pages.length>18)fail('作品需要3–18页');
const seen=new Set<string>();
const pages=raw.pages.map((entry,index)=>{const p=exact(entry,['key','role','layoutId','intent','bindings','parameters']);const key=str(p.key,60);if(!/^[a-z0-9-]+$/.test(key)||seen.has(key))fail('页面 key 必须唯一');seen.add(key);
const role=index===0?'cover':index===(raw.pages as unknown[]).length-1?'ending':'body';if(p.role!==role)fail('页序须为封面、正文、结尾');str(p.intent,1000);const layout=layouts.find(l=>l.id===p.layoutId);if(!layout)fail('作品模板不存在');const resolvedLayout=layout!;const params=parameters(resolvedLayout,p.parameters);const bindings=obj(p.bindings);const slots=[...resolvedLayout.elements,...(params.added||[])];for(const [id,token] of Object.entries(bindings)){if(!slots.some(s=>s.id===id&&s.kind==='text')||!['title','body','caption'].includes(String(token)))fail('字体绑定无效');if(!params.texts[id])fail('绑定文字必须提供文案')}
// Every visible text is authored; template placeholder copy must never leak into a finished series.
for(const s of slots)if(s.kind==='text'&&!params.layers?.[s.id]?.hidden&&!params.texts[s.id])fail('请填写或隐藏所有文字元素');
for(const image of Object.values(params.images))if(image.assetId&&!sources.some(s=>s.assetId===image.assetId))fail('每张图片需记录作者、许可和生成过程');
return {...p,parameters:params,bindings} as Series['pages'][number]});
return {version:1,brief:brief as Series['brief'],style:style as Series['style'],sources,pages};
}
/** Style units use a 120-wide reference canvas; explicit element values always win. */
export function pageParameters(doc:Series,page:Series['pages'][number],layout:Layout):Parameters{
const p=page.parameters,s=doc.style,k=layout.width/120;
const result:Parameters={...p,paper:p.paper||s.palette.paper,texts:{...p.texts},images:{...p.images},decorations:{...p.decorations},layers:{...p.layers}};
for(const [id,token] of Object.entries(page.bindings)){const t=s.typography[token];result.texts[id]={...t,fontSize:t.fontSize*k,letterSpacing:t.letterSpacing*k,color:token==='caption'?s.palette.muted:s.palette.ink,...p.texts[id]}}
for(const slot of [...layout.elements,...(p.added||[])]){if(slot.kind==='image')result.images[slot.id]={color:s.palette.accent,...p.images[slot.id]};if(slot.kind==='decoration')result.decorations[slot.id]={color:s.palette.accent,...p.decorations[slot.id]}}
if(layout.id==='standard-01'){
const m=s.spacing.margin,g=s.spacing.gap,w=120-2*m;
const boxes:Record<string,number[]>={T03:[m,m,w,4],T01:[m,m+4+g,w,28],I01:[m,m+32+2*g,w,50],T02:[m,m+82+3*g,w,27],D01:[m,160-m,w,.4]};
for(const [id,[x,y,width,height]] of Object.entries(boxes))result.layers![id]={x,y,width,height,...p.layers?.[id]};
}
return result;
}
-3
View File
@@ -1,3 +0,0 @@
import type { AppProps } from "next/app";
import "../styles/globals.css";
export default function App({Component,pageProps}:AppProps){return <Component {...pageProps}/>;}
-10
View File
@@ -1,10 +0,0 @@
import type {GetStaticPaths,GetStaticProps} from 'next';
import Head from 'next/head';
import Link from 'next/link';
import {LayoutSelection} from '../../components/LayoutSelection';
import {tutorialPost} from '../../content/tool-links';
import {TemplateTrial} from '../../components/TemplateTrial';
import {ideas,type Idea} from '../../content/ideas';
export default function IdeaPage({idea}:{idea:Idea}){if(idea.tool==='layout-selection')return <LayoutSelection/>;if(idea.template)return <TemplateTrial idea={idea}/>;return <main className="editor"><Head><title>{idea.title} · 创作站</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head><header><a href={tutorialPost}>← PROMPT 使用教程</a><a href={'/studio/api/export/'+idea.slug}>导出内容包 ↓</a><a href={'/studio/api/export/'+idea.slug+'?format=markdown'}>Markdown ↓</a><button onClick={()=>window.print()}>打印 / PDF</button></header><h1>{idea.title}</h1><p>{idea.summary}</p><div className="slides">{idea.slides.map((s,i)=><article className="slide" key={i} style={{background:s.color}}><small>PROMPT STUDIO / {String(i+1).padStart(2,'0')}</small><h2>{s.heading}</h2><p>{s.body}</p><footer>记录灵感 · 自由表达</footer></article>)}</div></main>}
export const getStaticPaths:GetStaticPaths=async()=>({paths:ideas.map(i=>({params:{slug:i.slug}})),fallback:false});
export const getStaticProps:GetStaticProps=async({params})=>{const idea=ideas.find(i=>i.slug===params?.slug);return idea?{props:{idea}}:{notFound:true}};
-3
View File
@@ -1,3 +0,0 @@
import type {GetServerSideProps} from 'next';
export default function StudioEntry(){return null}
export const getServerSideProps:GetServerSideProps=async()=>({redirect:{destination:'/graphic',permanent:false}});
@@ -1,3 +0,0 @@
.root{position:static}.panel{position:absolute;left:0;top:calc(100% + 12px);width:min(390px,calc(100vw - 32px));max-height:75dvh;overflow:auto;padding:20px;border:1px solid #414141;border-radius:16px;background:#242424;color:#ddd;box-shadow:0 15px 70px #0005;z-index:12;display:grid;gap:14px;font-size:12px}.panel header,.actions,.conflict{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}.panel button{font-size:11px!important;padding:7px 10px!important;background:#333!important;color:inherit!important;border:1px solid #555!important}.panel button:disabled{opacity:.45}.panel textarea{width:100%;height:150px;resize:vertical;background:#181818;color:#ddd;border:1px solid #444;border-radius:8px;padding:10px;font:10px/1.7 monospace}.panel p{margin:0;color:#aaa;line-height:1.7}.panel small{color:#aaa;font-size:10px}.panel label{display:grid;gap:8px}.panel select{width:100%;background:#333;color:inherit;padding:8px;border-radius:6px}.panel ul{padding:0;margin:0;list-style:none;display:grid;gap:12px}.panel li{display:grid;gap:5px;border-top:1px solid #444;padding-top:10px}.panel li button{justify-self:start}.panel a{color:inherit!important;text-decoration:underline!important}:global([data-theme=light]) .panel{background:white;color:#333;border-color:#ccc}:global([data-theme=light]) .panel button{background:#f3f3f3!important;border-color:#ddd!important}
:global(header):has(> .root .panel){z-index:12}
@@ -1,4 +0,0 @@
.composer{position:fixed;z-index:6;left:50%;bottom:92px;transform:translateX(-50%);width:min(900px,calc(100vw - 40px));height:280px;border:1px solid #414141;border-radius:22px;padding:20px 24px;background:#242424;color:#ddd;display:flex;flex-direction:column;box-shadow:0 15px 70px #0003}.composer[data-expanded=true]{height:min(65dvh,650px)}.composer header{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px}.composer header>span,.composer header>div{display:flex;align-items:center;gap:8px}.composer header button,.composer footer button{background:#303030;border-color:#414141;font-size:11px;padding:7px 10px}.composer textarea{display:block;flex:1;min-height:50px;width:100%;resize:none;padding:17px 0;border:0;background:transparent;color:inherit;font:inherit;line-height:1.8;outline:none}.composer footer{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:10px;color:#888}.references{display:flex;gap:10px;padding-top:15px;flex-shrink:0;overflow:auto}.reference{width:70px;height:65px;border:1px solid #555;border-radius:8px;position:relative;display:grid;place-items:center;flex-shrink:0;overflow:hidden;background:#333}.reference img{width:100%;height:100%;object-fit:cover}.reference>span{position:absolute;top:3px;left:4px;background:#151515;color:white;border-radius:50%;padding:1px 4px;font-size:9px}.reference small{position:absolute;bottom:0;width:100%;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:#222b;font-size:8px;padding:3px}.reference button{position:absolute;top:1px;right:1px;padding:2px!important;background:#222c!important;border:0!important}.picker{position:absolute;bottom:calc(100% + 10px);left:0;background:#242424;border:1px solid #444;border-radius:14px;padding:10px;max-height:200px;overflow:auto;display:grid;gap:5px;min-width:220px}.picker button{justify-content:flex-start;background:transparent;border:0}.picker>span{font-size:11px;padding:12px}:global([data-theme=light]) .composer,:global([data-theme=light]) .picker{background:#fff;color:#333;border-color:#ccc}:global([data-theme=light]) .composer button{background:#f1f1f1;color:#333;border-color:#ddd}@media(max-width:600px){.composer{width:calc(100vw - 20px);padding:15px;bottom:80px;height:260px;border-radius:16px}.composer header>span{max-width:95px;overflow:hidden;white-space:nowrap;font-size:10px}.composer header button{padding:5px;font-size:10px}.composer header{gap:4px}.composer header>div{gap:4px}}
.composer header input{width:200px;max-width:25vw;min-width:0;border:0;background:transparent;color:inherit;font:inherit;outline:none}
.composer footer>div{display:flex;gap:8px;flex-shrink:0}@media(max-width:600px){.composer footer>div{gap:4px}.composer footer button{font-size:10px;padding:6px}.composer footer>span{min-width:0}}
@@ -1,3 +0,0 @@
.node{position:absolute;background:#242424;border:1px solid #414141;border-radius:13px;color:#ddd;pointer-events:auto;touch-action:none;cursor:grab;outline:none}.node[data-selected=true]{border-color:#999}.node[data-editing=true]{border:2px solid #aaa;cursor:default}.title{position:absolute;top:-31px;left:0;right:0;display:flex;gap:7px;align-items:center;height:26px;color:#969696;font-size:12px;white-space:nowrap}.title>span{overflow:hidden;text-overflow:ellipsis}.title>button{margin-left:auto;padding:3px!important;background:transparent!important;border:0!important}.content,.image{height:100%;overflow:hidden;border-radius:12px}.content{padding:25px;white-space:pre-wrap;overflow:auto;scrollbar-width:thin}.content p{margin:0;line-height:1.7;pointer-events:none}.image{position:relative;display:grid;place-items:center}.image img{width:100%;height:100%;object-fit:contain;pointer-events:none;user-select:none;-webkit-user-drag:none}.empty{height:100%;width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;color:#777}.empty button{background:transparent!important;border:0!important;color:#bbb!important;font-size:12px!important}.node>textarea{width:100%;height:100%;border:0;border-radius:12px;background:transparent;color:inherit;outline:none;resize:none;padding:25px;line-height:1.7;touch-action:auto}.format{position:absolute;bottom:calc(100% + 48px);left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:4px;background:#242424;border:1px solid #444;border-radius:12px;padding:6px;white-space:nowrap;z-index:3}.format input{width:24px;height:24px;padding:0;border:0;background:transparent}.format select{max-width:100px;background:transparent;color:#ddd;border:0;padding:6px}.format button{padding:5px!important;border:0!important;background:transparent!important}.format button[aria-pressed=true]{background:#4b4b4b!important}.reference{position:absolute!important;right:-38px;top:calc(50% - 14px);width:28px;height:28px;border-radius:50%!important;border:1px solid #777!important;padding:2px!important;background:#202020!important;color:#ddd!important}.resize{position:absolute!important;right:3px;bottom:3px;padding:3px!important;border:0!important;background:transparent!important;color:#888!important;cursor:nwse-resize!important}.upscale{position:absolute;bottom:10px;left:10px;font-size:10px!important;background:#242424df!important}.node:focus-visible{outline:2px solid #a6c579;outline-offset:3px}:global([data-theme=light]) .node{background:white;color:#333;border-color:#d0d0d0}:global([data-theme=light]) .node[data-selected=true]{border-color:#555}:global([data-theme=light]) .empty button{color:#555!important}:global([data-theme=light]) .format{background:white;border-color:#ccc}:global([data-theme=light]) .format select{color:#333}
.title>input{min-width:0;width:100%;border:0!important;background:transparent!important;padding:0!important;color:inherit;font:inherit;outline:none}.title>button{flex-shrink:0}
.incoming{right:auto!important;left:-38px}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,41 +0,0 @@
import test from 'node:test';
import {readFileSync} from 'node:fs';
import assert from 'node:assert/strict';
import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask} from '../src/lib/canvas-document.ts';
const note={id:'brief',kind:'text',title:'创意简报',text:'清晨的城市',x:20,y:50};
const doc={version:1,cards:[note],links:[]};
test('existing v1 browser drafts remain readable and untrusted fields are discarded',()=>{
assert.deepEqual(parseCanvasDocument(JSON.stringify({...doc,token:'ignored',cards:[{...note,html:'ignored'}]})),doc);
for(const bad of [{...doc,cards:[note,note]},{...doc,links:[['brief','missing']]},{...doc,cards:[{...note,image:'https://example.com/x.png'}]},{...doc,cards:[{...note,style:{color:'url(javascript:bad)'}}]}])assert.throws(()=>parseCanvasDocument(bad));
});
test('derived image and script nodes preserve directed references across save and restore',()=>{
const picture={...note,id:'picture',kind:'image',title:'参考图',image:'data:image/png;base64,AAAA',operation:'generate'};
const next=deriveNode(doc,'brief',picture);
const restored=parseCanvasDocument(JSON.stringify(deriveNode(next,'picture',{...note,id:'script',kind:'script',operation:'script'})));
assert.equal(referencedNodes(restored,'picture')[0].id,'brief');assert.equal(referencedNodes(restored,'script')[0].id,'picture');
assert.throws(()=>connectNodes(restored,'script','brief'),/循环/);
assert.throws(()=>parseCanvasDocument({...restored,links:[...restored.links,['script','brief']]}),/循环/);
const removed=removeCanvasNode(restored,'picture');assert.deepEqual(removed.links,[]);assert.equal(removed.cards.length,2);
});
test('agent task captures current referenced content and validates upscale inputs',()=>{
const next=deriveNode(doc,'brief',{...note,id:'out',kind:'script',operation:'script'});
assert.equal(agentTask(next,'out').references[0].text,'清晨的城市');
assert.equal(agentTask(next,'out').result.targetId,'out');
assert.throws(()=>agentTask({...next,cards:next.cards.map(n=>n.id==='out'?{...n,operation:'upscale'}:n)},'out'),/引用一张图片/);
});
test('instructions stay distinct from generated content in agent requests',()=>{
const result={...note,id:'script',kind:'script',text:'已有脚本正文',instruction:'改写为六个分镜',operation:'script'};
const restored=parseCanvasDocument(JSON.stringify(deriveNode(doc,'brief',result)));
assert.equal(agentTask(restored,'script').instruction,'改写为六个分镜');
assert.equal(restored.cards.find(n=>n.id==='script').text,'已有脚本正文');
});
test('editing an image passes the current image to the external agent',()=>{
const image={...note,id:'image',kind:'image',instruction:'把背景改为雪夜',image:'data:image/png;base64,AAAA'};
assert.equal(agentTask({version:1,cards:[image],links:[]},'image').sourceImage,image.image);
});
test('agent and browser parse the same canvas document contract',()=>{
assert.equal(readFileSync(new URL('../src/lib/canvas-document.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/canvas-document.ts',import.meta.url),'utf8'));
});
@@ -1,8 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {parseGraphicDocument} from '../src/lib/graphic-document.ts';
const item={id:'one',kind:'text',name:'标题',text:'<script>文本</script>',x:20,y:20,w:300,h:100,fill:'#123456',size:32,hidden:false,locked:false};
const doc={name:'作品',background:'#ffffff',items:[item]};
test('graphic backup restores editable text and ignores unrelated properties',()=>{assert.deepEqual(parseGraphicDocument(JSON.stringify({...doc,extra:'ignored'})),doc)});
test('graphic backup rejects corrupted layers, duplicate IDs and unbounded geometry',()=>{for(const items of [[{...item,text:null}],[item,item],[{...item,x:Infinity}],[{...item,w:-1}],[{...item,fill:'url(https://example.com)'}]])assert.throws(()=>parseGraphicDocument(JSON.stringify({...doc,items})));});
test('graphic backup disallows active SVG and remote image loads',()=>{for(const src of ['https://example.com/image.png','javascript:alert(1)','data:image/svg+xml;base64,PHN2Zz4='])assert.throws(()=>parseGraphicDocument(JSON.stringify({...doc,items:[{...item,kind:'image',src}]})));});
-27
View File
@@ -1,27 +0,0 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {embedSvgMedia} from '../src/lib/layout-images.ts';
const id='a05dc841-240a-5a3a-b01b-d589fc21cd36';
test('SVG export fetches each internal media once with the caller authentication',async()=>{
const original=globalThis.fetch;let calls=0;
try{
globalThis.fetch=async(url,options)=>{calls++;assert.equal(url,'http://waterfall-wiki:3000/api/media/'+id);assert.equal(options.headers.cookie,'session=fixture');assert.equal(options.redirect,'error');return new Response(Buffer.from('fixture'),{headers:{'content-type':'image/png'}});};
const svg=await embedSvgMedia({headers:{cookie:'session=fixture'}},`<svg><image href="/api/media/${id}"/><image href="/api/media/${id}"/></svg>`);
assert.equal(calls,1);assert.equal((svg.match(/data:image\/png;base64,Zml4dHVyZQ==/g)||[]).length,2);assert.ok(!svg.includes('/api/media/'));
}finally{globalThis.fetch=original;}
});
test('SVG export rejects unavailable or non-image objects',async()=>{
const original=globalThis.fetch;
try{for(const response of [new Response('missing',{status:404}),new Response('<svg/>',{headers:{'content-type':'image/svg+xml'}})]){
globalThis.fetch=async()=>response;await assert.rejects(()=>embedSvgMedia({headers:{}},`<image href="/api/media/${id}"/>`),/模板图片不可访问/);
}}finally{globalThis.fetch=original;}
});
test('SVG exports without managed images do not access storage',async()=>{
assert.equal(await embedSvgMedia({headers:{}},'<svg><text>Title</text></svg>'),'<svg><text>Title</text></svg>');
});
test('SVG export uses the configured isolated Wiki origin',async()=>{
const original=globalThis.fetch,previous=process.env.STUDIO_WIKI_ORIGIN;
try{process.env.STUDIO_WIKI_ORIGIN='http://127.0.0.1:19000';globalThis.fetch=async url=>{assert.equal(url,'http://127.0.0.1:19000/api/media/'+id);return new Response('fixture',{headers:{'content-type':'image/png'}})};assert.ok((await embedSvgMedia({headers:{}},`<image href="/api/media/${id}"/>`)).includes('data:image/png;base64,'));}
finally{globalThis.fetch=original;if(previous===undefined)delete process.env.STUDIO_WIKI_ORIGIN;else process.env.STUDIO_WIKI_ORIGIN=previous;}
});
@@ -1,7 +0,0 @@
import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';
import {parameters} from '../src/lib/layout-parameters.ts';
const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url)));
test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^\/api\/media\/[a-f0-9-]{36}$/);if(s.kind==='image')assert.match(s.shape,/^\/api\/media\/[a-f0-9-]{36}$/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}});
test('parameters reject mismatched slots, unsafe URLs and excessive text',()=>{const l=layouts[0];for(const input of [{texts:{I01:{value:'bad'}}},{images:{I01:{url:'https://evil.test'}}},{texts:{T01:{value:'x',scale:3}}},{texts:{T01:{value:'x'.repeat(501)}}},{paper:'url(x)'},{decorations:{D01:{hidden:'true'}}},null,[]])assert.throws(()=>parameters(l,input));const p=parameters(l,{texts:{T01:{value:'<script>not markup</script>',scale:1.2,align:'middle'}},images:{I01:{color:'#abcdef',fit:'contain'}},decorations:{D01:{hidden:true}}});assert.equal(p.texts.T01.value,'<script>not markup</script>');assert.equal(p.decorations.D01.hidden,true)});
test('workspace parameters round-trip identically through Studio and Wiki validation',async()=>{const {parameters:wiki}=await import('../../waterfall-wiki/src/lib/layout-parameters.ts');for(const l of layouts){const added={id:'user-example',kind:'text',role:'Added text',box:[3,4,20,12],direction:'horizontal',text:'Hello',reading:'custom'};const input={added:[added],layers:{T01:{x:2,y:4,width:15,height:10,rotation:25,opacity:.7,radius:2,z:10,hidden:false,locked:true},'user-example':{x:8,z:20}},texts:{T01:{value:'Updated',fontFamily:'serif',fontSize:4,fontWeight:700,lineHeight:1.6,letterSpacing:.2},'user-example':{value:'Hello'}},images:{I01:{fit:'cover',cropX:15,cropY:85,zoom:2}}};assert.deepEqual(parameters(l,input),wiki(l,input));assert.deepEqual(parameters(l,parameters(l,input)),parameters(l,input))}});
test('workspace validation rejects unbounded, mismatched and executable properties',()=>{const l=layouts[0];for(const input of [{layers:{unknown:{x:2}}},{layers:{T01:{rotation:Infinity}}},{layers:{T01:{width:0}}},{layers:{T01:{opacity:2}}},{layers:{T01:{locked:'yes'}}},{texts:{T01:{value:'x',fontFamily:'url(evil)'}}},{images:{I01:{cropX:101}}},{images:{I01:{zoom:0}}},{added:[{id:'T01'}]},{added:Array(101).fill({})},{layers:{T01:{onClick:'evil'}}}])assert.throws(()=>parameters(l,input))});
-19
View File
@@ -1,19 +0,0 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {nativeLayout,defaultText,textSize,imageShape,designUrl,newElementId} from '../src/lib/native-layout.ts';
const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url)));
test('all 27 native pages are independent of source images and supply actual text',()=>{
for(const original of layouts){const layout=nativeLayout(original),serialized=JSON.stringify(layout);assert.ok(!serialized.includes('data:image'));assert.ok(!serialized.includes('raster'));assert.ok(!serialized.includes('source'));assert.ok(serialized.length<7000);
for(const slot of layout.elements.filter(s=>s.kind==='text')){assert.ok(defaultText(layout,slot).length);for(const text of [defaultText(layout,slot),'','新的文字\n第二行','<script>plain text</script>']){const size=textSize(layout,slot,text);assert.ok(Number.isFinite(size)&&size>0)}}
}
});
test('geometric templates retain their distinct silhouettes without bitmap masks',()=>{
assert.equal(imageShape(layouts[4],layouts[4].elements.find(s=>s.id==='I03')).borderRadius,'50%');
assert.ok(imageShape(layouts[21],layouts[21].elements.find(s=>s.id==='I02')).clipPath.startsWith('polygon('));
assert.notEqual(imageShape(layouts[21],layouts[21].elements.find(s=>s.id==='I02')).clipPath,imageShape(layouts[26],layouts[26].elements.find(s=>s.id==='I02')).clipPath);
assert.equal(designUrl('ref-01','abc'),'/studio/template-layout?id=abc');
assert.equal(designUrl('standard-01','abc'),'/studio/template-layout?id=abc');
});
test('element IDs work on HTTP origins without secure-context randomUUID',()=>{const saved=Object.getOwnPropertyDescriptor(globalThis,'crypto');let calls=0;Object.defineProperty(globalThis,'crypto',{configurable:true,value:{getRandomValues(array){for(let i=0;i<array.length;i++)array[i]=++calls;return array}}});try{const first=newElementId(),second=newElementId();assert.match(first,/^user-[a-f0-9]{32}$/);assert.notEqual(first,second)}finally{Object.defineProperty(globalThis,'crypto',saved)}});
-12
View File
@@ -1,12 +0,0 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {registerHooks} from 'node:module';
registerHooks({resolve(specifier,context,next){try{return next(specifier,context)}catch(e){if(e.code==='ERR_MODULE_NOT_FOUND'&&specifier.startsWith('.'))return next(specifier+'.ts',context);throw e}}});
const {series,pageParameters}=await import('../src/lib/series.ts');
const layout=JSON.parse(readFileSync(new URL('../src/content/standard-layout.json',import.meta.url)));
// Fixture copied into the CI image; no build is performed by these tests.
const input=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url)));
test('a complete three-page document preserves copy and resolves shared visual tokens',()=>{const d=series(input.document,[layout]);assert.equal(d.pages.length,3);for(const page of d.pages){const p=pageParameters(d,page,layout);assert.equal(p.paper,d.style.palette.paper);assert.equal(p.texts.T01.fontFamily,'serif');assert.equal(p.texts.T01.value,page.parameters.texts.T01.value);assert.equal(p.layers.T01.x,10)}const change=structuredClone(d);change.style.palette.ink='#112233';change.style.spacing.margin=8;for(const page of change.pages){const p=pageParameters(change,page,layout);assert.equal(p.texts.T02.color,'#112233');assert.equal(p.layers.T01.x,8)}change.pages[1].parameters.texts.T01.color='#ABCDEF';assert.equal(pageParameters(change,change.pages[1],layout).texts.T01.color,'#ABCDEF')});
test('invalid page order, stale template placeholders, untracked images and unsafe fields fail',()=>{for(const mutate of [d=>d.pages.reverse(),d=>d.pages[1].key=d.pages[0].key,d=>delete d.pages[0].parameters.texts.T02,d=>d.pages[0].parameters.images.I01.assetId='f76134e7-6e4f-4261-b90c-3c523dc16307',d=>d.style.palette.ink='url(evil)',d=>d.style.spacing.gap=10,d=>d.pages[0].bindings.I01='title',d=>d.pages[0].parameters.images.I01.url='https://evil.test']){const d=structuredClone(input.document);mutate(d);assert.throws(()=>series(d,[layout]))}});
test('persistence and renderer use byte-identical series contracts',()=>assert.equal(readFileSync(new URL('../src/lib/series.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/series.ts',import.meta.url),'utf8')));
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+13 -17
View File
@@ -1,22 +1,18 @@
# Replace all three placeholders before starting Docker Compose.
POSTGRES_PASSWORD=replace-with-a-long-random-password
S3_ACCESS_KEY=replace-with-a-unique-access-key
S3_SECRET_KEY=replace-with-a-long-random-secret
# Export to the web, storage bootstrap and worker processes.
DATABASE_URL=postgresql://endoleap:replace-me@127.0.0.1:5432/endoleap
S3_ENDPOINT=http://127.0.0.1:9000
S3_ACCESS_KEY=replace-me
S3_SECRET_KEY=replace-me
S3_BUCKET=prompt
WEB_BIND_ADDRESS=0.0.0.0
WEB_PORT=18131
PG_PORT=18132
S3_PORT=18133
PROMPT_IMAGE=prompt:local
RELEASE_SHA=development
# Use HTTPS and secure cookies in production; HTTP acceptance requires explicit false.
PORT=3000
AUTH_CONFIG_FILE=/path/to/private/auth.json
AUTH_COOKIE_SECURE=true
ACCOUNT_PUBLIC_ORIGIN=http://8.211.154.134:18131
# Enable only behind an ingress that overwrites x-forwarded-for.
ACCOUNT_PUBLIC_ORIGIN=https://endoleap.example
ACCOUNT_TRUST_PROXY=false
SMTP_HOST=smtpdm.aliyun.com
# Email verification and recovery
SMTP_HOST=
SMTP_PORT=465
SMTP_USERNAME=noreply@notice.entroia.com
SMTP_FROM=noreply@notice.entroia.com
SMTP_USERNAME=
SMTP_FROM=
SMTP_PASSWORD=
-38
View File
@@ -1,38 +0,0 @@
FROM node:24-bookworm-slim AS base
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates chromium fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
FROM base AS dependencies
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
COPY apps/waterfall-wiki/package.json ./
RUN pnpm install --offline --frozen-lockfile
FROM dependencies AS build
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
COPY apps/waterfall-wiki/ .
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
FROM build AS integration
USER node
CMD ["npm", "run", "test:integration"]
FROM base AS runtime
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
COPY --from=build --chown=node:node /app/.next/standalone/ ./
COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs /app/dist/auth-cli.cjs ./
COPY --from=build --chown=node:node /app/db/ ./db/
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
COPY --chown=node:node apps/waterfall-wiki/src/content/managed-media.json ./src/content/managed-media.json
USER node
EXPOSE 3000
CMD ["node", "server.js"]
@@ -0,0 +1,5 @@
BEGIN;
ALTER TABLE account_tokens DROP CONSTRAINT account_tokens_kind_check;
ALTER TABLE account_tokens ADD CONSTRAINT account_tokens_kind_check CHECK (kind IN ('verify','session','reset'));
INSERT INTO schema_migrations(version) VALUES ('017_password_reset');
COMMIT;
@@ -1,163 +0,0 @@
{
"schemaVersion": 2,
"name": "把秋天装进一页 · 银杏观察手记",
"document": {
"version": 1,
"brief": {
"topic": "银杏与秋日观察手记",
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
},
"style": {
"version": "autumn-journal/1",
"palette": {
"paper": "#F3EFE5",
"ink": "#253C32",
"muted": "#718074",
"accent": "#C3A050"
},
"typography": {
"title": {
"fontFamily": "serif",
"fontSize": 8.4,
"fontWeight": 600,
"lineHeight": 1.15,
"letterSpacing": 0
},
"body": {
"fontFamily": "sans",
"fontSize": 3.1,
"fontWeight": 400,
"lineHeight": 1.65,
"letterSpacing": 0
},
"caption": {
"fontFamily": "sans",
"fontSize": 1.7,
"fontWeight": 500,
"lineHeight": 1.2,
"letterSpacing": 0
}
},
"spacing": {
"margin": 10,
"gap": 4
}
},
"sources": [],
"pages": [
{
"key": "cover",
"role": "cover",
"layoutId": "standard-01",
"intent": "用银杏静物与两行大标题建立主题",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "把秋天\n装进一页"
},
"T02": {
"value": "不必走很远。\n一片银杏,就是今天的风景。"
},
"T03": {
"value": "SEASONAL NOTES / 01"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 50,
"cropY": 50,
"zoom": 1
}
},
"decorations": {}
}
},
{
"key": "observe",
"role": "body",
"layoutId": "standard-01",
"intent": "用三个观察动作提供可实践的方法",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "先看见\n再记录"
},
"T02": {
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
},
"T03": {
"value": "FIELD NOTES / 02"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 40,
"zoom": 1.25
}
},
"decorations": {}
}
},
{
"key": "ending",
"role": "ending",
"layoutId": "standard-01",
"intent": "以轻量行动和提问收尾",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "留一点白\n给下一次",
"align": "middle"
},
"T02": {
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
"align": "middle"
},
"T03": {
"value": "YOUR TURN / 03"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 50,
"zoom": 1
}
},
"decorations": {},
"layers": {
"I01": {
"x": 40,
"y": 55,
"width": 40,
"height": 40,
"radius": 20
}
}
}
}
]
}
}
+6 -5
View File
@@ -1,7 +1,9 @@
import { resolve } from "node:path";
import type { NextConfig } from "next";
import managedMedia from "./src/content/managed-media.json";
const nextConfig: NextConfig = {
turbopack: { root: resolve(process.cwd(), "../..") },
serverExternalPackages: ["playwright-core"],
async redirects(){return [
{source:'/studio/graphic',destination:'/graphic',permanent:true},
@@ -10,9 +12,10 @@ const nextConfig: NextConfig = {
]},
async rewrites(){return [
...Object.entries(managedMedia).filter(([path])=>path.startsWith("apps/waterfall-wiki/public/")).map(([path,asset])=>({source:"/"+path.slice("apps/waterfall-wiki/public/".length),destination:"/api/media/"+asset.id})),
{source:'/graphic',destination:'http://prompt-studio:3000/studio/graphic'},
{source:'/infinite_canvas',destination:'http://prompt-studio:3000/studio/infinite_canvas'},
{source:"/studio/:path*",destination:"http://prompt-studio:3000/studio/:path*"},
{source:"/studio/api/:path*",destination:"/api/studio-ui/:path*"},
{source:"/studio/brand/:path*",destination:"/brand/:path*"},
{source:"/studio/favicon.ico",destination:"/favicon.ico"},
{source:"/studio/apple-touch-icon.png",destination:"/apple-touch-icon.png"},
{source:"/index.md",destination:"/api/agent-index"},
{source:"/skills/index.md",destination:"/api/guidance/skills"},
{source:"/skills/:path+/index.md",destination:"/api/guidance/skills/:path+"},
@@ -20,8 +23,6 @@ const nextConfig: NextConfig = {
{source:"/agent/index.md",destination:"/api/agent-index"},
{source:"/skills/:skill/:doc.md",destination:"/api/skill-docs/:skill/:doc"},
]},
output: "standalone", outputFileTracingRoot: process.cwd(),
outputFileTracingIncludes: {"/*":["./node_modules/playwright-core/**/*"]},
poweredByHeader: false,
};
+5 -7
View File
@@ -6,16 +6,13 @@
"node": ">=24 <25"
},
"scripts": {
"dev": "next dev",
"build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs && node scripts/prepare-standalone.mjs",
"dev": "pnpm brand:prepare && next dev",
"build": "pnpm brand:prepare && next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs",
"start": "next start",
"typecheck": "next typegen && tsc --noEmit",
"test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts tests/account.test.ts tests/guidance.test.ts tests/guidance-publishing.test.ts",
"test:smoke": "node scripts/smoke.mjs",
"storage:prepare": "tsx scripts/bootstrap.ts",
"content:publish-learning": "tsx scripts/publish-learning-series.ts",
"worker": "tsx src/worker.ts",
"test:integration": "tsx --test tests/integration.test.ts"
"brand:prepare": "node ../../packages/brand/scripts/prepare-app.mjs"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1155.0",
@@ -32,7 +29,8 @@
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1",
"sharp": "0.35.5",
"zod": "4.6.7"
"zod": "4.6.7",
"@endoleap/brand": "workspace:*"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.3",
-3177
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -48,6 +48,7 @@ async function main() {
const request = (path:string,init:RequestInit={}) => fetch(base+path,{...init,redirect:'manual'});
const buildId = readFileSync('.next/BUILD_ID','utf8').trim();
for(const path of ['/','/workspace','/documents','/account'])assert.equal((await request(path)).status,200,'Public shell '+path);
for(const path of ['/favicon.ico','/brand/logo-light.svg','/brand/logo-dark.svg','/brand/icon-dark.svg','/apple-touch-icon.png']) assert.equal((await request(path)).status,200,'Public brand asset '+path);
const studio=await request('/studio');assert.equal(studio.status,307);assert.equal(studio.headers.get('location'),'/workspace');
for(const [from,to] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){const r=await request(from);assert.equal(r.status,308);assert.equal(r.headers.get('location'),to)}
assert.equal((await request('/account')).status,200,'Public account page');
@@ -1,9 +0,0 @@
try {
const port = process.env.PORT || '3000';
const [health, ready] = await Promise.all([
fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(5000) }),
fetch(`http://127.0.0.1:${port}/api/ready`, { signal: AbortSignal.timeout(5000) }),
]);
const data = await health.json();
if (!health.ok || !ready.ok || data.service !== 'prompt' || data.release !== (process.env.RELEASE_SHA || 'development')) process.exit(1);
} catch { process.exit(1); }
@@ -1,38 +0,0 @@
import {readFile} from 'node:fs/promises';
import {fileURLToPath} from 'node:url';
import {join,dirname} from 'node:path';
import {createHash} from 'node:crypto';
import {requiredPackImages} from './verify-character-pack.mjs';
const root=join(dirname(fileURLToPath(import.meta.url)),'..','seed','characters');
const base=process.env.PROMPT_IMPORT_BASE_URL||'http://127.0.0.1:3000';
const media=JSON.parse(await readFile(join(root,'..','..','src','content','managed-media.json'),'utf8'));
const packFiles=pack=>Object.entries(media).filter(([name])=>name.startsWith(`apps/waterfall-wiki/seed/characters/${pack.directory}/`)).sort(([a],[b])=>a.localeCompare(b));
const packs=JSON.parse(await readFile(join(root,'packs.json'),'utf8'));
async function verify(pack){
let cursor='',post;const seen=new Set();
do{const response=await fetch(base+'/api/feed?'+new URLSearchParams({section:'images',...(cursor?{cursor}:{})}));if(!response.ok)throw Error('Cannot verify character feed: '+response.status);const page=await response.json();post=page.posts.find(p=>p.slug===pack.slug);cursor=page.nextCursor||'';if(cursor&&seen.has(cursor))throw Error('Character feed cursor did not advance');seen.add(cursor);}while(!post&&cursor);
if(!post)throw Error('Character pack missing: '+pack.slug);
const files=packFiles(pack);if(files.length!==pack.count)throw Error('Seed file count mismatch: '+pack.slug);
const hashes=files.map(([,asset])=>asset.sha256);
const originals=requiredPackImages(hashes,post.assets.filter(a=>a.kind==='image'));
for(const image of originals){const media=await fetch(`${base}/api/media/${image.id}`,{method:'HEAD'});if(!media.ok||media.headers.get('content-type')!=='image/png')throw Error('Original PNG unavailable');}
console.log(`Verified: /p/${pack.slug} (${originals.length} original PNGs preserved; editorial covers allowed)`);
}
for(const pack of packs){
const existing=await fetch(`${base}/p/${pack.slug}`);
if(existing.ok){console.log(`Already imported: /p/${pack.slug}`);await verify(pack);continue;}
if(existing.status!==404)throw new Error(`Cannot check pack ${pack.slug}: ${existing.status}`);
const files=packFiles(pack);
if(files.length!==pack.count)throw new Error(`Expected ${pack.count} PNGs for ${pack.slug}, got ${files.length}`);
const data=new FormData();data.set('title',pack.title);data.set('slug',pack.slug);data.set('description',pack.description);
for(const [name,asset] of files){
const response=await fetch(`${base}/api/media/${asset.id}`);if(!response.ok)throw Error('Cannot read migrated original: '+name);
const bytes=Buffer.from(await response.arrayBuffer());if(bytes.length!==asset.bytes||createHash('sha256').update(bytes).digest('hex')!==asset.sha256)throw Error('Original checksum mismatch: '+name);
data.append('files',new Blob([bytes],{type:'image/png'}),name.split('/').pop());
}
const response=await fetch(`${base}/api/skills/street-people`,{method:'POST',body:data});
if(response.status!==201&&response.status!==409)throw new Error(`Import ${pack.slug}: ${response.status} ${await response.text()}`);
if(response.status===409&&!(await fetch(`${base}/p/${pack.slug}`)).ok)throw new Error(`Conflicting import ${pack.slug}`);
console.log(`Imported: /p/${pack.slug} (${files.length} transparent PNGs)`);
await verify(pack);
}
@@ -1,4 +0,0 @@
import { cp,mkdir,access } from 'node:fs/promises';
await mkdir('.next/standalone/.next',{recursive:true});
await cp('.next/static','.next/standalone/.next/static',{recursive:true});
try{await access('public');await cp('public','.next/standalone/public',{recursive:true});}catch(e){if(e.code!=='ENOENT')throw e;}
@@ -1,5 +0,0 @@
import { db } from "../src/lib/db";
import { publishLearningSeries } from "../src/lib/publish-learning-series";
publishLearningSeries().then(result=>console.log(`Published learning series: ${result.published.join(", ")}; one shared cover: ${result.coverId}`))
.catch(error=>{console.error("Learning series publication failed:",error instanceof Error?error.message:"Unknown error");process.exitCode=1;})
.finally(()=>db().end());
-65
View File
@@ -1,65 +0,0 @@
import {writeFile,unlink} from 'node:fs/promises';
import sharp from 'sharp';
import { spawn } from "node:child_process";
import assert from "node:assert/strict";
import { setTimeout } from "node:timers/promises";
import { createServer } from "node:net";
import { once } from "node:events";
async function availablePort() {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const port = server.address().port;
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
return String(port);
}
const port = process.env.SMOKE_PORT || await availablePort();
const release = "smoke-test";
const previewFixture='.next/standalone/public/__smoke-preview.png';
await writeFile(previewFixture,await sharp({create:{width:48,height:48,channels:3,background:'#eee'}}).png().toBuffer());
const child = spawn(process.execPath, [".next/standalone/server.js"], {
// This loopback-only child checks retained resources without production credentials.
// Production keeps failing closed; the bypass requires both explicit test settings.
env: { ...process.env, AUTH_CONFIG_FILE: "", AUTH_DISABLED_FOR_TESTS: "true", PORT: port, HOSTNAME: "127.0.0.1", RELEASE_SHA: release },
stdio: "inherit",
});
try {
let ready = false;
for (let i = 0; i < 40; i++) {
if (child.exitCode !== null) throw new Error(`Server exited: ${child.exitCode}`);
try {
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { status: "ok", service: "prompt", release });
ready = true;
break;
} catch { await setTimeout(500); }
}
assert.ok(ready, "Standalone production server must be healthy");
const response = await fetch(`http://127.0.0.1:${port}/api/health`, { method: "POST" });
assert.equal(response.status, 405);
const indexResponse=await fetch(`http://127.0.0.1:${port}/api/wiki-index`);
assert.equal(indexResponse.status,200);
const indexText=await indexResponse.text();
const id=indexText.match(/\/explore\/([a-f0-9]{24})/)?.[1];assert.ok(id);
const agent=await fetch(`http://127.0.0.1:${port}/explore/${id}?agent_mode=True`);
assert.equal(agent.status,200);assert.match(agent.headers.get('content-type'),/text\/plain/);const agentText=await agent.text();assert.match(agentText,/^# /);assert.doesNotMatch(agentText,/^\s*(?:<!doctype html|<html)/i);
const preview=await fetch(`http://127.0.0.1:${port}/api/preview?source=${encodeURIComponent('/__smoke-preview.png')}&width=480`);
assert.equal(preview.status,200);assert.match(preview.headers.get('content-type'),/image\/webp/);assert.ok((await preview.arrayBuffer()).byteLength<=100000);
const home=await fetch(`http://127.0.0.1:${port}/`).then(r=>r.text());
for(const href of ['/graphic','/infinite_canvas','/account?next=/workspace'])assert.ok(home.includes(`href="${href}"`),href);
for(const [legacy,canonical] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){
const redirect=await fetch(`http://127.0.0.1:${port}${legacy}`,{redirect:'manual'});
assert.equal(redirect.status,308);assert.equal(redirect.headers.get('location'),canonical);
}
console.log("Production smoke test passed, including landing entry points and canonical redirects");
} finally {
await unlink(previewFixture);
if (child.exitCode === null && child.signalCode === null) {
const exited = once(child, "exit");
child.kill("SIGTERM");
await exited;
}
}
@@ -1,6 +0,0 @@
/** Extra editorial covers are allowed; every original seed image must remain attached. */
export function requiredPackImages(hashes,images){
const remaining=[...images],originals=[];
for(const hash of hashes){const index=remaining.findIndex(image=>image.sha256===hash);if(index<0)throw Error('Original seed image missing from character pack');originals.push(...remaining.splice(index,1));}
return originals;
}
@@ -0,0 +1,4 @@
/** The approved mark is copied from @endoleap/brand during the remote build. */
export function Brand({ compact = false }: { compact?: boolean }) {
return <span className="endoleap-brand"><img src="/brand/logo-light.svg" className="endoleap-mark endoleap-mark-light" alt="" width={32} height={32}/><img src="/brand/logo-dark.svg" className="endoleap-mark endoleap-mark-dark" alt="" width={32} height={32}/><span className="endoleap-wordmark">啡跃{!compact && <small>Endoleap</small>}</span></span>;
}
@@ -35,7 +35,7 @@ export function ExploreDetail({entry:initial,background}:{entry:ExploreEntry;bac
async function archive(){
setArchiveBusy(true);setCopyError('');try{const r=await fetch('/api/posts/'+entry.id+'/archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({archived:!entry.archived})});const d=await r.json();if(!r.ok)throw Error(d.error||'操作失败');const state=await fetch('/api/notes/'+entry.id).then(r=>r.json());setEntry(state.entry);void router.replace(router.asPath,undefined,{scroll:false})}catch(e){setCopyError(e instanceof Error?e.message:'操作失败')}finally{setArchiveBusy(false)}
}
return <Shell><Head><title>{entry.title} · PROMPT</title><link rel="canonical" href={exploreUrl(entry.id)}/><link rel="alternate" type="text/plain" href={exploreUrl(entry.id)+'?agent_mode=True'}/></Head>
return <Shell><Head><title>{entry.title} · 啡跃 Endoleap</title><link rel="canonical" href={exploreUrl(entry.id)}/><link rel="alternate" type="text/plain" href={exploreUrl(entry.id)+'?agent_mode=True'}/></Head>
<div aria-hidden="true" inert><ExploreFeed key={entry.revision} initial={background.map(e=>e.id===entry.id?entry:e)}/></div>
<div className="note-overlay" onClick={()=>{if(!editing)close()}}><div className="note-dialog" ref={detailRef} role="dialog" aria-modal="true" aria-labelledby="note-title" onClick={e=>e.stopPropagation()}>
<button className="note-close" ref={closeRef} aria-label={editing?'取消编辑':'关闭详情'} onClick={close}><X/></button>
+2 -2
View File
@@ -31,12 +31,12 @@ export function Feed({initial,section='',tagPath=''}:{initial:FeedData;section?:
useEffect(()=>{generation.current++;setPosts(initial.posts);setCursor(initial.nextCursor);setError('');setBusy(false);},[initial,router.query.q]);
const current=sections.find(s=>s.path===section);const target=initial.tags.find(t=>t.path===tagPath);const children=initial.tags;
async function load(){const gen=generation.current;setBusy(true);setError('');try{const q=new URLSearchParams({section,path:tagPath,cursor:cursor||'',q:String(router.query.q||'')});const res=await fetch('/api/feed?'+q);const d=await res.json();if(!res.ok)throw new Error(d.error);if(gen===generation.current){setPosts(old=>[...old,...d.posts.filter((p:Post)=>!old.some(a=>a.id===p.id))]);setCursor(d.nextCursor);}}catch{if(gen===generation.current)setError('加载没有完成,点击重试。');}finally{if(gen===generation.current)setBusy(false);}}
return <Shell><Head><title>{target?.name||current?.name||'灵感首页'} · PROMPT</title><meta name="description" content="自己的图文、视频与声音创作空间"/><link rel="alternate" type="text/markdown" href="/index.md" title="Agent 文档入口"/></Head>
return <Shell><Head><title>{target?.name||current?.name||'灵感首页'} · 啡跃 Endoleap</title><meta name="description" content="自己的图文、视频与声音创作空间"/><link rel="alternate" type="text/markdown" href="/index.md" title="Agent 文档入口"/></Head>
{current&&<header className="page-header feed-heading"><div><h1>{target?.name||current.name}</h1><p>{current.hint}</p></div></header>}
{tagPath&&<div className="breadcrumbs"><Link href={`/${section}`}>全部{current?.name}</Link>{tagPath.split('/').map((_,i)=>{const path=tagPath.split('/').slice(0,i+1).join('/');return <span key={path}> / <Link href={`/${section}/${path}`}>{initial.tags.find(t=>t.path===path)?.name}</Link></span>;})}</div>}
<div className="feed-toolbar"><div className="feed-tabs">{!section?<><Link className="selected" href="/">全部</Link>{sections.map(s=><Link key={s.path} href={`/${s.path}`}>{s.name}</Link>)}</>:<><Link className={!tagPath?'selected':''} href={`/${section}`}>全部</Link>{children.map(t=><Link key={t.id} href={`/${section}/${t.path}`}>{t.name}</Link>)}</>}</div><span className="sort-label"><ArrowDown size={13}/>最近收录</span></div>
{initial.unavailable?<div className="empty-state"><Layers/><h2>素材库暂时没有连接上</h2><p>请稍后刷新,已保存的内容不会因此丢失。</p></div>:(posts.length||(!section&&skillCatalog.some(s=>(s.title+s.description+s.category).toLowerCase().includes(String(router.query.q||'').toLowerCase()))))?<Masonry posts={posts} includeSkills={!section} q={String(router.query.q||'')}/>:<div className="empty-state"><div className="empty-art"><span/><ImageIcon size={42} strokeWidth={1.2}/><i><AudioLines size={18}/></i><b><Film size={19}/></b></div><span className="eyebrow">A LITTLE SPACE FOR SOMETHING GOOD</span><h2>{router.query.q?'还没有找到这份灵感':'这里还没有内容'}</h2><p>{router.query.q?'换一个词试试看。':'导入图片、视频或声音,开始整理你的素材。'}</p><Link className="primary-button" href={`/library/import${current&&['image','video','audio'].includes(current.kind)?'?kind='+current.kind:''}`}><Upload size={16}/>导入素材<ArrowUpRight size={16}/></Link><small>图文和动态作品通过代码加入,自由决定每一个像素。</small></div>}
{cursor&&<div className="load-more"><button disabled={busy} onClick={load}>{busy?'正在翻找…':error||'再看一些'}<ArrowDown size={14}/></button></div>}
<footer className="page-footer">PROMPT <span>·</span> PROBABLY RENDERING ONE MORE PIXEL TONIGHT</footer>
<footer className="page-footer">啡跃 <span>·</span> Endoleap</footer>
</Shell>;
}
@@ -4,7 +4,7 @@ import {Shell} from './Shell';
import {SkillCard} from './SkillCard';
import {skillCatalog} from '@/content/skills/catalog';
import {guidanceDocuments,humanDocumentUrl} from '@/content/skills/guidance';
export function GuidanceIndex(){return <Shell><Head><title>指导文档 · PROMPT</title><link rel="canonical" href="/skills"/><link rel="alternate" type="text/markdown" href="/skills/index.md" title="指导文档"/></Head>
export function GuidanceIndex(){return <Shell><Head><title>指导文档 · 啡跃 Endoleap</title><link rel="canonical" href="/skills"/><link rel="alternate" type="text/markdown" href="/skills/index.md" title="指导文档"/></Head>
<header className="page-header"><div><h1>指导文档</h1><p>每篇都有独立地址,网页与 Markdown 一一对应。沿着引用,找到需要的方法。</p></div></header>
<article className="docs-body"><h2>全部文档</h2><p>这些分组只帮助浏览。任何一篇都可以独立进入,也可以引用其他文档。</p><div className="guidance-directory">{guidanceDocuments.map(doc=><div key={doc.path}><Link href={humanDocumentUrl(doc.path)}>{doc.title}</Link><small>{doc.collection}</small><a href={`${humanDocumentUrl(doc.path)}/index.md`}>Markdown ↗</a></div>)}</div></article>
<h2>按创作主题浏览</h2>
@@ -333,7 +333,7 @@ export default function GuidanceReader({
<dt>来源</dt>
<dd>
{source === "example"
? "织页协议样例"
? "啡跃协议样例"
: canEdit
? "你的文档"
: "社区作者"}
@@ -1,3 +1,6 @@
import a from "@/styles/account.module.css";
import { ThemeToggle } from "./ThemeToggle";
import { Brand } from "./Brand";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -46,9 +49,11 @@ export const useGuidanceAccount = () => useContext(AccountContext);
export default function GuidanceShell({
children,
title = "探索文档",
standalone = false,
}: {
children: ReactNode;
title?: string;
standalone?: boolean;
}) {
const router = useRouter();
const dialog = useRef<HTMLDialogElement>(null);
@@ -110,12 +115,7 @@ export default function GuidanceShell({
href="/"
onClick={() => dialog.current?.close()}
>
<span className={s.brandMark}>
<Waypoints size={22} strokeWidth={2.2} />
</span>
<span>
织页<small>KNOWLEDGE FOR AGENTS</small>
</span>
<Brand/>
</Link>
<div className={s.workspaceLabel}>
<Globe2 size={16} />
@@ -190,11 +190,23 @@ export default function GuidanceShell({
</>
);
}
if (standalone) return (
<AccountContext.Provider value={{ user, loading, error, refresh }}>
<div className={a.page}>
<Head><title>{`${title} · 啡跃`}</title></Head>
<a className={s.skipLink} href="#account-content">跳到正文</a>
<header className={a.header}>
<div><Link href="/">返回首页</Link><ThemeToggle /></div>
</header>
<main id="account-content">{children}</main>
</div>
</AccountContext.Provider>
);
return (
<AccountContext.Provider value={{ user, loading, error, refresh }}>
<div className={s.site}>
<Head>
<title>{`${title} · 织页`}</title>
<title>{`${title} · 啡跃`}</title>
<meta
name="description"
content="为 Agent 编写、发现和连接可独立使用的指导文档。"
@@ -230,7 +242,7 @@ export default function GuidanceShell({
<ChevronRight size={14} />
<strong>{title}</strong>
</div>
<div className={s.topbarActions}>
<div className={s.topbarActions}><ThemeToggle/>
<Link className={s.topbarProtocol} href="/protocol">
<span className={s.statusDot} />
Linked Guidance 0.1
@@ -253,7 +265,7 @@ export default function GuidanceShell({
{children}
</main>
<footer className={s.workspaceFooter}>
<span>织页 · 让知识按需连接</span>
<span>啡跃 · 让知识按需连接</span>
<Link href="/protocol">
文档协议 <ArrowUpRight size={12} />
</Link>
@@ -0,0 +1,25 @@
type Pose = "idea" | "read" | "make" | "collect" | "dream";
/** Original theme-aware line drawings, independent of the fixed brand mark. */
export function CreatorSketch({ pose }: { pose: Pose }) {
return <svg viewBox="0 0 200 220" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M13 17 185 14l3 176-174 3ZM17 202q42-3 86 0m-85 8 62 1" strokeWidth="1.5" />
<path d="M42 188q3-46 31-60l42 1q28 16 42 59" fill="var(--selection)" />
<path d="M78 64q-20-3-15-20 5-15 21-11 11-15 29-6 20 7 11 31l-18-10-15 18Z" fill="currentColor" />
<path d="M79 63q-11 5-5 17l8 2q1 27 21 29 21-4 21-39l-3-15m-34 52-5 20m24-18 7 18m-17-58-2 13 8 2m-9 13q9 5 17-3m-23-23h1m24-1h1" />
{pose === "idea" && <><path d="m117 135 17-38 13-48 11 3-7 53-14 49M68 143l-5 27 37 15" fill="var(--background)" /><path d="m141 70-8-31 26-9 11 29Z" fill="var(--selection)" /><path d="m142 42 14-5m-11 14 13-5m-10 13 12-5M151 22V12m16 14 8-7m-41 12-9-4" /></>}
{pose === "read" && <><path d="m49 132 49 16 46-20-4 58-41 15-46-15Z" fill="var(--background)" /><path d="m98 148 1 53m-35-54 23 8m-22 3 20 7m-19 3 16 6m28-16 20-8m-20 19 18-7m-18 17 15-6" /><path d="m50 162 12-5q8 1 5 8l-14 13m89-17-12-4q-9 0-6 7l15 12" fill="var(--background)" /></>}
{pose === "make" && <><path d="m99 143 65-7 15 51-68 5Z" fill="var(--background)" /><path d="m101 150-22 3-8 19 32 5m-39-6 16 7 29-2m21-12h13m-28 29H37" /><path d="m151 46 5-14 8 12 15 2-11 9 1 15-13-8-13 5 4-13-10-10Z" fill="var(--selection)" /></>}
{pose === "collect" && <><path d="m60 156 15-18 53 7-5 40-62-5Z" fill="var(--background)" /><path d="m76 138 8-11 19 3 7 13m-12 9a12 12 0 1 0 1 24 12 12 0 0 0-1-24m-36 10-11-5-8 8 15 15m64-21 15 9-13 11" /><path d="m143 68 25-7 7 31-25 7Zm7 14 6-6 10 9m-8-14h1" stroke="var(--primary)" /></>}
{pose === "dream" && <><path d="m82 136 16 30 26-6 12-36m-18 38 18 11 11-35-7-20q-4-7-8-3l-2 14-6-7m-46 26-9 25 28 14 37-13" fill="var(--background)" /><path d="m142 32 29-10-13 25-4-12-12-3 12 3 17-13m-36 25q-21 4-15 17t25 2" stroke="var(--primary)" /></>}
</svg>;
}
export function IdeaSketch({ kind }: { kind: "collect" | "connect" | "read" | "make" }) {
return <svg viewBox="0 0 140 110" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{kind === "collect" && <><path d="m43 25 49-5 7 64-53 6Z" fill="var(--selection)" /><path d="m54 39 25-2m-24 14 30-3m-28 14 21-2m20-23 18-20 7 7-20 21-9 3ZM34 18l-6-8m19 3-1-10m61 62 8 3m-81 2-10 2" /></>}
{kind === "connect" && <><path d="M42 37q27-31 50-1m-48 8q27-17 47-1M45 62q23 28 46 2" /><path d="m18 32 29-3 3 34-31 2Zm72-8 32 4-3 33-30-3ZM52 73l32-1 2 25-32 2Z" fill="var(--selection)" /><path d="m26 43 14-1m-13 9 10-1m62-13 13 2m-13 7 11 1M62 83h13m-12 7h9" /></>}
{kind === "read" && <><path d="M20 30q25-8 49 7 24-17 51-10l-3 56q-24-5-48 10-22-13-47-7Z" fill="var(--selection)" /><path d="m69 37 0 56M32 42q13-1 25 5m-23 8 21 5m-21 8 20 4m27-27 24-6m-23 19 21-6m-20 18 18-4m-35-44-2-11m17 10 6-10" /></>}
{kind === "make" && <><path d="m24 29 85-3 5 63-86 4ZM31 41h71M45 34h1m-10 0h1m20-1h1m-18 21 21-2 1 26-22 1Zm31 0 27-1m-26 12 20-1m-19 13 24-1" /><path d="m103 14 7-11 4 12 13 2-10 8 2 11-10-6-10 7 2-12-10-7Z" fill="var(--selection)" /></>}
</svg>;
}
+4 -2
View File
@@ -1,3 +1,5 @@
import { ThemeToggle } from "./ThemeToggle";
import { Brand } from "./Brand";
import Link from "next/link";
import { useRouter } from "next/router";
import { Grid2X2,Image,Film,AudioLines,BookOpen,Clapperboard,Sparkles,ArrowUpRight,Layers,Tags,Upload,Menu,X,Search } from "lucide-react";
@@ -7,9 +9,9 @@ export function Shell({children}:{children:ReactNode}){
const router=useRouter();const [open,setOpen]=useState(false);const [query,setQuery]=useState(String(router.query.q||''));
useEffect(()=>setQuery(String(router.query.q||'')),[router.query.q]);
return <div className="app-shell"><button className="mobile-menu icon-button" aria-label={open?"关闭导航":"展开导航"} aria-expanded={open} onClick={()=>setOpen(!open)}>{open?<X size={22}/>:<Menu size={22}/>}</button>
<aside className={`sidebar ${open?'is-open':''}`}><Link href="/" className="brand" aria-label="PROMPT 首页" onClick={()=>setOpen(false)}><img className="brand-mark" src="/brand-mark.svg" width={40} height={40} alt=""/><strong>PROMPT</strong></Link>
<aside className={`sidebar ${open?'is-open':''}`}><Link href="/" className="brand" aria-label="啡跃 Endoleap 首页" onClick={()=>setOpen(false)}><Brand/></Link>
<nav>{groups.map((g,i)=><div className="nav-group" key={i}>{g.label&&<p>{g.label}</p>}{g.items.map(item=>{const active=item.href==='/'?router.asPath.split('?')[0]==='/':router.asPath.split('?')[0].startsWith(item.href);return <Link key={item.href} href={item.href} title={item.label} className={`nav-item ${active?'active':''}`} onClick={()=>setOpen(false)}><item.icon size={24} strokeWidth={active?2.2:1.8}/><span>{item.label}</span></Link>;})}</div>)}</nav>
<button className="auth-logout" onClick={async()=>{const response=await fetch('/api/auth/logout',{method:'POST'});if(response.ok)window.location.assign('/login');}}>退出登录</button></aside><main className="main-content"><div className="global-topbar"><form className="global-search" role="search" onSubmit={e=>{e.preventDefault();const path=router.asPath.split('?')[0];const destination=path==='/categories'?path:'/';void router.push({pathname:destination,query:{...(destination==='/categories'&&router.query.category?{category:router.query.category}:{}),...(query.trim()?{q:query.trim()}:{})}});}}><Search size={24}/><input aria-label="搜索笔记全文" placeholder="搜索知识、灵感和素材" value={query} onChange={e=>setQuery(e.target.value)}/>{query&&<button type="button" aria-label="清空搜索" onClick={()=>{setQuery('');void router.push({pathname:router.pathname==='/categories'?'/categories':'/',query:router.query.category?{category:router.query.category}:{}})}}><X size={20}/></button>}<button type="submit" className="search-submit">搜索</button></form></div>{children}</main>{open&&<button className="nav-scrim" aria-label="关闭导航" onClick={()=>setOpen(false)}/>}
<button className="auth-logout" onClick={async()=>{const response=await fetch('/api/auth/logout',{method:'POST'});if(response.ok)window.location.assign('/login');}}>退出登录</button></aside><main className="main-content"><div className="global-topbar"><ThemeToggle/><form className="global-search" role="search" onSubmit={e=>{e.preventDefault();const path=router.asPath.split('?')[0];const destination=path==='/categories'?path:'/';void router.push({pathname:destination,query:{...(destination==='/categories'&&router.query.category?{category:router.query.category}:{}),...(query.trim()?{q:query.trim()}:{})}});}}><Search size={24}/><input aria-label="搜索笔记全文" placeholder="搜索知识、灵感和素材" value={query} onChange={e=>setQuery(e.target.value)}/>{query&&<button type="button" aria-label="清空搜索" onClick={()=>{setQuery('');void router.push({pathname:router.pathname==='/categories'?'/categories':'/',query:router.query.category?{category:router.query.category}:{}})}}><X size={20}/></button>}<button type="submit" className="search-submit">搜索</button></form></div>{children}</main>{open&&<button className="nav-scrim" aria-label="关闭导航" onClick={()=>setOpen(false)}/>}
</div>;
}
@@ -26,7 +26,7 @@ export function SkillDocs({documentPath,documentId='index',skillId='street-peopl
setCopied(true);
}catch{setCopied(false)}
}
return <Shell><Head><title>{doc.title} · 指导文档 · PROMPT</title><link rel="canonical" href={humanDocumentUrl(path)}/><link rel="alternate" type="text/markdown" href={markdownDocumentUrl(path)} title={doc.title}/></Head>
return <Shell><Head><title>{doc.title} · 指导文档 · 啡跃 Endoleap</title><link rel="canonical" href={humanDocumentUrl(path)}/><link rel="alternate" type="text/markdown" href={markdownDocumentUrl(path)} title={doc.title}/></Head>
<div className="docs-top"><Link href="/skills">← 全部指导文档</Link><button onClick={()=>setOpen(v=>!v)} aria-expanded={open}><PanelRight size={16}/>文档与来源 · {doc.resources.length}</button></div>
<div className={`docs-layout ${open?'with-sources':''}`}><article className="docs-body">
<div className="docs-eyebrow">{doc.collection} / 指导文档</div><h1>{doc.title}</h1>
@@ -0,0 +1,7 @@
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const [dark, setDark] = useState(false);
const [ready, setReady] = useState(false);
useEffect(() => { const sync = () => setDark(document.documentElement.dataset.mode === 'dark'); sync(); setReady(true); window.addEventListener('endoleap-theme', sync); return () => window.removeEventListener('endoleap-theme', sync); }, []);
return <button type="button" data-theme-ready={ready} className="endoleap-theme-toggle" aria-label="切换明暗主题" title={dark ? '切换浅色模式' : '切换深色模式'} onClick={() => { const mode = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark'; const scope = document.documentElement.dataset.theme?.startsWith('canvas-') ? 'canvas' : 'home'; document.documentElement.dataset.mode = mode; document.documentElement.dataset.theme = `${scope}-${mode}`; try { localStorage.setItem('endoleap.theme', mode); } catch {} window.dispatchEvent(new Event('endoleap-theme')); }}><span aria-hidden="true">◐</span></button>;
}
+15 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { normalizeEmail,validPassword, trustedOrigin,accountCookie } from './account-crypto';
import { rateLimit,registerAccount,resendVerification,verifyAccount,loginAccount,currentAccount,logoutAccount } from './account';
import { requestPasswordReset,resetPassword,rateLimit,registerAccount,resendVerification,verifyAccount,loginAccount,currentAccount,logoutAccount } from './account';
export const runtime = 'nodejs';
const headers = {'Cache-Control':'private, no-store','Referrer-Policy':'no-referrer'};
const json = (body: object,status=200) => NextResponse.json(body,{status,headers});
@@ -12,7 +12,7 @@ export async function GET(request: Request,context: Context) {
export async function POST(request: Request,context: Context) {
if (!trustedOrigin(request)) return json({error:'请求来源不受信任'},403);
const {action} = await context.params;
if (!['register','resend','verify','login','logout'].includes(action)) return json({error:'不存在'},404);
if (!['register','resend','verify','login','logout','forgot','reset'].includes(action)) return json({error:'不存在'},404);
try {
// Revocation remains available even when login or mail limits are exhausted.
if (action === 'logout') { await logoutAccount(request); const r=json({ok:true});r.headers.set('Set-Cookie',accountCookie('',true));return r; }
@@ -27,6 +27,19 @@ export async function POST(request: Request,context: Context) {
if(typeof body.token!=='string'||! /^[a-f0-9]{64}$/.test(body.token))return json({error:'验证链接无效或已过期'},400);
return await verifyAccount(body.token) ? json({ok:true,message:'邮箱已验证,请登录'}) : json({error:'验证链接无效或已过期'},400);
}
if(action==='reset') {
if(typeof body.token!=='string'||!/^[a-f0-9]{64}$/.test(body.token)||!validPassword(body.password))return json({error:'重设链接无效,或密码不符合要求'},400);
if(!await rateLimit('reset:'+body.token,5))return json({error:'请求过于频繁,请稍后再试'},429);
if(!await resetPassword(body.token,body.password))return json({error:'重设链接无效或已过期,请重新申请'},400);
const r=json({ok:true,message:'密码已更新,请使用新密码登录'});r.headers.set('Set-Cookie',accountCookie('',true));return r;
}
if(action==='forgot') {
const email=normalizeEmail(body.email);
if(!email)return json({error:'请输入有效邮箱'},400);
if(!await rateLimit('forgot:'+email,3))return json({error:'请求过于频繁,请 15 分钟后再试'},429);
await requestPasswordReset(email);
return json({ok:true,message:'若该邮箱对应已验证账户,重设链接将发送至邮箱,有效期 30 分钟。'});
}
const email=normalizeEmail(body.email);
if(!email||!validPassword(body.password))return json({error:'请输入有效邮箱及 12–256 字节密码'},400);
if(!await rateLimit(action+':'+email,action==='login'?10:3))return json({error:'请求过于频繁,请 15 分钟后再试'},429);
+32 -6
View File
@@ -1,20 +1,44 @@
import { randomUUID } from 'node:crypto';
import nodemailer from 'nodemailer';
import { db, transaction } from './db';
import { ACCOUNT_COOKIE, SESSION_TTL, newToken, tokenDigest, hashPassword, checkPassword } from './account-crypto';
import { ACCOUNT_COOKIE, SESSION_TTL, newToken, tokenDigest, hashPassword, checkPassword, validPassword } from './account-crypto';
export async function rateLimit(key: string, limit: number) {
const result = await db().query(`INSERT INTO account_limits(key,attempts,window_start) VALUES ($1,1,now())
ON CONFLICT(key) DO UPDATE SET attempts=CASE WHEN account_limits.window_start < now()-interval '15 minutes' THEN 1 ELSE account_limits.attempts+1 END,
window_start=CASE WHEN account_limits.window_start < now()-interval '15 minutes' THEN now() ELSE account_limits.window_start END RETURNING attempts`,[tokenDigest(key)]);
return result.rows[0].attempts <= limit;
}
export async function sendVerification(email: string, token: string) {
async function sendAccountMail(email: string, token: string, kind: 'verify' | 'reset') {
const origin = process.env.ACCOUNT_PUBLIC_ORIGIN;
if (!origin || !process.env.SMTP_PASSWORD || !process.env.SMTP_USERNAME) throw Error('SMTP unavailable');
const url = new URL('/account',origin); url.searchParams.set('verify',token);
const url = new URL('/account',origin); url.searchParams.set(kind,token);
const smtp = nodemailer.createTransport({host:process.env.SMTP_HOST || 'smtpdm.aliyun.com',port:Number(process.env.SMTP_PORT || 465),secure:true,
auth:{user:process.env.SMTP_USERNAME,pass:process.env.SMTP_PASSWORD},connectionTimeout:10000,socketTimeout:15000});
await smtp.sendMail({from:process.env.SMTP_FROM || process.env.SMTP_USERNAME,to:email,subject:'验证你的知识站邮箱',text:`请在 30 分钟内打开链接并点击确认验证邮箱:\n${url.href}\n如果你没有注册,请忽略。`});
await smtp.sendMail({from:process.env.SMTP_FROM || process.env.SMTP_USERNAME,to:email,subject:kind==='reset'?'重设你的啡跃账户密码':'验证你的知识站邮箱',text:kind==='reset'?`请在 30 分钟内打开链接,由你设置新的密码:\n${url.href}\n如果你没有请求重设密码,请忽略;你的密码不会改变。`:`请在 30 分钟内打开链接并点击确认验证邮箱:\n${url.href}\n如果你没有注册,请忽略。`});
}
export async function sendVerification(email: string,token: string) { await sendAccountMail(email,token,'verify'); }
export async function requestPasswordReset(email: string) {
const result=await db().query('SELECT id FROM account_users WHERE email=$1 AND verified_at IS NOT NULL',[email]);
if(!result.rowCount)return;
const token=newToken(),digest=tokenDigest(token);
await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES ($1,$2,'reset',now()+interval '30 minutes')",[digest,result.rows[0].id]);
try { await sendAccountMail(email,token,'reset'); } catch { await db().query('DELETE FROM account_tokens WHERE digest=$1',[digest]);throw Error('SMTP unavailable'); }
}
export async function resetPassword(token: string,password: string) {
if(!/^[a-f0-9]{64}$/.test(token)||!validPassword(password))return false;
const hash=await hashPassword(password),digest=tokenDigest(token);
return transaction(async client=>{
const lookup=await client.query("SELECT user_id FROM account_tokens WHERE digest=$1 AND kind='reset' AND expires_at>now()",[digest]);
if(!lookup.rowCount)return false;
const id=lookup.rows[0].user_id;
// Serialize all reset links for one account, then recheck after acquiring the lock.
await client.query('SELECT id FROM account_users WHERE id=$1 FOR UPDATE',[id]);
const consumed=await client.query("DELETE FROM account_tokens WHERE digest=$1 AND kind='reset' AND expires_at>now() RETURNING user_id",[digest]);
if(!consumed.rowCount)return false;
await client.query('UPDATE account_users SET password_hash=$1 WHERE id=$2',[hash,id]);
await client.query('DELETE FROM account_tokens WHERE user_id=$1',[id]);
return true;
});
}
export async function registerAccount(email: string,password: string) {
const hash = await hashPassword(password);
@@ -42,14 +66,16 @@ export async function verifyAccount(token: string) {
});
}
export async function loginAccount(email: string,password: string) {
const result = await db().query('SELECT * FROM account_users WHERE email=$1',[email]);
return transaction(async client=>{
const result = await client.query('SELECT * FROM account_users WHERE email=$1 FOR UPDATE',[email]);
const user = result.rows[0];
// A fixed dummy hash makes unknown accounts perform the same expensive KDF.
const hash = user?.password_hash || 'scrypt:00000000000000000000000000000000:'+'0'.repeat(128);
if (!await checkPassword(password,hash) || !user?.verified_at) return null;
const token = newToken();
await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES ($1,$2,'session',now()+$3*interval '1 second')",[tokenDigest(token),user.id,SESSION_TTL]);
await client.query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES ($1,$2,'session',now()+$3*interval '1 second')",[tokenDigest(token),user.id,SESSION_TTL]);
return token;
});
}
export function requestToken(request: Request) {
const value = request.headers.get('cookie')?.split(';').map(s=>s.trim()).find(s=>s.startsWith(ACCOUNT_COOKIE+'='))?.slice(ACCOUNT_COOKIE.length+1);
@@ -59,8 +59,14 @@ export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument {
}
export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument {
if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确');
const result=connectNodes({...doc,cards:[...doc.cards,node]},source,node.id);
return parseCanvasDocument(result);
const target=parseCanvasDocument({version:1,cards:[node],links:[]}).cards[0];
for(let attempt=0;attempt<=doc.cards.length+1;attempt++){
const blockers=doc.cards.filter(n=>target.x<n.x+(n.width||320)+64&&target.x+(target.width||320)+64>n.x&&target.y<n.y+(n.height||350)+64&&target.y+(target.height||350)+64>n.y);
if(!blockers.length)return parseCanvasDocument(connectNodes({...doc,cards:[...doc.cards,target]},source,target.id));
const nextY=Math.max(...blockers.map(n=>n.y+(n.height||350)+64));
target.y=nextY>100000?-100000:nextY;
}
throw Error('请移动现有节点后再创建引用节点');
}
export function agentTask(doc:CanvasDocument,id:string){
const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在');
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -1,8 +1,15 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import type { AppProps } from "next/app";
import "@/styles/globals.css";
import "@/styles/skill-docs.css";
import "@/styles/learning-series.css";
import "@/styles/brand.css";
import "@/studio/styles/globals.css";
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
const router = useRouter();
useEffect(() => { const scope = /^\/(?:studio\/)?(?:graphic|infinite_canvas|layouts|template-layout)(?:\/|$)/.test(router.pathname) ? 'canvas' : 'home'; document.documentElement.dataset.theme = `${scope}-${document.documentElement.dataset.mode === 'dark' ? 'dark' : 'light'}`; }, [router.pathname]);
const studio = /^\/(?:studio(?:\/|$)|graphic(?:\/|$)|infinite_canvas(?:\/|$))/.test(router.pathname);
return studio ? <div className="studio-app"><Component {...pageProps} /></div> : <Component {...pageProps} />;
}
@@ -0,0 +1,3 @@
import { Html, Head, Main, NextScript } from 'next/document';
const initializeTheme = String.raw`(function(){try{var mode=localStorage.getItem('endoleap.theme')==='dark'?'dark':'light';var canvas=/^\/(?:studio\/)?(?:graphic|infinite_canvas|layouts|template-layout)(?:\/|$)/.test(location.pathname);document.documentElement.dataset.mode=mode;document.documentElement.dataset.theme=(canvas?'canvas':'home')+'-'+mode;}catch(e){}})();`;
export default function Document() { return <Html lang="zh-CN" data-theme="home-light" data-mode="light" suppressHydrationWarning><Head><link rel="icon" href="/favicon.ico?v=endoleap-1.2" sizes="any"/><link rel="icon" type="image/svg+xml" href="/brand/icon-dark.svg?v=endoleap-1.2"/><link rel="apple-touch-icon" href="/apple-touch-icon.png"/><script dangerouslySetInnerHTML={{__html:initializeTheme}}/></Head><body><Main/><NextScript/></body></Html>; }
+82 -308
View File
@@ -1,22 +1,19 @@
import { Brand } from "@/components/Brand";
import a from "@/styles/account.module.css";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState, type FormEvent } from "react";
import {
ArrowRight,
BookOpen,
CheckCircle2,
Eye,
EyeOff,
GitBranch,
Link2,
LogOut,
MailCheck,
Waypoints,
} from "lucide-react";
import GuidanceShell, { useGuidanceAccount } from "@/components/GuidanceShell";
import { Notice } from "@/components/GuidanceUI";
import s from "@/styles/guidance.module.css";
type Mode = "login" | "register" | "resend";
type Mode = "login" | "register" | "resend" | "forgot" | "reset" | "verify";
function localNext(value: string | string[] | undefined) {
if (
typeof value !== "string" ||
@@ -35,327 +32,104 @@ function localNext(value: string | string[] | undefined) {
}
}
export default function AccountPage() {
return (
<GuidanceShell title="邮箱账户">
<AccountContent />
</GuidanceShell>
);
return <GuidanceShell title="登录与注册" standalone><AccountContent /></GuidanceShell>;
}
function AccountContent() {
const account = useGuidanceAccount(),
router = useRouter();
const [mode, setMode] = useState<Mode>("login"),
[email, setEmail] = useState(""),
[password, setPassword] = useState(""),
[showPassword, setShowPassword] = useState(false);
const [busy, setBusy] = useState(false),
[token, setToken] = useState(""),
[feedback, setFeedback] = useState<{
tone: "success" | "error";
text: string;
} | null>(null);
const account = useGuidanceAccount(), router = useRouter();
const [mode, setMode] = useState<Mode>("login");
const [email, setEmail] = useState(""), [password, setPassword] = useState(""), [confirm, setConfirm] = useState("");
const [showPassword, setShowPassword] = useState(false), [busy, setBusy] = useState(false);
const [token, setToken] = useState("");
const [feedback, setFeedback] = useState<{tone: "success" | "error"; text: string} | null>(null);
useEffect(() => {
const url = new URL(window.location.href);
const value = url.searchParams.get("verify");
if (value) {
setToken(value);
url.searchParams.delete("verify");
const reset = url.searchParams.get("reset"), verify = url.searchParams.get("verify");
if (reset || verify) {
setToken(reset || verify || ""); setMode(reset ? "reset" : "verify");
url.searchParams.delete("reset"); url.searchParams.delete("verify");
window.history.replaceState({}, "", url.pathname + url.search + url.hash);
}
}, []);
async function action(name: Mode | "verify" | "logout", body: object = {}) {
function switchMode(value: Mode) { setMode(value); setFeedback(null); setPassword(""); setConfirm(""); setShowPassword(false); }
async function action(name: Mode | "logout", body: object = {}) {
if (busy) return;
setBusy(true);
setFeedback(null);
setBusy(true); setFeedback(null);
try {
const response = await fetch("/api/account/" + name, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const response = await fetch("/api/account/" + name, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)});
const data = await response.json();
if (!response.ok) throw Error(data.error || "暂时无法完成操作,请重试");
if (name === "verify") {
setToken("");
setMode("login");
setFeedback({ tone: "success", text: "邮箱已验证,现在可以登录。" });
} else if (name === "login") {
setPassword("");
setFeedback({ tone: "success", text: "登录成功,欢迎回到织页。" });
} else if (name === "logout") {
setPassword("");
setFeedback({ tone: "success", text: "已退出登录。" });
} else
setFeedback({
tone: "success",
text:
data.message ||
"如果信息符合条件,验证邮件已发送。请检查收件箱和垃圾邮件。",
});
await account.refresh();
if (name === "verify" || name === "reset") { setToken(""); switchMode("login"); }
if (name === "login" || name === "logout" || name === "reset") { setPassword(""); setConfirm(""); }
setFeedback({tone: "success", text: name === "verify" ? "邮箱已验证,现在可以登录。" : name === "reset" ? "密码已更新,请使用新密码登录。" : name === "login" ? "登录成功,欢迎回到啡跃。" : name === "logout" ? "已退出登录。" : data.message || "若账户符合条件,邮件将发送到你的邮箱。"});
if (["verify", "login", "logout", "reset"].includes(name)) await account.refresh();
if (name === "login") await router.push(localNext(router.query.next));
} catch (e) {
setFeedback({
tone: "error",
text: e instanceof Error ? e.message : "网络异常,请稍后重试",
});
} finally {
setBusy(false);
}
} catch (error) { setFeedback({tone: "error", text: error instanceof Error ? error.message : "网络异常,请稍后重试"}); }
finally { setBusy(false); }
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void action(mode, { email, password });
if (mode === "reset" && password !== confirm) { setFeedback({tone: "error", text: "两次输入的密码不一致。"}); return; }
void action(mode, mode === "reset" ? {token, password} : mode === "verify" ? {token} : {email, password});
}
function switchMode(value: Mode) {
setMode(value);
setFeedback(null);
}
return (
<div className={s.accountLayout}>
<section className={s.accountIntro}>
<span className={s.accountSymbol}>
<Waypoints size={28} />
</span>
<span className={s.eyebrow}>YOUR KNOWLEDGE, CONNECTED</span>
<h1>
让有用的知识,
<br />
成为下一次行动的起点。
</h1>
<p>
用邮箱建立你的知识空间。把工作方法写成独立文档,让 Agent
在需要的时候找到它。
</p>
<div className={s.accountFeatures}>
<div>
<BookOpen size={18} />
<div>
<strong>每篇文档,都能独立使用</strong>
<p>从当前任务出发,无需加载整个集合。</p>
</div>
</div>
<div>
<GitBranch size={18} />
<div>
<strong>每次更新,都有固定版本</strong>
<p>历史引用保持可读,修改始终有迹可循。</p>
</div>
</div>
<div>
<Link2 size={18} />
<div>
<strong>知识之间,按条件连接</strong>
<p>需要更多背景时,再沿有用的链接继续阅读。</p>
</div>
</div>
const user = mode === "reset" || mode === "verify" || mode === "forgot" ? null : account.user;
const title = user ? "你的账户" : ({login: "欢迎回来", register: "创建你的账户", resend: "重发验证邮件", forgot: "找回你的账户", reset: "设置新密码", verify: "确认验证邮箱"})[mode];
const description = user ? "你的知识与创作,随时继续。" : ({login: "使用邮箱与密码登录,继续你的创作。", register: "用邮箱建立你的知识与创作空间。", resend: "仅适用于尚未验证的账户,输入邮箱和原密码。", forgot: "输入注册邮箱,我们会发送重设密码链接。", reset: "由你设置新密码,原有内容会完整保留。", verify: "确认后,即可使用邮箱和密码登录。"})[mode];
const toast = feedback || (account.error ? {tone: "error" as const, text: account.error} : null);
return <div className={a.layout}>
<section className={a.intro} data-theme="home-dark" aria-label="啡跃创作空间">
<Link className={a.brand} href="/" aria-label="啡跃首页"><Brand /></Link>
<div className={a.story}>
<p className={a.eyebrow}>A SPACE FOR YOUR NEXT IDEA</p>
<h1>给想法留白,<br />让灵感有形。</h1>
<p className={a.description}>收集一闪而过的灵感,把有用的知识连接起来。下一件作品,从这里开始。</p>
<div className={a.art} aria-hidden="true">
<div className={a.orbit} />
<div className={a.idea}><span>01 / THE IDEA</span><strong>留住清晨<br />的一束光。</strong><i>灵感片段 · 随手记下</i></div>
<div className={a.work}><span>02 / IN THE MAKING</span><div className={a.artwork} /><strong>光的形状</strong><i>让一个念头,慢慢成形。</i></div>
<span className={a.artCaption}>COLLECT. CONNECT. CREATE.</span>
</div>
</section>
<section className={s.authCard} aria-label="邮箱账户">
<h2>
{account.user
? "你的账户"
: mode === "register"
? "创建你的账户"
: mode === "resend"
? "重发验证邮件"
: "欢迎回来"}
</h2>
<p className={s.authDescription}>
{account.user
? "已准备好发布和管理你的独立文档。"
: mode === "register"
? "验证邮箱后,就可以发布第一篇文档。"
: mode === "resend"
? "输入注册时的邮箱和密码,重新获取验证链接。"
: "使用邮箱与密码登录,继续你的知识工作。"}
</p>
{token && (
<section className={s.verificationCard}>
<h3>
<MailCheck size={17} />
确认验证邮箱
</h3>
<p>验证链接有效期为 30 分钟。确认后,再使用邮箱与密码登录。</p>
<button
type="button"
className={s.primaryButton}
disabled={busy}
onClick={() => void action("verify", { token })}
>
{busy ? "正在验证…" : "确认验证邮箱"}
</button>
</section>
)}
{feedback && <Notice tone={feedback.tone}>{feedback.text}</Notice>}
{account.error && (
<Notice tone="error">
{account.error}{" "}
<button
type="button"
className={s.textLink}
onClick={() => void account.refresh()}
>
重新连接
</button>
</Notice>
)}
{account.loading ? (
<div className={s.accountLoading} aria-busy="true">
正在检查账户状态…
</div>
<p className={a.signature}>啡跃 Endoleap <span>为下一次创作,留一个起点。</span></p>
</section>
<section className={a.form} aria-label="邮箱账户">
<div className={a.frame}>
<h2 key={title}>{title}</h2><p className={a.formDescription}>{description}</p>
{account.loading ? <div className={a.loading} aria-busy="true">正在检查账户状态…</div> : user ? <>
<div className={a.accountDetails}><CheckCircle2 size={22} /><strong>{user.email}</strong><span>邮箱已验证</span></div>
<Link className={s.primaryButton} href={localNext(router.query.next)}>继续使用啡跃 <ArrowRight size={15} /></Link>
<button className={a.secondary} type="button" disabled={busy} onClick={() => void action("logout")}><LogOut size={14} />退出登录</button>
</> : <>
<div className={a.tabSlot}>
{(mode === "login" || mode === "register") && <nav className={a.tabs} aria-label="账户操作" data-active={mode}>
<button type="button" aria-pressed={mode === "login"} disabled={busy} onClick={() => switchMode("login")}>登录</button>
<button type="button" aria-pressed={mode === "register"} disabled={busy} onClick={() => switchMode("register")}>创建账户</button>
</nav>}
</div>
) : account.user ? (
<>
<div className={s.loggedUser}>
<span className={s.avatar}>
{account.user.email.slice(0, 1).toUpperCase()}
</span>
<div>
<strong>{account.user.email}</strong>
<span className={s.verifiedBadge}>
<CheckCircle2 size={13} />
邮箱已验证
</span>
</div>
</div>
<div className={s.accountInfo}>
<p>
<strong>你的内容,由你管理。</strong>
<br />
你可以发布文档、创建新版本,以及撤下自己的内容。公开文档的固定历史版本会继续保留。
</p>
</div>
<Link
className={s.primaryButton}
href={localNext(router.query.next)}
>
继续使用织页
<ArrowRight size={15} />
</Link>
<div className={s.authSecondary}>
<button
type="button"
disabled={busy}
onClick={() => void action("logout")}
>
<LogOut size={12} /> 退出登录
</button>
</div>
</>
) : (
<>
{mode !== "resend" && (
<nav className={s.authTabs} aria-label="账户操作">
<button
type="button"
aria-pressed={mode === "login"}
disabled={busy}
onClick={() => switchMode("login")}
>
登录
</button>
<button
type="button"
aria-pressed={mode === "register"}
disabled={busy}
onClick={() => switchMode("register")}
>
创建账户
</button>
</nav>
)}
<form onSubmit={submit}>
<fieldset className={s.formFieldset} disabled={busy}>
<div className={s.formField}>
<form onSubmit={submit}>
<fieldset className={a.fieldset} disabled={busy}>
<div className={a.fields} key={mode}>
{mode !== "reset" && mode !== "verify" && <div className={a.field}>
<label htmlFor="account-email">邮箱地址</label>
<input
id="account-email"
className={s.input}
type="email"
name="email"
autoComplete="email"
placeholder="you@example.com"
required
maxLength={254}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className={s.formField}>
<label htmlFor="account-password">密码</label>
<input id="account-email" className={s.input} type="email" name="email" autoComplete="email" placeholder="you@example.com" required maxLength={254} value={email} onChange={event => setEmail(event.target.value)} />
</div>}
{mode !== "forgot" && mode !== "verify" && <div className={a.field}>
<div className={a.labelRow}><label htmlFor="account-password">{mode === "reset" ? "新密码" : "密码"}</label>{mode === "login" && <button type="button" onClick={() => switchMode("forgot")}>忘记密码?</button>}</div>
<div className={s.passwordField}>
<input
id="account-password"
className={s.input}
type={showPassword ? "text" : "password"}
name="password"
autoComplete={
mode === "register"
? "new-password"
: "current-password"
}
placeholder={
mode === "register"
? "设置至少 12 个字符的密码"
: "输入你的密码"
}
minLength={12}
maxLength={256}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
aria-label={showPassword ? "隐藏密码" : "显示密码"}
onClick={() => setShowPassword((value) => !value)}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
<input id="account-password" className={s.input} type={showPassword ? "text" : "password"} name="password" autoComplete={mode === "register" || mode === "reset" ? "new-password" : "current-password"} placeholder={mode === "register" || mode === "reset" ? "设置至少 12 个字符的密码" : "输入你的密码"} minLength={12} maxLength={256} required value={password} onChange={event => setPassword(event.target.value)} />
<button type="button" aria-label={showPassword ? "隐藏密码" : "显示密码"} onClick={() => setShowPassword(value => !value)}>{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}</button>
</div>
{mode === "register" && (
<small className={s.fieldHint}>
建议使用包含字母、数字的长密码。
</small>
)}
</div>
<button className={s.primaryButton} type="submit">
{busy
? "正在处理…"
: mode === "login"
? "登录"
: mode === "register"
? "创建账户"
: "发送验证邮件"}
{!busy && <ArrowRight size={15} />}
</button>
</fieldset>
</form>
<div className={s.authSecondary}>
{mode === "resend" ? (
<button
type="button"
disabled={busy}
onClick={() => switchMode("login")}
>
返回登录
</button>
) : (
<button
type="button"
disabled={busy}
onClick={() => switchMode("resend")}
>
没有收到验证邮件?重新发送
</button>
)}
</div>
<p className={s.authFootnote}>
注册后需要先验证邮箱。公开文档始终可以直接阅读。
</p>
</>
)}
</section>
</div>
);
</div>}
{mode === "reset" && <div className={a.field}><label htmlFor="account-confirm">确认新密码</label><input id="account-confirm" className={s.input} type={showPassword ? "text" : "password"} autoComplete="new-password" required minLength={12} maxLength={256} value={confirm} onChange={event => setConfirm(event.target.value)} /></div>}
{mode === "verify" && <div className={a.verifyInfo}><MailCheck size={28} /><p>验证链接有效期为 30 分钟。请确认这是你注册的邮箱。</p></div>}
</div>
<button className={`${s.primaryButton} ${a.submit}`} type="submit">{busy ? "正在处理…" : ({login: "登录", register: "创建账户", resend: "发送验证邮件", forgot: "发送重设链接", reset: "保存新密码", verify: "确认验证邮箱"})[mode]}{!busy && <ArrowRight size={15} />}</button>
</fieldset>
</form>
<div className={a.secondarySlot}>{mode === "login" || mode === "register" ? <button className={a.secondary} type="button" disabled={busy} onClick={() => switchMode("resend")}>没有收到验证邮件?重新发送</button> : <button className={a.secondary} type="button" disabled={busy} onClick={() => switchMode("login")}>返回登录</button>}</div>
<p className={a.footnote}>{mode === "forgot" || mode === "reset" ? "链接仅供本人使用。重设成功后,所有旧会话将退出。" : "首次注册需要验证邮箱;已验证的账户可直接登录。"}</p>
</>}
</div>
</section>
{toast && <div className={a.toast} data-tone={toast.tone} role={toast.tone === "error" ? "alert" : "status"}><span aria-hidden="true">{toast.tone === "error" ? "!" : "✓"}</span><p>{toast.text}</p><button type="button" aria-label={!feedback && account.error ? "重新连接" : "关闭提示"} onClick={() => {setFeedback(null); if (account.error) void account.refresh();}}>×</button></div>}
</div>;
}
@@ -1,11 +1,11 @@
import {embedSvgMedia} from '../../../lib/layout-images';
import {embedSvgMedia} from '../../../../studio/lib/layout-images';
import {renderToStaticMarkup} from 'react-dom/server';
import {serveApiDocumentation} from '../../../lib/api-documentation';
import {serveApiDocumentation} from '../../../../studio/lib/api-documentation';
import type {NextApiRequest,NextApiResponse} from 'next';
import {ideas,ideaMarkdown} from '../../../content/ideas';
import {selections} from '../../../components/SourceCover';
import {renderCover} from '../../../lib/render-cover';
import {tutorialPost} from '../../../content/tool-links';
import {ideas,ideaMarkdown} from '../../../../studio/content/ideas';
import {selections} from '../../../../studio/components/SourceCover';
import {renderCover} from '../../../../studio/lib/render-cover';
import {tutorialPost} from '../../../../studio/content/tool-links';
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(serveApiDocumentation(req,res,'/studio/api/export/{slug}'))return;
if(!['GET','POST'].includes(req.method||'')){res.setHeader('Allow','GET, POST');return res.status(405).end();}
@@ -1,3 +1,3 @@
import {serveApiDocumentation} from '../../lib/api-documentation';
import {serveApiDocumentation} from '../../../studio/lib/api-documentation';
import type {NextApiRequest,NextApiResponse} from "next";
export default function handler(req:NextApiRequest,res:NextApiResponse){if(serveApiDocumentation(req,res,'/studio/api/health'))return;res.setHeader("Cache-Control","no-store");if(!["GET","HEAD"].includes(req.method||""))return res.status(405).end();return req.method==="HEAD"?res.status(200).end():res.status(200).json({status:"ok",service:"prompt-studio"});}
@@ -1,3 +1,3 @@
import type {NextApiRequest,NextApiResponse} from 'next';
import {ideas} from '../../content/ideas';
import {ideas} from '../../../studio/content/ideas';
export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# 创作站\n\n- [在线画布、引用与 Agent 任务](/api/canvases?agent_mode=True)\n- [27 套排版目录与 API](/studio/api/layouts?agent_mode=True)\n- [多页作品与统一视觉](/api/studio/series?agent_mode=True)\n- [按 ID 保存效果](/api/studio/designs?agent_mode=True)\n\n'+ideas.map(i=>`- [${i.title}](/studio/ideas/${i.slug}) · [正文](/studio/api/export/${i.slug}?format=markdown)`).join('\n'));}
@@ -1,12 +1,12 @@
import {embedSvgMedia} from '../../../lib/layout-images';
import {embedSvgMedia} from '../../../../studio/lib/layout-images';
import type {NextApiRequest,NextApiResponse} from 'next';
import {embedLayoutImages} from '../../../lib/layout-images';
import {embedLayoutImages} from '../../../../studio/lib/layout-images';
import {createElement} from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import layouts from '../../../content/templates/layouts.json';
import standardLayout from '../../../content/standard-layout.json';
import {LayoutCanvas} from '../../../components/LayoutCanvas';
import {parameters,LayoutParameterError} from '../../../lib/layout-parameters';
import layouts from '../../../../studio/content/templates/layouts.json';
import standardLayout from '../../../../studio/content/standard-layout.json';
import {LayoutCanvas} from '../../../../studio/components/LayoutCanvas';
import {parameters,LayoutParameterError} from '../../../../studio/lib/layout-parameters';
const workspaceSchema={
added:'可选数组,最多100项:{id:"user-唯一ID",kind:"text|image|decoration",role:"名称",box:[x,y,width,height],direction:"horizontal",text:"文字或null",reading:"custom",shape:"rectangle|ellipse(装饰可选)"}',
layers:'按原生或新增元素ID索引:x/y -10000..10000,width/height 0.1..10000,rotation -360..360,opacity 0..1,radius 0..5000,z -1000..1000,hidden/locked 布尔值;坐标和字号均为模板单位',
@@ -1,6 +1,6 @@
import type {NextApiRequest,NextApiResponse} from 'next';
import fixed from '../../../content/templates/layouts.json';
import standard from '../../../content/standard-layout.json';
import fixed from '../../../../studio/content/templates/layouts.json';
import standard from '../../../../studio/content/standard-layout.json';
export default function handler(req:NextApiRequest,res:NextApiResponse){
if(!['GET','HEAD'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD');return res.status(405).end()}
const entries=[standard,...fixed].map(l=>({id:l.id,name:l.name,family:l.id==='standard-01'?'standard':'fixed',url:'/studio/layouts/'+l.id,api:'/studio/api/layouts/'+l.id}));
+1 -1
View File
@@ -8,7 +8,7 @@ import {tags} from '@/lib/library';
import {systemTags} from '@/lib/system-tags';
export default function Categories({categories,category,entries,cursor,q,total,unavailable=false}:{categories:string[];category:string;entries:ExploreEntry[];cursor:string|null;q:string;total?:number;unavailable?:boolean}){
const link=(value:string)=>({pathname:'/categories',query:{...(value?{category:value}:{}),...(q?{q}:{})}});
return <Shell><Head><title>分类 · PROMPT</title></Head><nav className="discovery-tabs" aria-label="系统标签分类"><Link href={link('')} className={!category?'selected':''}>全部</Link>{categories.map(c=><Link className={c===category?'selected':''} href={link(c)} key={c}>{c}</Link>)}</nav>{category&&!categories.includes(category)&&<div className="ordinary-tag-filter">#{category}<Link href={link('')}>清除筛选 ×</Link></div>}{unavailable&&<p role="status">分类暂时无法读取,请稍后重试。</p>}<ExploreFeed key={JSON.stringify([category,q])} initial={entries} cursor={cursor} q={q} category={category} total={total}/></Shell>;
return <Shell><Head><title>分类 · 啡跃 Endoleap</title></Head><nav className="discovery-tabs" aria-label="系统标签分类"><Link href={link('')} className={!category?'selected':''}>全部</Link>{categories.map(c=><Link className={c===category?'selected':''} href={link(c)} key={c}>{c}</Link>)}</nav>{category&&!categories.includes(category)&&<div className="ordinary-tag-filter">#{category}<Link href={link('')}>清除筛选 ×</Link></div>}{unavailable&&<p role="status">分类暂时无法读取,请稍后重试。</p>}<ExploreFeed key={JSON.stringify([category,q])} initial={entries} cursor={cursor} q={q} category={category} total={total}/></Shell>;
}
export const getServerSideProps:GetServerSideProps=async({query})=>{
const category=String(query.category||'').slice(0,200),q=String(query.q||'').slice(0,200);try{const [all,data]=await Promise.all([tags(),exploreFeed(q,category)]);return {props:{categories:all.filter(t=>t.kind==='system').map(t=>t.name),category,q,entries:data.entries,cursor:data.nextCursor,total:data.total}};}catch{return {props:{categories:systemTags.map(t=>t.name),category,q,entries:[],cursor:null,unavailable:true}}}
@@ -1,9 +1,11 @@
import { ThemeToggle } from "../../studio/components/ThemeToggle";
import { Brand } from "../../studio/components/Brand";
import Head from 'next/head';
import {useEffect,useRef,useState,type PointerEvent} from 'react';
import {MousePointer2,Type,Square,Circle,Plus,Minus,Undo2,Redo2,Download,Layers,ImagePlus,Trash2,Copy,Eye,EyeOff,Lock,Unlock,Scan,ChevronRight,ArrowUpRight,PenTool,X,Hand,Link2,BookOpen,PanelLeft,PanelRight} from 'lucide-react';
import {newElementId} from '../../lib/native-layout';
import styles from '../../styles/graphic.module.css';
import {parseGraphicDocument,type GraphicItem as Item,type GraphicDocument as Doc} from '../../lib/graphic-document';
import {newElementId} from '../../studio/lib/native-layout';
import styles from '../../studio/styles/graphic.module.css';
import {parseGraphicDocument,type GraphicItem as Item,type GraphicDocument as Doc} from '../../studio/lib/graphic-document';
const blank:Doc={name:'未命名设计',background:'#ffffff',items:[]};
const storage='studio.graphic.v1';
export default function Graphic(){
@@ -34,13 +36,13 @@ export default function Graphic(){
function download(content:string,type:string,extension:string){const url=URL.createObjectURL(new Blob([content],{type}));const a=document.createElement('a');a.href=url;a.download=doc.name+'.'+extension;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
function exportSVG(){const esc=(s:string)=>s.replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c]!));const svg=doc.items.filter(i=>!i.hidden).map(i=>i.kind==='text'?`<text x="${i.x}" y="${i.y+i.size}" font-family="sans-serif" font-size="${i.size}" fill="${esc(i.fill)}">${i.text.split('\n').map((line,n)=>`<tspan x="${i.x}" dy="${n?i.size*1.3:0}">${esc(line)}</tspan>`).join('')}</text>`:i.kind==='image'?`<image x="${i.x}" y="${i.y}" width="${i.w}" height="${i.h}" href="${esc(i.src||'')}" preserveAspectRatio="xMidYMid slice"/>`:i.kind==='ellipse'?`<ellipse cx="${i.x+i.w/2}" cy="${i.y+i.h/2}" rx="${i.w/2}" ry="${i.h/2}" fill="${esc(i.fill)}"/>`:`<rect x="${i.x}" y="${i.y}" width="${i.w}" height="${i.h}" fill="${esc(i.fill)}"/>`).join('');download(`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1000" viewBox="0 0 800 1000"><rect width="800" height="1000" fill="${esc(doc.background)}"/>${svg}</svg>`,'image/svg+xml','svg');}
async function account(e:React.FormEvent<HTMLFormElement>){e.preventDefault();setBusy(true);setMessage('');const data=new FormData(e.currentTarget);try{const r=await fetch('/api/account/'+mode,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:data.get('email'),password:data.get('password')})});const d=await r.json();setMessage(d.error||d.message||'登录成功');if(r.ok){await refresh();if(mode==='login')setAuth(false);}}catch{setMessage('账户服务暂时无法连接')}finally{setBusy(false)}}
return <div className={styles.app} data-layers={showLayers} data-properties={showProps}><Head><title>{doc.name + " · Studio Graphic"}</title><meta name="description" content="在 Studio 中自由编排文字、图形与图片。"/></Head>
<header className={styles.top}><a href="/workspace" className={styles.brand}><span>✳</span> studio<span className={styles.badge}>GRAPHIC</span></a><button aria-label="切换图层面板" aria-pressed={showLayers} onClick={()=>setShowLayers(v=>!v)}><PanelLeft size={17}/></button><button aria-label="切换属性面板" aria-pressed={showProps} onClick={()=>setShowProps(v=>!v)}><PanelRight size={17}/></button><ChevronRight size={14}/><input aria-label="设计名称" value={doc.name} maxLength={100} onChange={e=>change({...doc,name:e.target.value})}/><span className={styles.saved} role="status">{status}</span><button title="撤销" aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={17}/></button><button title="重做" aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>setAuth(true)} className={styles.account}>{user?user.email:'邮箱登录'}</button><button className={styles.primary} onClick={exportSVG}><Download size={15}/>导出 SVG</button></header>
return <div className={styles.app} data-layers={showLayers} data-properties={showProps}><Head><title>{doc.name + " · 啡跃 Endoleap Graphic"}</title><meta name="description" content="在 Studio 中自由编排文字、图形与图片。"/></Head>
<header className={styles.top}><a href="/workspace" className={styles.brand}><Brand compact/><span className={styles.badge}>GRAPHIC</span></a><button aria-label="切换图层面板" aria-pressed={showLayers} onClick={()=>setShowLayers(v=>!v)}><PanelLeft size={17}/></button><button aria-label="切换属性面板" aria-pressed={showProps} onClick={()=>setShowProps(v=>!v)}><PanelRight size={17}/></button><ChevronRight size={14}/><input aria-label="设计名称" value={doc.name} maxLength={100} onChange={e=>change({...doc,name:e.target.value})}/><span className={styles.saved} role="status">{status}</span><button title="撤销" aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={17}/></button><button title="重做" aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={17}/></button><ThemeToggle/><button onClick={()=>setAuth(true)} className={styles.account}>{user?user.email:'邮箱登录'}</button><button className={styles.primary} onClick={exportSVG}><Download size={15}/>导出 SVG</button></header>
<div className={styles.body}><aside className={styles.left}><div className={styles.tabs}>{['图层','素材','Agent'].map(t=><button key={t} aria-pressed={tab===t} onClick={()=>setTab(t)}>{t}</button>)}</div><div className={styles.pageTitle}><span>页面 01</span><span>800 × 1000</span></div>{tab==='Agent'?<div className={styles.agentPanel}><Link2 size={24}/><h3>与你的 Agent 一起设计</h3><p>将接入文档交给 coding agent,使用已有的模板与创作 API。</p><a href="/studio/api" target="_blank" rel="noreferrer">查看接入文档 ↗</a><a href="/documents" target="_blank" rel="noreferrer"><BookOpen size={15}/>知识与教程 ↗</a><small>在 Agent 中打开文档,查看可用的创作接口与使用方式。</small></div>:tab==='图层'?<><div className={styles.layers}>{[...doc.items].reverse().map(i=><div className={styles.layer} data-selected={i.id===selected} key={i.id}><button onClick={()=>setSelected(i.id)}>{i.kind==='text'?<Type size={14}/>:<Square size={14}/>}<span>{i.name}</span></button><button aria-label={i.hidden?'显示图层':'隐藏图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,hidden:!n.hidden}:n)})}>{i.hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button aria-label={i.locked?'解锁图层':'锁定图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,locked:!n.locked}:n)})}>{i.locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}{!doc.items.length&&<div className={styles.emptyLayers}><Layers size={23}/><p>好设计,从第一笔开始</p><small>从下方工具栏添加文字与图形</small></div>}</div><button className={styles.addLayer} onClick={()=>add('text')}><Plus size={15}/>添加图层</button></>:<div className={styles.assets}><h3>基础元素</h3><button onClick={()=>add('rect')}><Square/>矩形</button><button onClick={()=>add('ellipse')}><Circle/>椭圆</button><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>upload.current?.click()}><ImagePlus/>上传图片</button><p>你的画布,由你定义。支持 PNG、JPEG、WebP。</p></div>}<div className={styles.leftFooter}><a href="/infinite_canvas">Infinite Canvas <ArrowUpRight size={14}/></a><small>⌘ Z 撤销 · 方向键微调 · Delete 删除</small></div></aside>
<main ref={viewport} className={styles.viewport} onPointerDown={e=>{setSelected('');panBoard(e)}}><div className={styles.canvasLabel}>画板 01 <span>800 × 1000</span></div><div className={styles.boardWrap} style={{width:800*zoom,height:1000*zoom,transform:`translate(${offset.x}px,${offset.y}px)`}}><div ref={board} className={styles.board} style={{background:doc.background,transform:`scale(${zoom})`}}>{doc.items.filter(i=>!i.hidden).map(i=><div key={i.id} tabIndex={0} role="button" aria-label={i.name} aria-pressed={selected===i.id} onFocus={()=>setSelected(i.id)} onPointerDown={e=>drag(e,i)} className={styles.element} data-selected={selected===i.id} style={{left:i.x,top:i.y,width:i.w,height:i.h,borderRadius:i.kind==='ellipse'?'50%':0,background:i.kind==='rect'||i.kind==='ellipse'?i.fill:undefined,color:i.fill,fontSize:i.size,whiteSpace:'pre-wrap'}}>{i.kind==='text'?i.text:i.kind==='image'?<img src={i.src} alt={i.name} draggable={false}/>:null}{selected===i.id&&<><i className={styles.corner} onPointerDown={resize}/><span className={styles.measure}>{Math.round(i.w)} × {Math.round(i.h)}</span></>}</div>)}{!doc.items.length&&<div className={styles.blankHint}><PenTool size={34} strokeWidth={1}/><h1>留白,是创作的起点。</h1><p>添加文字、图形,或放入你的第一张图片。</p><button onPointerDown={e=>e.stopPropagation()} onClick={()=>add('text')}>开始设计 <ArrowUpRight size={16}/></button></div>}</div></div><div className={styles.tools} onPointerDown={e=>e.stopPropagation()}><button title="选择" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>{setTool('select');setSelected('')}}><MousePointer2 size={20}/></button><button title="抓手工具" aria-label="抓手工具" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand size={19}/></button><button title="文字" aria-label="添加文字" onClick={()=>add('text')}><Type size={20}/></button><button title="矩形" aria-label="添加矩形" onClick={()=>add('rect')}><Square size={19}/></button><button title="椭圆" aria-label="添加椭圆" onClick={()=>add('ellipse')}><Circle size={19}/></button><button title="图片" aria-label="上传图片" onClick={()=>upload.current?.click()}><ImagePlus size={19}/></button><span/><button aria-label="缩小" onClick={()=>setZoom(z=>Math.max(.15,z-.1))}><Minus size={16}/></button><output>{Math.round(zoom*100)}%</output><button aria-label="放大" onClick={()=>setZoom(z=>Math.min(2,z+.1))}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Scan size={18}/></button></div></main>
<aside className={styles.right}><div className={styles.inspectorTitle}>设计属性<span>Design</span></div>{item?<div className={styles.props}><label>图层名称<input maxLength={200} value={item.name} disabled={item.locked} onChange={e=>patch({name:e.target.value})}/></label><h3>布局</h3><div className={styles.fields}>{(['x','y','w','h'] as const).map(k=><label key={k}>{({x:'X',y:'Y',w:'宽',h:'高'})[k]}<input type="number" value={Math.round(item[k])} disabled={item.locked} onChange={e=>{const value=Number(e.target.value);if(Number.isFinite(value))patch({[k]:k==='w'||k==='h'?Math.max(1,Math.min(4000,value)):Math.max(-4000,Math.min(4000,value))})}}/></label>)}</div>{item.kind==='text'&&<><h3>文字</h3><textarea aria-label="文字内容" value={item.text} maxLength={4000} disabled={item.locked} onChange={e=>patch({text:e.target.value})}/><label>字号<input type="number" min={8} max={240} value={item.size} disabled={item.locked} onChange={e=>patch({size:Math.max(8,Math.min(240,Number(e.target.value)||8))})}/></label></>}<h3>填色</h3><label className={styles.color}><input type="color" value={item.fill} disabled={item.locked} onChange={e=>patch({fill:e.target.value})}/>{item.fill.toUpperCase()}</label><div className={styles.actions}><button onClick={duplicate}><Copy size={15}/>复制</button><button disabled={item.locked} onClick={remove}><Trash2 size={15}/>删除</button></div><button disabled={item.locked} onClick={()=>{const n=doc.items.findIndex(i=>i.id===item.id);if(n>0){const items=[...doc.items];[items[n-1],items[n]]=[items[n],items[n-1]];change({...doc,items})}}}>下移一层</button></div>:<div className={styles.props}><h3>画板</h3><p>自由画布 · 竖版</p><div className={styles.dimensions}>W <b>800</b> H <b>1000</b></div><h3>背景颜色</h3><label className={styles.color}><input type="color" value={doc.background} onChange={e=>change({...doc,background:e.target.value})}/>{doc.background.toUpperCase()}</label><div className={styles.note}><MousePointer2 size={20}/><p>选中一个图层,<br/>调整属于它的每个细节。</p></div></div>}<div className={styles.rightFooter}><button onClick={()=>download(JSON.stringify(doc,null,2),'application/json','json')}><Download size={15}/>备份设计 JSON</button><button onClick={()=>restore.current?.click()}>导入设计 JSON</button><p>草稿仅保存在此浏览器。<br/>邮箱登录共用全站账户。</p></div></aside></div>
<input ref={restore} hidden type="file" accept="application/json,.json" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;try{if(f.size>5*1024*1024)throw Error('文件不得超过 5MB');const restored=parseGraphicDocument(await f.text());change(restored);setSelected('');}catch(error){setStatus(error instanceof Error?error.message:'导入失败')}}}/>
<input ref={upload} hidden type="file" accept="image/png,image/jpeg,image/webp" onChange={e=>{const f=e.target.files?.[0];if(!f)return;if(f.size>3*1024*1024||!['image/png','image/jpeg','image/webp'].includes(f.type)){setStatus('请使用 3MB 以内的 PNG / JPEG / WebP');return}const reader=new FileReader();reader.onload=()=>add('image',String(reader.result));reader.readAsDataURL(f);e.target.value=''}}/>
{auth&&<div className={styles.overlay}><div ref={dialog} className={styles.modal} role="dialog" aria-modal="true" aria-label="邮箱账户"><button className={styles.close} aria-label="关闭" onClick={()=>setAuth(false)}><X size={20}/></button><span className={styles.modalIcon}>✳</span><h2>{user?'你的 Studio 账户':'把灵感带进 Studio'}</h2>{user?<><p>{user.email}</p><button onClick={async()=>{try{const r=await fetch('/api/account/logout',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});if(!r.ok)throw Error();setUser(null);setAuth(false)}catch{setMessage('退出失败,请重试')}}}>退出登录</button></>:<><p>使用知识站的同一个邮箱账户</p><div className={styles.tabs}>{[['login','登录'],['register','注册'],['resend','重发邮件']].map(([v,t])=><button key={v} aria-pressed={mode===v} onClick={()=>setMode(v)}>{t}</button>)}</div><form onSubmit={account}><label>邮箱<input autoFocus required type="email" name="email" autoComplete="email" maxLength={254}/></label><label>密码<input required type="password" name="password" minLength={12} maxLength={256} autoComplete={mode==='register'?'new-password':'current-password'}/></label><small>密码至少 12 字符;新账户需通过邮件验证。</small><button className={styles.primary} disabled={busy}>{busy?'处理中…':mode==='login'?'登录 Studio':mode==='register'?'创建账户':'重发验证邮件'}</button></form></>}<p role="status">{message}</p></div></div>}
{auth&&<div className={styles.overlay}><div ref={dialog} className={styles.modal} role="dialog" aria-modal="true" aria-label="邮箱账户"><button className={styles.close} aria-label="关闭" onClick={()=>setAuth(false)}><X size={20}/></button><span className={styles.modalIcon}><Brand compact/></span><h2>{user?'你的 Studio 账户':'把灵感带进 Studio'}</h2>{user?<><p>{user.email}</p><button onClick={async()=>{try{const r=await fetch('/api/account/logout',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});if(!r.ok)throw Error();setUser(null);setAuth(false)}catch{setMessage('退出失败,请重试')}}}>退出登录</button></>:<><p>使用知识站的同一个邮箱账户</p><div className={styles.tabs}>{[['login','登录'],['register','注册'],['resend','重发邮件']].map(([v,t])=><button key={v} aria-pressed={mode===v} onClick={()=>setMode(v)}>{t}</button>)}</div><form onSubmit={account}><label>邮箱<input autoFocus required type="email" name="email" autoComplete="email" maxLength={254}/></label><label>密码<input required type="password" name="password" minLength={12} maxLength={256} autoComplete={mode==='register'?'new-password':'current-password'}/></label><small>密码至少 12 字符;新账户需通过邮件验证。</small><button className={styles.primary} disabled={busy}>{busy?'处理中…':mode==='login'?'登录 Studio':mode==='register'?'创建账户':'重发验证邮件'}</button></form></>}<p role="status">{message}</p></div></div>}
</div>
}
+17 -5
View File
@@ -1,10 +1,22 @@
import { ThemeToggle } from "../components/ThemeToggle";
import { Brand } from "../components/Brand";
import Head from 'next/head';
import {ArrowUpRight, MousePointer2, Plus, Type, Image, Sparkles, Workflow, Layers, ArrowRight} from 'lucide-react';
import {ArrowUpRight, Workflow, Layers, ArrowRight} from 'lucide-react';
import {CreatorSketch, IdeaSketch} from "@/components/LandingSketches";
import s from '@/styles/landing.module.css';
export default function Home(){return <main className={s.page}>
<Head><title>织页 Studio · 让灵感,有形。</title><meta name="description" content="连接你的 coding agent,在图形编辑器与无限画布中,把想法组织成作品。"/></Head>
<nav className={s.nav}><a className={s.logo} href="/">✳ <b>织页</b><span>STUDIO</span></a><div><a href="#tools">创作工具</a><a href="/documents">知识与教程</a><a href="#workflow">工作方式</a></div><a href="/account?next=/workspace">登录</a><a className={s.dark} href="/account?next=/workspace">进入工作台 <ArrowUpRight size={15}/></a></nav>
<section className={s.hero}><p className={s.eyebrow}>A SPACE FOR HUMAN IDEAS & AGENTS</p><h1>想法不该停在对话里。<br/><span>让灵感,有形。</span></h1><p className={s.intro}>从一闪而过的念头,到值得分享的作品。<br/>连接你的 coding agent,在一张画布上,把创作继续下去。</p><div className={s.actions}><a className={s.dark} href="/account?next=/workspace">开始创作 <ArrowRight size={17}/></a><a href="#tools">探索你的创作空间 ↓</a></div><div className={s.product}><div className={s.productBar}><b>✳ STUDIO</b><span>日常的另一种可能 / 创意探索</span><span>本机草稿</span></div><div className={s.demo}><aside>工作空间<hr/>图层与素材<p>◈ 创意简报</p><p>▧ 视觉探索</p><p>✳ 镜头构思</p><small>把线索连接起来</small></aside><div className={s.demoWorld}><svg viewBox="0 0 800 330" preserveAspectRatio="none"><path d="M190 170 C300 170 260 120 360 120 S520 240 620 240"/></svg><article className={s.note}><small>01 / THE IDEA</small><h3>把日常,<br/>拍成一场电影。</h3><p>自然光、细腻颗粒,<br/>让每一帧都有呼吸感。</p></article><article className={s.art}><small>02 / VISUAL STUDY</small><div className={s.orbit}/><h3>光的形状</h3><span>LIGHT / SPACE / EMOTION</span></article><article className={s.prompt}><Sparkles size={17}/><small>03 / PROMPT</small><h3>留住清晨的光。</h3><p>窗边的植物,安静的房间。<br/>35mm 胶片,缓慢推进。</p></article><div className={s.dock}><MousePointer2/><Plus/><Type/><Image/><Sparkles/></div></div></div></div><p className={s.caption}>一个创作场景示意。你的想法、素材与工具,在这里相遇。</p></section>
<Head><title>啡跃 Endoleap · 让灵感,有形。</title><meta name="description" content="收藏灵感、连接知识,与熟悉的 Agent 一起,把想法变成作品。探索图形编辑器、无限画布与知识文档。"/></Head>
<nav className={s.nav} aria-label="主导航"><a className={s.logo} href="/"><Brand/></a><div><a href="#tools">创作工具</a><a href="/documents">知识与教程</a><a href="#workflow">工作方式</a></div><ThemeToggle/><a href="/account?next=/workspace">登录</a><a className={s.dark} href="/account?next=/workspace">进入工作台 <ArrowUpRight size={15}/></a></nav>
<section className={s.hero}><p className={s.eyebrow}>A SPACE FOR HUMAN IDEAS & AGENTS</p><h1>想法不该停在对话里。<br/><span>让灵感,有形。</span></h1><p className={s.intro}>从一闪而过的念头,到值得分享的作品。<br/>连接你的 coding agent,在一张画布上,把创作继续下去。</p><div className={s.actions}><a className={s.dark} href="/account?next=/workspace">开始创作 <ArrowRight size={17}/></a><a href="#tools">探索你的创作空间 ↓</a></div><div className={s.peopleStrip}><span className={s.stripNote}>每一种好奇,都有自己的起点。</span>{([
['collect','收藏生活的切片'],['read','遇见新的工作方法'],['idea','记下突然出现的灵感'],['dream','给想法一点自由'],['make','做一件属于你的作品']
] as const).map(([pose,label])=><figure key={pose}><CreatorSketch pose={pose}/><figcaption>{label}</figcaption></figure>)}<span className={s.stripSpark} aria-hidden="true">✳</span></div></section>
<section className={s.possibilities} id="possibilities" aria-labelledby="possibilities-title"><p className={s.eyebrow}>MAKE ROOM FOR WHAT COMES NEXT</p><h2 id="possibilities-title">给好奇心一点空间,<br/>让下一步<em>自然发生。</em></h2><div className={s.featureGrid}>{([
['collect','留住一闪而过','一句念头、一张参考图、一个还没想好的方向。先留下,再慢慢展开。'],
['connect','让线索相遇','把笔记、图片和提示词放上画布,用连接看见它们之间的新可能。'],
['read','找到有用的方法','阅读可独立使用的知识文档,需要更多背景时,沿链接继续探索。'],
['make','把创作继续下去','用熟悉的 coding agent 探索,再亲手调整细节,让想法成为自己的作品。']
] as const).map(([kind,title,copy])=><article key={kind}><IdeaSketch kind={kind}/><h3>{title}</h3><p>{copy}</p></article>)}</div></section>
<section className={s.tools} id="tools"><p className={s.eyebrow}>TWO TOOLS. ONE CREATIVE FLOW.</p><h2>给不同的想法,<br/>恰好的空间。</h2><div className={s.toolGrid}><a href="/graphic"><Layers/><span>01 / GRAPHIC</span><h3>把灵感排成作品。</h3><p>文字、图形与图片自由编排。精确调整图层和位置,把设计导出为 SVG。</p><div className={s.poster}><span>MAKE<br/>ROOM<br/><i>for ideas.</i></span><b>✳</b></div><strong>打开图形编辑器 <ArrowUpRight size={17}/></strong></a><a href="/infinite_canvas"><Workflow/><span>02 / INFINITE CANVAS</span><h3>让思路,自由生长。</h3><p>把笔记、参考图与提示词连成线索。在无限画布上平移、缩放,整理下一次创作。</p><div className={s.miniCanvas}><b>一个念头</b><i>⟶</i><b>一张参考</b><i>⟶</i><b>新的可能</b></div><strong>打开无限画布 <ArrowUpRight size={17}/></strong></a></div></section>
<section className={s.reading} aria-labelledby="reading-title"><div><p className={s.eyebrow}>GOOD IDEAS DESERVE GOOD COMPANY</p><h2 id="reading-title">不必每一次,<br/>都从空白开始。</h2><p>把有用的工作方法,留给下一次的自己。<br/>一篇文档讲清一件事,需要时再沿着链接走远一点。</p><a href="/documents">去知识空间逛逛 <ArrowUpRight size={16}/></a></div><div className={s.readingArt} aria-hidden="true"><span>读到这里,<br/><em>想到了什么?</em></span><CreatorSketch pose="read"/><small>READ. CONNECT. MAKE.</small><svg viewBox="0 0 440 380"><path d="M35 105q-20-55 35-64m-15 0 17-2-8 17M348 267q46 33 32 68m-14-13 14 15 14-13"/></svg></div></section>
<section className={s.workflow} id="workflow"><div><p className={s.eyebrow}>YOUR AGENT. YOUR WORKFLOW.</p><h2>用熟悉的 Agent,<br/>继续你的创作。</h2><p>通过项目已有的 API 与文档,让 coding agent 理解模板和创作内容。画布负责呈现,你来决定下一步。</p><a href="/studio/api">查看 Agent 接入文档 <ArrowUpRight size={16}/></a></div><ol><li><b>01</b><div><h3>找到起点</h3><p>从空白设计、素材或一段灵感出发。</p></div></li><li><b>02</b><div><h3>连接工作方式</h3><p>将接入文档交给你的 coding agent。</p></div></li><li><b>03</b><div><h3>亲手打磨,保存作品</h3><p>在编辑器中调整细节,导出并保留备份。</p></div></li></ol></section>
<section className={s.end}><span>✳</span><h2>下一件作品,<br/>从这里开始。</h2><a className={s.dark} href="/account?next=/workspace">进入你的工作台 <ArrowRight size={17}/></a></section><footer className={s.footer}><a className={s.logo} href="/">✳ 织页 STUDIO</a><span>给想法留白,给创作空间。</span><a href="/documents">知识与教程 ↗</a></footer></main>}
<section className={s.end}><div className={s.endWall} aria-hidden="true">{(['collect','read','connect','make'] as const).map(kind=><div key={kind}><IdeaSketch kind={kind}/><i/><i/><i/></div>)}</div><div className={s.endContent}><p className={s.eyebrow}>YOUR NEXT IDEA STARTS HERE</p><h2>给下一个念头,<br/><em>留一个起点。</em></h2><p>不用想好全部。带着一点好奇,先开始。</p><a className={s.dark} href="/account?next=/workspace">进入你的工作台 <ArrowRight size={17}/></a><a className={s.endSecondary} href="/documents">或先读一篇有用的文档 ↗</a></div></section><footer className={s.footer}><a className={s.logo} href="/"><Brand/></a><span>给想法留白,给创作空间。</span><a href="/documents">知识与教程 ↗</a></footer></main>}
@@ -1,25 +1,27 @@
import { ThemeToggle } from "../../studio/components/ThemeToggle";
import { Brand } from "../../studio/components/Brand";
import Head from 'next/head';
import {useEffect,useRef,useState} from 'react';
import {ArrowLeft,Plus,MousePointer2,Hand,ImagePlus,Type,Workflow,Undo2,Redo2,Minus,Maximize,Download,Upload,Trash2,Link2,Sparkles,ChevronRight,Sun,Moon} from 'lucide-react';
import s from '../../styles/infinite-canvas.module.css';
import CanvasNodeView from '../../components/canvas/CanvasNodeView';
import CanvasComposer from '../../components/canvas/CanvasComposer';
import CanvasAgentPanel,{type CanvasAgentHandle} from '../../components/canvas/CanvasAgentPanel';
import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask,type CanvasNode as Card,type CanvasDocument as Doc} from '../../lib/canvas-document';
import s from '../../studio/styles/infinite-canvas.module.css';
import CanvasNodeView from '../../studio/components/canvas/CanvasNodeView';
import CanvasComposer from '../../studio/components/canvas/CanvasComposer';
import CanvasAgentPanel,{type CanvasAgentHandle} from '../../studio/components/canvas/CanvasAgentPanel';
import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask,type CanvasNode as Card,type CanvasDocument as Doc} from '../../studio/lib/canvas-document';
const example:Doc={version:1,cards:[{id:'brief',kind:'text',title:'01 / 创意简报',text:'把日常,拍成一场电影。\n\n一段关于城市清晨的视觉散文。低饱和色彩、自然光与细腻颗粒,让每一帧都有呼吸感。',x:70,y:140},{id:'reference',kind:'image',title:'02 / 视觉参考',text:'晨光 · 建筑 · 胶片质感\n上传你的参考图片',x:460,y:80},{id:'shot',kind:'prompt',title:'03 / 镜头构思',text:'清晨,阳光穿过窗边的植物,落在安静的房间里。缓慢推进镜头,35mm 胶片,温暖柔和的光线。',x:850,y:160}],links:[['brief','reference'],['reference','shot']]};
const initial:Doc={version:1,cards:[],links:[]};
const key='studio.infinite-canvas.v1';
export default function InfiniteCanvas(){
const [doc,setDoc]=useState<Doc>(initial),[selected,select]=useState('shot'),[zoom,setZoom]=useState(.8),[pan,setPan]=useState({x:0,y:0}),[tool,setTool]=useState('select'),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[status,setStatus]=useState('示例项目 · 尚未保存'),[ready,setReady]=useState(false),[email,setEmail]=useState(''),[link,setLink]=useState(''),[menu,setMenu]=useState<{x:number;y:number}|null>(null),[theme,setTheme]=useState<'light'|'dark'>('dark'),[editing,setEditing]=useState(''),[refMenu,setRefMenu]=useState(''),[taskFeedback,setTaskFeedback]=useState(''),[composerTarget,setComposerTarget]=useState(''),[pickerRequest,setPickerRequest]=useState(0);
const [doc,setDoc]=useState<Doc>(initial),[selected,select]=useState('shot'),[zoom,setZoom]=useState(.8),[pan,setPan]=useState({x:0,y:0}),[tool,setTool]=useState('select'),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[status,setStatus]=useState('示例项目 · 尚未保存'),[ready,setReady]=useState(false),[email,setEmail]=useState(''),[link,setLink]=useState(''),[menu,setMenu]=useState<{x:number;y:number}|null>(null),[editing,setEditing]=useState(''),[refMenu,setRefMenu]=useState(''),[taskFeedback,setTaskFeedback]=useState(''),[composerTarget,setComposerTarget]=useState(''),[pickerRequest,setPickerRequest]=useState(0);
const file=useRef<HTMLInputElement>(null),json=useRef<HTMLInputElement>(null),viewport=useRef<HTMLDivElement>(null),drag=useRef<{id:string;x:number;y:number;ox:number;oy:number;before:Doc;resize?:boolean}|null>(null);
const pickerSequence=useRef(0),agent=useRef<CanvasAgentHandle>(null);
const active=doc.cards.find(c=>c.id===selected);
useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=parseCanvasDocument(raw);setDoc(d);setStatus('已恢复本机草稿')}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}try{const v=JSON.parse(localStorage.getItem(key+'.view')||'null');if(v&&Number.isFinite(v.zoom)&&v.zoom>=.2&&v.zoom<=2&&Number.isFinite(v.x)&&Number.isFinite(v.y)){setZoom(v.zoom);setPan({x:v.x,y:v.y})}}catch{}try{const t=localStorage.getItem(key+'.theme');setTheme(t==='light'?'light':'dark')}catch{}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]);
useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=parseCanvasDocument(raw);setDoc(d);setStatus('已恢复本机草稿')}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}try{const v=JSON.parse(localStorage.getItem(key+'.view')||'null');if(v&&Number.isFinite(v.zoom)&&v.zoom>=.2&&v.zoom<=2&&Number.isFinite(v.x)&&Number.isFinite(v.y)){setZoom(v.zoom);setPan({x:v.x,y:v.y})}}catch{}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]);
useEffect(()=>{if(!ready)return;try{localStorage.setItem(key,JSON.stringify(doc));setStatus('已保存到此浏览器')}catch{setStatus('本机空间不足,请导出项目备份')}},[doc,ready]);
useEffect(()=>{if(ready)try{localStorage.setItem(key+'.view',JSON.stringify({...pan,zoom}))}catch{}},[pan,zoom,ready]);
useEffect(()=>{const el=viewport.current;if(!el)return;const block=(e:WheelEvent)=>{if(!(e.target as HTMLElement).closest('textarea,input,select'))e.preventDefault()};el.addEventListener('wheel',block,{passive:false});return()=>el.removeEventListener('wheel',block)},[]);
useEffect(()=>{const keys=(e:KeyboardEvent)=>{if(e.key==='Escape'){setMenu(null);setRefMenu('');setEditing('');return}if((e.target as HTMLElement).closest('input,textarea,select,[contenteditable]'))return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)redo();else undo()}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}};window.addEventListener('keydown',keys);return()=>window.removeEventListener('keydown',keys)});
useEffect(()=>{if(ready)try{localStorage.setItem(key+'.theme',theme)}catch{}},[theme,ready]);
function scaleAt(next:number,cx?:number,cy?:number){const el=viewport.current;if(!el)return;const r=el.getBoundingClientRect(),x=cx===undefined?r.width/2:cx-r.left,y=cy===undefined?r.height/2:cy-r.top;const z=Math.max(.2,Math.min(2,next));setPan({x:x-(x-pan.x)*z/zoom,y:y-(y-pan.y)*z/zoom});setZoom(z)}
function duplicate(){if(!active||doc.cards.length>=80)return;const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');commit({...doc,cards:[...doc.cards,{...active,id,title:active.title.slice(0,197)+' 副本',x:active.x+40,y:active.y+40}]});select(id)}
function commit(next:Doc){setPast(p=>[...p.slice(-29),doc]);setFuture([]);setDoc(next)}
@@ -41,8 +43,8 @@ export default function InfiniteCanvas(){
function connect(from:string,to:string){try{const next=connectNodes(doc,from,to);if(next!==doc)commit(next);setTaskFeedback('')}catch(e){setTaskFeedback(e instanceof Error?e.message:'无法引用节点')}}
function exportTask(id:string){try{const task=agentTask(doc,id),url=URL.createObjectURL(new Blob([JSON.stringify(task,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download=`canvas-task-${id}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);setTaskFeedback('Agent 任务已导出')}catch(e){setTaskFeedback(e instanceof Error?e.message:'无法导出任务')}}
function exportDoc(){const url=URL.createObjectURL(new Blob([JSON.stringify(doc,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='studio-canvas.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
return <main className={s.app} data-theme={theme}><Head><title>无限画布 · Studio</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
<header className={s.header}><a href="/workspace" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/workspace" className={s.brand}>STUDIO<span>✳</span></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><CanvasAgentPanel ref={agent} document={doc} email={email} onLoad={next=>{setDoc(next);setPast([]);setFuture([]);setEditing('');select('')}} onFeedback={setTaskFeedback}/><button aria-label="切换明暗主题" title={theme==='dark'?'切换浅色模式':'切换深色模式'} onClick={()=>setTheme(t=>t==='dark'?'light':'dark')}>{theme==='dark'?<Sun size={17}/>:<Moon size={17}/>}</button><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
return <main className={s.app}><Head><title>无限画布 · 啡跃 Endoleap</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
<header className={s.header}><a href="/workspace" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/workspace" className={s.brand}><Brand compact/></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><CanvasAgentPanel ref={agent} document={doc} email={email} onLoad={next=>{setDoc(next);setPast([]);setFuture([]);setEditing('');select('')}} onFeedback={setTaskFeedback}/><ThemeToggle/><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
<div className={s.workspace}><nav className={s.rail} aria-label="画布工具"><button title="选择工具" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>setTool('select')}><MousePointer2/></button><button title="移动画布" aria-label="移动画布" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand/></button><hr/><button title="添加笔记" aria-label="添加笔记" onClick={()=>add('text')}><Type/></button><button title="上传图片" aria-label="上传图片" onClick={()=>file.current?.click()}><ImagePlus/></button><button title="添加提示词" aria-label="添加提示词" onClick={()=>add('prompt')}><Sparkles/></button><button title="导入项目" aria-label="导入项目" onClick={()=>json.current?.click()}><Upload/></button><div className={s.spacer}/><span>∞</span></nav>
<section className={s.canvasArea}>{!doc.cards.length&&<div className={s.startPanel}><span>YOUR NEXT IDEA STARTS HERE</span><h2>给想法,一个无限的空间。</h2><p>双击画布添加节点,或选择一个起点。</p><div><button onClick={()=>add('text')}><Type size={19}/>灵感笔记</button><button onClick={()=>file.current?.click()}><ImagePlus size={19}/>参考图片</button><button onClick={()=>add('prompt')}><Sparkles size={19}/>创作提示词</button></div><button onClick={()=>{commit(example);select('');setPan({x:0,y:0});setZoom(.7)}}>从示例开始 ↗</button><a href="/studio/api" target="_blank" rel="noreferrer">连接你的 coding agent · 接入文档 ↗</a></div>}<div className={s.canvasTitle}><span className={s.eyebrow}>YOUR IDEAS, CONNECTED</span><h1>无限画布<span> / Infinite canvas</span></h1><p>从一个灵感,连接所有可能。</p></div>
<div className={s.viewport} style={{backgroundPosition:`${pan.x}px ${pan.y}px`,backgroundSize:`${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px ${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px`}} ref={viewport} onDoubleClick={e=>{if(e.target===e.currentTarget){setMenu(null);add('text')}}} onContextMenu={e=>{e.preventDefault();const id=(e.target as HTMLElement).closest('[data-node-id]')?.getAttribute('data-node-id');select(id||'');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onWheel={e=>{const target=e.target as HTMLElement;if(drag.current)return;if(target.closest('textarea,input,select'))return;if(e.ctrlKey||e.metaKey){scaleAt(zoom*Math.exp(-e.deltaY*.002),e.clientX,e.clientY)}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{setMenu(null);setRefMenu('');if(e.target===e.currentTarget){setEditing('');select('')};if(e.target===e.currentTarget||(tool==='hand'&&!(e.target as HTMLElement).closest('input,textarea,button,select'))||e.button===1){e.currentTarget.setPointerCapture(e.pointerId);drag.current={id:'pan',x:e.clientX,y:e.clientY,ox:pan.x,oy:pan.y,before:doc}}}} onPointerMove={e=>{const d=drag.current;if(!d)return;if(d.id==='pan')setPan({x:d.ox+e.clientX-d.x,y:d.oy+e.clientY-d.y});else {if(!d.resize&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4)return;e.currentTarget.setPointerCapture(e.pointerId);setDoc({...d.before,cards:d.before.cards.map(c=>c.id===d.id?(d.resize?{...c,width:Math.max(260,Math.min(1200,d.ox+(e.clientX-d.x)/zoom)),height:Math.max(300,Math.min(1200,d.oy+(e.clientY-d.y)/zoom))}:{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}):c)})}}} onPointerUp={e=>{const d=drag.current;if(d?.id==='pan'&&tool==='select'&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4){select('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}if(d&&d.id!=='pan'&&doc!==d.before){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{if(drag.current?.id!=='pan'&&drag.current)setDoc(drag.current.before);drag.current=null}}>
@@ -12,7 +12,7 @@ export default function Import({allTags}:{allTags:Tag[]}){
const [files,setFiles]=useState<File[]>([]);const [title,setTitle]=useState('');const [description,setDescription]=useState('');const [selected,setSelected]=useState<string[]>([]);const [busy,setBusy]=useState(false);const [error,setError]=useState('');
function choose(list:FileList|null){if(!list)return;const items=Array.from(list);setFiles(items);if(!title&&items[0])setTitle(items[0].name.replace(/\.[^.]+$/,''));}
async function submit(e:FormEvent){e.preventDefault();setError('');setBusy(true);try{const data=new FormData();data.set('kind',kind);data.set('title',title);data.set('description',description);data.set('tags',JSON.stringify(selected));files.forEach(f=>data.append('files',f));const res=await fetch('/api/upload',{method:'POST',body:data});const result=await res.json();if(!res.ok)throw new Error(result.error);await router.push('/explore/'+result.id);}catch(e){setError(e instanceof Error?e.message:'上传失败');}finally{setBusy(false);}}
return <Shell><Head><title>素材入库 · PROMPT</title></Head><header className="page-header"><div><span className="eyebrow">MAKE ROOM FOR INSPIRATION</span><h1>导入素材</h1><p>支持多文件导入。同一组素材收录为一个素材包,每份文件都可以再次引用。</p></div></header><form className="editor-panel" onSubmit={submit}>
return <Shell><Head><title>素材入库 · 啡跃 Endoleap</title></Head><header className="page-header"><div><span className="eyebrow">MAKE ROOM FOR INSPIRATION</span><h1>导入素材</h1><p>支持多文件导入。同一组素材收录为一个素材包,每份文件都可以再次引用。</p></div></header><form className="editor-panel" onSubmit={submit}>
<div className="kind-picker">{[{id:'image',name:'图片',Icon:Image},{id:'video',name:'视频',Icon:Film},{id:'audio',name:'声音',Icon:AudioLines}].map(t=><button type="button" key={t.id} disabled={busy} className={kind===t.id?'selected':''} onClick={()=>{setKind(t.id);setFiles([]);}}><t.Icon size={19}/>{t.name}</button>)}</div>
<label className="drop-zone" onDragOver={e=>e.preventDefault()} onDrop={e=>{e.preventDefault();if(!busy)choose(e.dataTransfer.files);}}><input type="file" multiple disabled={busy} accept={kind==='image'?'image/jpeg,image/png,image/webp,image/gif,image/avif':kind+'/*'} onChange={e=>choose(e.target.files)}/><span className="upload-circle"><Upload size={24}/></span><strong>选择文件或拖放到这里</strong><span>最多 16 个文件,单个不超过 250 MB</span></label>
{files.length>0&&<ul className="file-list">{files.map((f,i)=><li key={i}><Check size={15}/><span>{f.name}</span><small>{(f.size/1024/1024).toFixed(1)} MB</small><button type="button" aria-label={`移除 ${f.name}`} disabled={busy} onClick={()=>setFiles(a=>a.filter((_,j)=>i!==j))}><X size={14}/></button></li>)}</ul>}
+1 -1
View File
@@ -13,5 +13,5 @@ export default function Login() {
window.location.assign(typeof next === 'string' && next.startsWith('/') && !next.startsWith('//') && !/[\\\r\n]/.test(next) && !next.startsWith('/login') ? next : '/');
} catch { setError('暂时无法连接,请稍后重试'); } finally { setBusy(false); }
}
return <><Head><title>动态码登录 · PROMPT</title><meta name="robots" content="noindex,nofollow"/></Head><main className="auth-page"><form className="auth-card" onSubmit={submit}><img className="auth-logo" src="/brand-mark.svg" width={64} height={64} alt="Prompt"/><h1>输入动态码</h1><p className="auth-description">打开验证器,输入当前的 6 位动态码。</p><label htmlFor="auth-code">动态码</label><input id="auth-code" autoFocus inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} required value={code} onChange={e=>setCode(e.target.value.replace(/\D/g,''))} placeholder="000000" aria-describedby="auth-error"/><div id="auth-error" role="alert" className="auth-error">{error}</div><button type="submit" disabled={busy || code.length !== 6}>{busy ? '正在验证…' : '进入知识库'}</button><p className="auth-footnote">尚未绑定验证器?请联系管理员。</p></form></main></>;
return <><Head><title>动态码登录 · 啡跃 Endoleap</title><meta name="robots" content="noindex,nofollow"/></Head><main className="auth-page"><form className="auth-card" onSubmit={submit}><img className="auth-logo" src="/brand/icon-dark.svg" width={64} height={64} alt="啡跃 Endoleap"/><h1>输入动态码</h1><p className="auth-description">打开验证器,输入当前的 6 位动态码。</p><label htmlFor="auth-code">动态码</label><input id="auth-code" autoFocus inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} required value={code} onChange={e=>setCode(e.target.value.replace(/\D/g,''))} placeholder="000000" aria-describedby="auth-error"/><div id="auth-error" role="alert" className="auth-error">{error}</div><button type="submit" disabled={busy || code.length !== 6}>{busy ? '正在验证…' : '进入知识库'}</button><p className="auth-footnote">尚未绑定验证器?请联系管理员。</p></form></main></>;
}
@@ -1,11 +1,11 @@
import type {GetServerSideProps} from 'next';
import {readDesign} from '../../lib/design-server';
import {designUrl} from '../../lib/native-layout';
import {readDesign} from '../../../studio/lib/design-server';
import {designUrl} from '../../../studio/lib/native-layout';
export default function DesignPage(){return null}
export const getServerSideProps:GetServerSideProps=async({params,req,res})=>{
res.setHeader('Cache-Control','private, no-store');
const design=await readDesign(params?.id,req);
if(!design)return {notFound:true};
// Next adds the configured /studio basePath to server redirects.
return {redirect:{destination:designUrl(design.layoutId,design.id).slice('/studio'.length),permanent:false}};
// The unified application has no basePath; keep the full historical URL.
return {redirect:{destination:designUrl(design.layoutId,design.id),permanent:false}};
};
@@ -0,0 +1,10 @@
import type {GetStaticPaths,GetStaticProps} from 'next';
import Head from 'next/head';
import Link from 'next/link';
import {LayoutSelection} from '../../../studio/components/LayoutSelection';
import {tutorialPost} from '../../../studio/content/tool-links';
import {TemplateTrial} from '../../../studio/components/TemplateTrial';
import {ideas,type Idea} from '../../../studio/content/ideas';
export default function IdeaPage({idea}:{idea:Idea}){if(idea.tool==='layout-selection')return <LayoutSelection/>;if(idea.template)return <TemplateTrial idea={idea}/>;return <main className="editor"><Head><title>{idea.title} · 创作站</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head><header><a href={tutorialPost}>← 啡跃使用教程</a><a href={'/studio/api/export/'+idea.slug}>导出内容包 ↓</a><a href={'/studio/api/export/'+idea.slug+'?format=markdown'}>Markdown ↓</a><button onClick={()=>window.print()}>打印 / PDF</button></header><h1>{idea.title}</h1><p>{idea.summary}</p><div className="slides">{idea.slides.map((s,i)=><article className="slide" key={i} style={{background:s.color}}><small>ENDOLEAP / {String(i+1).padStart(2,'0')}</small><h2>{s.heading}</h2><p>{s.body}</p><footer>记录灵感 · 自由表达</footer></article>)}</div></main>}
export const getStaticPaths:GetStaticPaths=async()=>({paths:ideas.map(i=>({params:{slug:i.slug}})),fallback:false});
export const getStaticProps:GetStaticProps=async({params})=>{const idea=ideas.find(i=>i.slug===params?.slug);return idea?{props:{idea}}:{notFound:true}};
@@ -1,11 +1,11 @@
import type {GetServerSideProps} from 'next';
import {NativeResult} from '../../components/NativeResult';
import type {Layout} from '../../lib/layout-parameters';
import fixedLayouts from '../../content/templates/layouts.json';
import standardLayout from '../../content/standard-layout.json';
import {NativeResult} from '../../../studio/components/NativeResult';
import type {Layout} from '../../../studio/lib/layout-parameters';
import fixedLayouts from '../../../studio/content/templates/layouts.json';
import standardLayout from '../../../studio/content/standard-layout.json';
const layouts=[...fixedLayouts,standardLayout];
import {nativeLayout,designUrl} from '../../lib/native-layout';
import {readDesign} from '../../lib/design-server';
import {nativeLayout,designUrl} from '../../../studio/lib/native-layout';
import {readDesign} from '../../../studio/lib/design-server';
export default function LayoutPage({layout}:{layout:Layout}){return <NativeResult layout={layout} design={null}/>}
export const getServerSideProps:GetServerSideProps<{layout:Layout}>=async({params,query,req,res})=>{
res.setHeader('Cache-Control','private, no-store');
@@ -1,11 +1,11 @@
import type {GetServerSideProps} from 'next';
import {NativeSeries} from '../components/NativeSeries';
import {NativeResult} from '../components/NativeResult';
import fixedLayouts from '../content/templates/layouts.json';
import standardLayout from '../content/standard-layout.json';
import {readDesign} from '../lib/design-server';
import {nativeLayout,type Design} from '../lib/native-layout';
import type {Layout} from '../lib/layout-parameters';
import {NativeSeries} from '../../studio/components/NativeSeries';
import {NativeResult} from '../../studio/components/NativeResult';
import fixedLayouts from '../../studio/content/templates/layouts.json';
import standardLayout from '../../studio/content/standard-layout.json';
import {readDesign} from '../../studio/lib/design-server';
import {nativeLayout,type Design} from '../../studio/lib/native-layout';
import type {Layout} from '../../studio/lib/layout-parameters';
type Props={layout:Layout;design:Design;page:number|null;quality:boolean};
export default function TemplateLayout(props:Props){return props.design.document?<NativeSeries design={props.design} page={props.page} quality={props.quality}/>:<NativeResult {...props}/>}
@@ -16,5 +16,5 @@ export default function Doodle(){
const [selected,setSelected]=useState(0);const [color,setColor]=useState('#fffaf0');const [copied,setCopied]=useState(false);
const svg=()=>`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 128 128"><path d="${strokes[selected].path}" fill="none" stroke="${color}" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
function download(){const url=URL.createObjectURL(new Blob([svg()],{type:'image/svg+xml'}));const a=document.createElement('a');a.href=url;a.download=`prompt-doodle-${selected+1}.svg`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
return <Shell><Head><title>线条灵感 · PROMPT</title></Head><header className="page-header"><div><span className="eyebrow">SMALL LINES, LITTLE STORIES</span><h1>线条工具</h1><p>选择图形、调整颜色,下载后用于照片涂鸦。</p></div><span className="pill"><Sparkles size={14}/>照片涂鸦工具箱</span></header><div className="doodle-layout"><section className="doodle-catalog">{strokes.map((s,i)=><button key={s.name} onClick={()=>{setSelected(i);setCopied(false);}} className={`doodle-card ${i===selected?'selected':''}`}><div><svg viewBox="0 0 128 128"><path d={s.path} fill="none" stroke={color} strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round"/></svg></div><strong>{s.name}</strong><small>{s.hint}</small></button>)}</section><aside className="surface doodle-inspector"><span className="eyebrow">A LITTLE MARK</span><div className="doodle-large"><svg viewBox="0 0 128 128"><path d={strokes[selected].path} fill="none" stroke={color} strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round"/></svg></div><h2>{strokes[selected].name}</h2><p>{strokes[selected].hint}</p><div className="swatches">{['#fffaf0','#332c28','#ef6954','#ecc75b'].map(c=><button aria-label={`线条颜色 ${c}`} key={c} style={{background:c}} className={c===color?'selected':''} onClick={()=>setColor(c)}/>)}</div><button className="primary-button" onClick={download}><Download size={16}/>下载透明底 SVG</button><button className="secondary-button" onClick={async()=>{await navigator.clipboard.writeText(svg());setCopied(true);}}><Copy size={16}/>{copied?'已复制':'复制线条'}</button><p className="form-note">小图用来挑选造型与记录想法。正式画进照片时,位置、比例与笔触仍需要顺着画面调整。</p></aside></div></Shell>;
return <Shell><Head><title>线条灵感 · 啡跃 Endoleap</title></Head><header className="page-header"><div><span className="eyebrow">SMALL LINES, LITTLE STORIES</span><h1>线条工具</h1><p>选择图形、调整颜色,下载后用于照片涂鸦。</p></div><span className="pill"><Sparkles size={14}/>照片涂鸦工具箱</span></header><div className="doodle-layout"><section className="doodle-catalog">{strokes.map((s,i)=><button key={s.name} onClick={()=>{setSelected(i);setCopied(false);}} className={`doodle-card ${i===selected?'selected':''}`}><div><svg viewBox="0 0 128 128"><path d={s.path} fill="none" stroke={color} strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round"/></svg></div><strong>{s.name}</strong><small>{s.hint}</small></button>)}</section><aside className="surface doodle-inspector"><span className="eyebrow">A LITTLE MARK</span><div className="doodle-large"><svg viewBox="0 0 128 128"><path d={strokes[selected].path} fill="none" stroke={color} strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round"/></svg></div><h2>{strokes[selected].name}</h2><p>{strokes[selected].hint}</p><div className="swatches">{['#fffaf0','#332c28','#ef6954','#ecc75b'].map(c=><button aria-label={`线条颜色 ${c}`} key={c} style={{background:c}} className={c===color?'selected':''} onClick={()=>setColor(c)}/>)}</div><button className="primary-button" onClick={download}><Download size={16}/>下载透明底 SVG</button><button className="secondary-button" onClick={async()=>{await navigator.clipboard.writeText(svg());setCopied(true);}}><Copy size={16}/>{copied?'已复制':'复制线条'}</button><p className="form-note">小图用来挑选造型与记录想法。正式画进照片时,位置、比例与笔触仍需要顺着画面调整。</p></aside></div></Shell>;
}
+3 -1
View File
@@ -1,5 +1,7 @@
import { ThemeToggle } from "../components/ThemeToggle";
import { Brand } from "../components/Brand";
import Head from 'next/head';
import {useEffect,useState} from 'react';
import {ArrowUpRight, Layers, Workflow, BookOpen, Link2} from 'lucide-react';
import s from '@/styles/workspace.module.css';
export default function Workspace(){const [email,setEmail]=useState(''),[state,setState]=useState('loading');useEffect(()=>{fetch('/api/account/session').then(async r=>{if(!r.ok)throw Error();const d=await r.json();if(!d.user){window.location.replace('/account?next=/workspace');return}setEmail(d.user.email);setState('ready')}).catch(()=>setState('error'))},[]);return <main className={s.app}><Head><title>工作台 · 织页 Studio</title></Head><aside><a className={s.brand} href="/">✳ 织页 <small>STUDIO</small></a><span>你的工作空间</span><a aria-current="page" href="/workspace">◈ 创作概览</a><a href="/graphic"><Layers size={16}/>图形设计</a><a href="/infinite_canvas"><Workflow size={16}/>无限画布</a><a href="/documents"><BookOpen size={16}/>知识与教程</a><a href="/studio/api"><Link2 size={16}/>Agent 接入文档</a><a className={s.account} href="/account">{email||'账户设置'} ↗</a></aside><section>{state==='loading'?<p role="status">正在打开你的工作台…</p>:state==='error'?<p role="alert">暂时无法读取账户。<a href="/account?next=/workspace">重新登录</a></p>:<><header><span>WORKSPACE / OVERVIEW</span><a href="/account">账户设置 ↗</a></header><p className={s.kicker}>MAKE SOMETHING THAT MATTERS.</p><h1>今天,让哪个想法成形?</h1><p className={s.sub}>选一个空间,开始你的下一次创作。</p><div className={s.grid}><a href="/graphic"><Layers/><small>GRAPHIC</small><h2>图形设计</h2><p>在文字、图形与图像之间,找到你的表达。</p><div className={s.poster}>Ideas,<br/><i>in shape.</i><b>✳</b></div><strong>打开编辑器 <ArrowUpRight size={18}/></strong></a><a href="/infinite_canvas"><Workflow/><small>INFINITE CANVAS</small><h2>无限画布</h2><p>收集灵感、连接参考,让思路自由生长。</p><div className={s.canvas}><span>灵感</span>⟶<span>参考</span>⟶<span>作品</span></div><strong>打开画布 <ArrowUpRight size={18}/></strong></a></div><div className={s.agent}><Link2/><div><h3>带上你的 coding agent</h3><p>从接入文档开始,使用已有的创作 API。连接由你的 Agent 发起。</p></div><a href="/studio/api">查看文档 ↗</a></div><p className={s.hint}>编辑器草稿保存在当前浏览器。更换设备前,请先导出项目备份。</p></>}</section></main>}
export default function Workspace(){const [email,setEmail]=useState(''),[state,setState]=useState('loading');useEffect(()=>{fetch('/api/account/session').then(async r=>{if(!r.ok)throw Error();const d=await r.json();if(!d.user){window.location.replace('/account?next=/workspace');return}setEmail(d.user.email);setState('ready')}).catch(()=>setState('error'))},[]);return <main className={s.app}><Head><title>工作台 · 啡跃 Studio</title></Head><aside><a className={s.brand} href="/"><Brand/></a><span>你的工作空间</span><a aria-current="page" href="/workspace">◈ 创作概览</a><a href="/graphic"><Layers size={16}/>图形设计</a><a href="/infinite_canvas"><Workflow size={16}/>无限画布</a><a href="/documents"><BookOpen size={16}/>知识与教程</a><a href="/studio/api"><Link2 size={16}/>Agent 接入文档</a><a className={s.account} href="/account">{email||'账户设置'} ↗</a></aside><section>{state==='loading'?<p role="status">正在打开你的工作台…</p>:state==='error'?<p role="alert">暂时无法读取账户。<a href="/account?next=/workspace">重新登录</a></p>:<><header><span>WORKSPACE / OVERVIEW</span><ThemeToggle/><a href="/account">账户设置 ↗</a></header><p className={s.kicker}>MAKE SOMETHING THAT MATTERS.</p><h1>今天,让哪个想法成形?</h1><p className={s.sub}>选一个空间,开始你的下一次创作。</p><div className={s.grid}><a href="/graphic"><Layers/><small>GRAPHIC</small><h2>图形设计</h2><p>在文字、图形与图像之间,找到你的表达。</p><div className={s.poster}>Ideas,<br/><i>in shape.</i><b>✳</b></div><strong>打开编辑器 <ArrowUpRight size={18}/></strong></a><a href="/infinite_canvas"><Workflow/><small>INFINITE CANVAS</small><h2>无限画布</h2><p>收集灵感、连接参考,让思路自由生长。</p><div className={s.canvas}><span>灵感</span>⟶<span>参考</span>⟶<span>作品</span></div><strong>打开画布 <ArrowUpRight size={18}/></strong></a></div><div className={s.agent}><Link2/><div><h3>带上你的 coding agent</h3><p>从接入文档开始,使用已有的创作 API。连接由你的 Agent 发起。</p></div><a href="/studio/api">查看文档 ↗</a></div><p className={s.hint}>编辑器草稿保存在当前浏览器。更换设备前,请先导出项目备份。</p></>}</section></main>}
+2
View File
@@ -2,6 +2,8 @@ import { NextResponse, type NextRequest } from 'next/server';
import { authConfig, SESSION_COOKIE, validSession } from './lib/auth';
export function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
const publicBrandAssets = ['/favicon.ico','/apple-touch-icon.png','/brand/logo-light.svg','/brand/logo-dark.svg','/brand/icon-dark.svg','/studio/favicon.ico','/studio/apple-touch-icon.png','/studio/brand/logo-light.svg','/studio/brand/logo-dark.svg','/studio/brand/icon-dark.svg'];
if (['GET','HEAD'].includes(request.method) && publicBrandAssets.includes(path)) return NextResponse.next({headers:{'Referrer-Policy':'same-origin'}});
const responseHeaders = {'Cache-Control':'private, no-store', 'Referrer-Policy':'same-origin'};
// Public knowledge pages are read-only. Account handlers enforce their own origin and session checks.
const publicRead = ['GET','HEAD'].includes(request.method) && (path === '/' || ['/workspace','/graphic','/infinite_canvas','/studio','/studio/graphic','/studio/infinite_canvas','/studio/api'].includes(path) || path.startsWith('/studio/_next/') || path === '/protocol' || ['/llms.txt','/index.md','/agent','/agent/index.md','/api/agent-index','/api/guides'].includes(path) || path.startsWith('/guide/') || path.startsWith('/api/guides/'));
@@ -0,0 +1,4 @@
/** The approved mark is copied from @endoleap/brand during the remote build. */
export function Brand({ compact = false }: { compact?: boolean }) {
return <span className="endoleap-brand"><img src="/studio/brand/logo-light.svg" className="endoleap-mark endoleap-mark-light" alt="" width={32} height={32}/><img src="/studio/brand/logo-dark.svg" className="endoleap-mark endoleap-mark-dark" alt="" width={32} height={32}/><span className="endoleap-wordmark">啡跃{!compact && <small>Endoleap</small>}</span></span>;
}
@@ -0,0 +1,14 @@
import {referencePreview} from '../lib/layout-images-client';
import {useState} from 'react';
import Head from 'next/head';
import {SourceCover,selections,type CoverValues} from './SourceCover';
import {tutorialPost} from '../content/tool-links';
export function LayoutSelection(){
const [active,setActive]=useState(0),[values,setValues]=useState<CoverValues[]>(selections.map(l=>({fields:Object.fromEntries(Object.entries(l.fields).filter(([,v])=>v!==undefined)) as Record<string,string>,color:'#83918a',accent:'#c3cec5'}))),[guides,setGuides]=useState(false),[status,setStatus]=useState('');
const layout=selections[active],current=values[active];
function update(patch:Partial<CoverValues>){setValues(old=>old.map((v,i)=>i===active?{...v,...patch}:v));}
async function download(format:'svg'|'json'){
try{setStatus('正在生成…');const r=await fetch('/studio/api/export/layout-selection?format='+format,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({layoutId:layout.id,...current})});if(!r.ok)throw Error();const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=layout.id+'.'+format;a.click();setTimeout(()=>URL.revokeObjectURL(url),500);setStatus('已导出当前模板。')}catch{setStatus('导出失败,请重试。')}
}
return <main className="template-trial"><Head><title>三种排版 · 啡跃 Endoleap 工具</title></Head><header className="trial-nav"><a href={tutorialPost}>← 啡跃使用教程</a><span>STUDIO / LAYOUT SELECTION</span></header><section className="trial-intro"><div><small>REF-01 / REF-07 / REF-26</small><h1>三个构图,各有位置。</h1><p>切换真实原图对应的模板,分别试文字与配色。图位与装饰按教程构件重建为干净矢量;当前只放占位内容。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><h2>选择与编辑</h2><label>排版<select value={active} onChange={e=>{setActive(Number(e.target.value));setStatus('')}}>{selections.map((l,i)=><option key={l.id} value={i}>{l.id} · {l.name}</option>)}</select></label>{layout.slots.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<textarea rows={current.fields[s.id]?.includes('\n')?3:1} maxLength={120} value={current.fields[s.id]||''} onChange={e=>update({fields:{...current.fields,[s.id]:e.target.value}})}/></label>)}<div className="trial-colors"><label>主图色<input type="color" value={current.color} onChange={e=>update({color:e.target.value})}/></label><label>副图色<input type="color" value={current.accent} onChange={e=>update({accent:e.target.value})}/></label></div><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示构件边界</label><div className="trial-actions"><button className="trial-primary" onClick={()=>void download('svg')}>下载当前 SVG</button><button onClick={()=>void download('json')}>下载参数 JSON</button></div><p className="trial-help">每张模板单独保留本页修改,刷新恢复初始值。下载 SVG 为无边界线的正式图;原始低分辨率图层在 JSON 中保留作对照。</p><p role="status">{status}</p></aside><div><div className="selection-preview"><SourceCover layout={layout} values={current} guides={guides}/></div><div className="selection-thumbs">{selections.map((l,i)=><button key={l.id} aria-pressed={active===i} onClick={()=>setActive(i)}><SourceCover layout={l} values={values[i]}/><span>{l.id}</span></button>)}</div><details className="trial-reference"><summary>原图与构件坐标</summary><div className="trial-reference-body"><img src={referencePreview(layout.referenceUrl)} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>阅读这张原图的教程 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>x,y,w,h</th></tr></thead><tbody>{layout.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
}
@@ -1,3 +1,5 @@
import { ThemeToggle } from "./ThemeToggle";
import { Brand } from "./Brand";
import {CurvedCopy} from './NativeCurvedCopy';
import {useEffect,useRef,useState,type CSSProperties,type KeyboardEvent,type PointerEvent} from 'react';
import {ArrowLeft,MousePointer2,Type,Square,Circle,ImagePlus,Layers,SlidersHorizontal,Undo2,Redo2,Copy,Trash2,Eye,EyeOff,Lock,Unlock,ChevronUp,ChevronDown,Minus,Plus,Scan,Save} from 'lucide-react';
@@ -55,7 +57,7 @@ export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){
const selectedColor=slot?(slot.kind==='text'?params.texts[slot.id]?.color||textColor(layout.id,slot):slot.kind==='image'?params.images[slot.id]?.color||String(imageShape(layout,slot).backgroundColor):params.decorations[slot.id]?.color||'#83908a'):'';
return <main className={'native-layout-page'+(editing?' is-editing':'')}>
<Head><title>{name} · Prompt</title><meta name="description" content={layout.name+' · 可直接编辑的排版页面'}/></Head>
{editing&&<header className="workbench-top"><a href="/explore/8eb8c76500580f7a4b56ba3c" aria-label="返回目录"><ArrowLeft size={18}/></a><span className="studio-mark">P<span>STUDIO</span></span><input aria-label="页面名称" value={name} maxLength={120} onChange={e=>{setName(e.target.value);setDirty(true)}}/><span className="save-indicator">{busy?'正在处理…':dirty?'有未保存的修改':design?'已保存':'尚未保存'}</span><button title="撤销 ⌘Z" disabled={!history.length||busy} onClick={undo}><Undo2 size={17}/></button><button title="重做 ⇧⌘Z" disabled={!future.length||busy} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>{setEditing(false);setTextEditing(null);setSelected(null)}}>查看页面</button><button className="primary" disabled={busy||!name.trim()} onClick={()=>void save()}><Save size={15}/>保存</button></header>}
{editing&&<header className="workbench-top"><a href="/explore/8eb8c76500580f7a4b56ba3c" aria-label="返回目录"><ArrowLeft size={18}/></a><a href="/workspace" className="studio-mark"><Brand compact/></a><ThemeToggle/><input aria-label="页面名称" value={name} maxLength={120} onChange={e=>{setName(e.target.value);setDirty(true)}}/><span className="save-indicator">{busy?'正在处理…':dirty?'有未保存的修改':design?'已保存':'尚未保存'}</span><button title="撤销 ⌘Z" disabled={!history.length||busy} onClick={undo}><Undo2 size={17}/></button><button title="重做 ⇧⌘Z" disabled={!future.length||busy} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>{setEditing(false);setTextEditing(null);setSelected(null)}}>查看页面</button><button className="primary" disabled={busy||!name.trim()} onClick={()=>void save()}><Save size={15}/>保存</button></header>}
{editing&&<aside className={'workbench-layers '+(panel==='layers'?'mobile-open':'')} inert={busy}><div className="panel-heading"><Layers size={16}/><strong>图层与素材</strong><span>{elements.length}</span></div><div className="insert-tools"><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>add('decoration','rectangle')}><Square/>矩形</button><button onClick={()=>add('decoration','ellipse')}><Circle/>椭圆</button><button onClick={()=>add('image')}><ImagePlus/>图片</button></div><div className="layers-caption">{layout.id.toUpperCase()} <span>从上到下排列</span></div><div className="layer-list">{[...elements].sort((a,b)=>(layer(b).z??(b.kind==='text'?3:b.kind==='image'?1:2))-(layer(a).z??(a.kind==='text'?3:a.kind==='image'?1:2))).map(s=><div key={s.id} className={'layer-row '+(selected===s.id?'active':'')+(layer(s).hidden?' muted':'')}><button className="layer-select" onClick={()=>{setSelected(s.id);setTextEditing(null)}}>{s.kind==='text'?<Type size={14}/>:s.kind==='image'?<ImagePlus size={14}/>:<Square size={14}/>}<span>{s.role}</span></button><button title="显示或隐藏图层" aria-label={s.role+'显示或隐藏'} onClick={()=>updateLayer(s,{hidden:!layer(s).hidden})}>{layer(s).hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button title="锁定或解锁图层" aria-label={s.role+'锁定或解锁'} onClick={()=>updateLayer(s,{locked:!layer(s).locked})}>{layer(s).locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}</div><p className="workbench-tip">拖动移动 · 双击文字改写<br/>⌘/Ctrl D 复制 · 方向键微调<br/>Shift + 方向键移动 10 单位</p></aside>}
<div ref={viewport} className={editing?'workbench-viewport':'page-viewport'} onClick={e=>{if(e.target===e.currentTarget){setSelected(null);setTextEditing(null)}}}>
{editing&&<div className="canvas-caption">{layout.name}<span>{layout.width} × {layout.height}</span></div>}

Some files were not shown because too many files have changed in this diff Show More