Compare commits

..

1 Commits

Author SHA1 Message Date
codex-bot 558039a8de Clean up repository documentation and maintenance tests
Endoleap brand checks / verify (pull_request) Successful in 12s
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 4m46s
creative workspace review checks / verify (pull_request) Successful in 13m49s
2026-11-04 22:05:14 +08:00
382 changed files with 19227 additions and 4129 deletions
+24
View File
@@ -0,0 +1,24 @@
.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
+27
View File
@@ -0,0 +1,27 @@
name: Endoleap brand checks
on:
pull_request:
branches: [main]
paths:
- 'packages/brand/**'
- 'pnpm-workspace.yaml'
- 'package.json'
- 'AGENTS.md'
- '.gitea/workflows/brand.yml'
push:
branches: [main]
paths:
- 'packages/brand/**'
- 'pnpm-workspace.yaml'
- '.gitea/workflows/brand.yml'
concurrency:
group: brand-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ces-control
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Verify brand assets, Tailwind compilation and typed exports in Docker
run: docker build -f packages/brand/Dockerfile -t "endoleap-brand-check:${{ gitea.sha }}" .
+80
View File
@@ -0,0 +1,80 @@
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'
- 'scripts/cleanup-integration.sh'
- 'tests/test_integration_cleanup.py'
- 'scripts/cleanup-review-images.sh'
- 'tests/test_review_image_cleanup.py'
concurrency:
group: creative-workspace-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
verify-deployed-canvas:
runs-on: ces-control
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Verify production acceptance against the designated deployed server
run: |
node --check scripts/verify-studio-visual.cjs
node --check apps/studio/scripts/infinite-canvas-smoke.cjs
bash -n scripts/verify-studio-production.sh
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
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
bash -n scripts/integration.sh scripts/cleanup-integration.sh scripts/verify-studio-production.sh scripts/cleanup-review-images.sh
node --check scripts/verify-studio-visual.cjs
python3 tests/test_integration_cleanup.py
python3 tests/test_review_image_cleanup.py
node --test apps/waterfall-wiki/tests/brand-navigation.test.mjs
- 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
- name: Clean this run disposable integration resources even after cancellation
if: always()
env:
GITEA_RUN_ID: ${{ gitea.run_id }}
run: bash scripts/cleanup-integration.sh
- name: Remove this run disposable review image tags
if: always()
env:
REVIEW_SHA: ${{ gitea.sha }}
run: bash scripts/cleanup-review-images.sh
+23
View File
@@ -0,0 +1,23 @@
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 }}"
+71
View File
@@ -0,0 +1,71 @@
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'
- 'scripts/cleanup-integration.sh'
- 'tests/test_integration_cleanup.py'
- '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: |
python3 tests/test_integration_cleanup.py
bash -n scripts/integration.sh scripts/cleanup-integration.sh
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
- name: Clean this run disposable integration resources even after cancellation
if: always()
env:
GITEA_RUN_ID: ${{ gitea.run_id }}
run: bash scripts/cleanup-integration.sh
@@ -0,0 +1,19 @@
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
+3 -5
View File
@@ -1,6 +1,4 @@
node_modules/
.pnpm-store/
.turbo/
.next/
coverage/
.env*
@@ -27,6 +25,6 @@ __pycache__/
/apps/studio/public/brand/
/apps/studio/public/favicon.ico
/apps/studio/public/apple-touch-icon.png
/apps/dashboard/public/brand/
/apps/dashboard/public/favicon.ico
/apps/dashboard/public/apple-touch-icon.png
/apps/waterfall-wiki/public/brand/
/apps/waterfall-wiki/public/favicon.ico
/apps/waterfall-wiki/public/apple-touch-icon.png
+8 -9
View File
@@ -8,15 +8,14 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
<!-- END:nextjs-agent-rules -->
## 项目维护
## 构建与验收
- Wiki 和 Studio 共用 `apps/dashboard` 的 Pages Router、登录保护和品牌包。保留已发布的路由、媒体标识和数据库迁移。
- 在 ces-control 上执行构建、产物生成和需要编译的检查。本地可执行差异审查及无需构建的轻量检查。
- 页面改动经主线部署后,在指定的 [服务器入口](http://8.211.154.134:18131/) 验收。不要启动本地网页预览。
- CI 触发条件与发布路径见 [构建与入口](docs/ci-and-entrypoints.md)。
## 开发分支与集成
## 品牌
- `dev` 是日常开发成果的集成分支。开始新任务前检查工作区和远程状态,默认从最新 `origin/dev` 创建或复用对应任务分支,避免夹带其他任务的未完成改动。
- 每项任务完成并通过适当检查后,提交并推送任务分支,再将该任务合并到 `dev` 并推送;用户已持续授权这类向 `dev` 的合并与推送,无需逐次确认。
- 合并前检查完整差异和待合入提交;保留 `dev` 已有工作,冲突解决后重新执行受影响的检查。不强推或重写共享历史,不擅自删除任务分支。
- `main` 保持人工集成:未经用户针对本次操作明确授权,不直接推送 `main`,也不代为合并面向 `main` 的 PR。需要发布到主线时,准备 `dev` 到 `main` 的可评审 PR,等待用户手动合并。
- 当前不使用 CI/CD,不因合并或推送 `dev` 自动恢复旧流水线、远程预览或部署安排。完整构建与运行验证是否执行及其环境,按用户另行授权处理。
- 交付报告说明任务分支、提交、检查结果以及是否已合并并推送 `dev`;区分 `dev` 集成与 `main` 合并、部署。
- 修改品牌色、主题或 Logo 前,查阅 [共享品牌包](packages/brand/README.md)。
- 使用 home-light、home-dark、canvas-light、canvas-dark 四套主题。画布背景保持中性近白或近黑,用户素材保持原色。
- 色板和 Logo 轮廓分别以品牌包的 `tokens/palette.json` 和 `tokens/geometry.json` 为准,更新时保持 CSS、类型、资产和清单一致。
+41
View File
@@ -0,0 +1,41 @@
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
WORKDIR /repo/apps/waterfall-wiki
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 /repo/pnpm-workspace.yaml
COPY packages/brand/ /repo/packages/brand/
COPY apps/waterfall-wiki/package.json ./
RUN pnpm --filter @prompt/waterfall-wiki install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
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
WORKDIR /app/apps/waterfall-wiki
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 /repo/apps/waterfall-wiki/.next/standalone/ /app/
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/dist/worker.cjs /repo/apps/waterfall-wiki/dist/bootstrap.cjs /repo/apps/waterfall-wiki/dist/publish-learning-series.cjs /repo/apps/waterfall-wiki/dist/auth-cli.cjs ./
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/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"]
+28 -30
View File
@@ -1,44 +1,42 @@
# 啡跃 Endoleap
啡跃把笔记、参考素材和创作工作台放在同一个 Next.js 应用里。应用位于 `apps/dashboard`,共享主题与品牌资源位于 `packages/brand`。
啡跃把笔记、参考素材和创作工作台放在同一个网站。知识库用于浏览、搜索和发布内容,Studio 提供图文排版与无限画布。
[打开啡跃](http://8.211.154.134:18131/)
| 入口 | 用途 |
| --- | --- |
| `/` | 浏览内容,打开 `/explore/<id>` 查看笔记 |
| `/` | 浏览内容,打开 `/explore/<id>` 查看单篇笔记 |
| `/guide` | 浏览指导文档 |
| `/graphic` | 图文排版 |
| `/infinite_canvas` | 无限画布 |
| `/api?agent_mode=True` | 业务 API 目录 |
| `/studio/api` | Studio API 目录 |
| `/graphic` | 打开图文工作台 |
| `/infinite_canvas` | 打开无限画布 |
| `/api?agent_mode=True` | 查看业务 API 目录 |
## 运行
自动化客户端可以在支持的页面或业务接口上加 `?agent_mode=True` 获取纯文本。读写权限与认证要求见对应接口说明。
准备 Node.js 24、pnpm 11.25.0、PostgreSQL、S3 兼容对象存储和 FFmpeg。参考 [环境变量示例](apps/dashboard/.env.example),将变量注入 Web、初始化和媒体 worker 进程。邮箱注册与找回密码需要可用的 SMTP 服务。
## 仓库结构
在仓库根目录运行 `pnpm install --frozen-lockfile` 和 `pnpm build`。然后在 `apps/dashboard` 目录准备数据库和存储,初始化管理员认证并启动 Web。
| 路径 | 职责 |
| --- | --- |
| `apps/waterfall-wiki` | Next.js Pages Router 主站、账户、内容和媒体 API,以及后台媒体处理 |
| `apps/studio` | Next.js Pages Router 创作工作台及渲染 API,由主站提供统一入口 |
| `apps/python` | 按需运行的 PNG 和视频导出工具 |
| `packages/brand` | 共享主题、品牌资产及接入规范 |
| `content` | 排版源数据、发布教程和示例,供作者工具与契约检查读取 |
| `scripts` | 构建部署、运维与内容发布工具 |
| `tests` | 跨应用的仓库媒体与运维检查。应用自身的测试位于各自目录 |
```sh
node dist/bootstrap.cjs
node dist/auth-cli.cjs init
pnpm start
```
## 验证与发布
媒体 worker 在同一目录作为独立进程运行 `node dist/worker.cjs`。绑定管理员验证器时,在远程交互终端执行 `node dist/auth-cli.cjs qr`。已有实例复用原数据库、对象存储和私有认证文件。
JavaScript 工作区使用 Node.js 24 和 pnpm 11.25.0。生产服务由 Docker Compose 编排,数据保存在 PostgreSQL 和 S3 兼容对象存储中。
## Monorepo 与 Turborepo
需要编译或生成产物的验证在 ces-control 执行。发布工作流由匹配路径的 `main` 推送触发。页面验收使用部署后的指定服务器,详见 [AGENTS.md](AGENTS.md) 和 [构建与发布说明](docs/ci-and-entrypoints.md)。
仓库在现有 pnpm workspace 上接入 Turborepo,无需运行面向新项目的 `npx create-turbo@latest`。应用仍位于 `apps/dashboard`,源码品牌包仍位于 `packages/brand`;子包脚本和应用产物路径保持原样。依赖统一锁定在根目录 `pnpm-lock.yaml`,安装从仓库根目录执行。
## 维护文档
根目录命令由 Turbo 调度:`pnpm dev` 启动开发任务,`pnpm dev:dashboard` 仅启动 Wiki,`pnpm build` 构建应用及 worker/CLI,`pnpm typecheck` 生成 Next.js 类型并检查 TypeScript。`pnpm start` 继续直接启动现有应用。也可以在子包目录使用原有命令。
锁文件合并保留应用原有依赖解析。唯一重叠差异是 `@tailwindcss/postcss` 的间接 PostCSS 依赖:品牌包原为 8.5.32,现在沿用应用的 8.5.31;品牌包直接声明的 PostCSS 8.5.6 不变。
迁移阶段所有任务禁用缓存,根命令使用 `--env-mode=loose` 透传既有环境变量,保留品牌资源准备步骤的每次执行。后续启用缓存前,需明确环境变量输入、跨包资源和所有输出。当前不配置 CI/CD,也不自动发布。
仅检查任务计划、不执行编译或生成应用产物时,可运行 `pnpm build --dry=json` 和 `pnpm typecheck --dry=json`。完整构建与运行验证另行执行。
## 开发流程
`dev` 集成已完成的任务。新任务默认从最新 `origin/dev` 创建独立分支,检查完成后提交并推送任务分支,再合并并推送 `dev`。`main` 仍通过 PR 由用户手动合并;推送 `dev` 不代表已发布或部署。当前不使用 CI/CD。
本次同步保留原 `dev` 的双侧栏工作区、画布子集与近期编辑记录、工作区创建和加入 API,以及 `db/018_workspaces.sql` 迁移。已有数据库仍需在应用启动前按“运行”章节执行数据库初始化;合并代码本身不会执行迁移。认证 Cookie 名称继续支持原 `dev` 的 `ACCOUNT_COOKIE_NAME` 与 `AUTH_COOKIE_NAME` 配置,未设置时使用原默认名称。
- [业务 API](docs/api.md)与[数据模型](docs/data-model.md)
- [动态码登录与运维](docs/dynamic-code-login.md)
- [指导文档入口](docs/guidance.md)与[发布协议](docs/linked-guidance/publishing.md)
- [对象存储](docs/s3-media.md)与[镜像保留策略](docs/wiki-image-retention.md)
- [Studio 排版 API 与示例](content/studio-tools/layouts-api.md)
- [共享品牌包](packages/brand/README.md)
-18
View File
@@ -1,18 +0,0 @@
# 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
PORT=3000
AUTH_CONFIG_FILE=/path/to/private/auth.json
AUTH_COOKIE_SECURE=true
ACCOUNT_PUBLIC_ORIGIN=https://endoleap.example
ACCOUNT_TRUST_PROXY=false
# Email verification and recovery
SMTP_HOST=
SMTP_PORT=465
SMTP_USERNAME=
SMTP_FROM=
SMTP_PASSWORD=
-22
View File
@@ -1,22 +0,0 @@
BEGIN;
CREATE TABLE workspaces (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL REFERENCES account_users(id),
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 40),
purpose text NOT NULL CHECK (purpose IN ('工作与团队','个人创作','学习与研究')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE workspace_members (
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES account_users(id) ON DELETE CASCADE,
PRIMARY KEY (workspace_id, user_id)
);
CREATE INDEX workspace_members_user ON workspace_members(user_id);
CREATE TABLE workspace_invites (
digest text PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL
);
CREATE INDEX workspace_invites_workspace ON workspace_invites(workspace_id);
INSERT INTO schema_migrations(version) VALUES ('018_workspaces');
COMMIT;
@@ -1,366 +0,0 @@
import Head from 'next/head';
import {useEffect, useRef, useState, type ReactNode} from 'react';
import {ArrowLeft, ArrowUpRight, BookOpen, Bot, Check, ChevronDown, ChevronRight, Layers, LogOut, User, House, LayoutGrid, Palette, PanelLeft, Plus, Search, Settings, Sparkles, Workflow, X} from 'lucide-react';
import {Brand} from './Brand';
import {ThemeToggle} from './ThemeToggle';
import {parseCanvasDocument, type CanvasNode} from '@/lib/canvas-document';
import s from '@/styles/workspace.module.css';
const tools = [
{label: '无限画布', detail: '收集灵感,连接文字与参考素材', href: '/infinite_canvas', icon: Workflow},
{label: '图形设计', detail: '编辑文字、图像与版式', href: '/graphic', icon: Palette},
{label: '知识与教程', detail: '查找指南和创作方法', href: '/documents', icon: BookOpen},
{label: 'Agent 接入', detail: '了解如何连接创作工具与 API', href: '/studio/api', icon: Bot},
];
const purposes = [
{label: '工作与团队', detail: '整理项目和工作资料。', icon: LayoutGrid},
{label: '个人创作', detail: '收集灵感,让想法成形。', icon: House},
{label: '学习与研究', detail: '梳理资料,记录研究过程。', icon: BookOpen},
];
type Space = {id?: string; name: string; purpose: string; owner?: boolean};
const spaceKey = (space: Space) => space.id ?? space.name;
export type Subcanvas = {id: string; name: string; nodes: string[]; editedAt?: string};
const personal = {name: '个人空间', purpose: ''};
type WorkspaceShellProps = {
editor?: (subset: Subcanvas | undefined) => ReactNode;
liveNodes?: CanvasNode[];
};
export function WorkspaceShell({editor, liveNodes}: WorkspaceShellProps) {
const [email, setEmail] = useState('');
const [state, setState] = useState('loading');
const [sidebar, setSidebar] = useState(true);
const [spaces, setSpaces] = useState<Space[]>([]);
const [active, setActive] = useState(personal);
const [query, setQuery] = useState('');
const [step, setStep] = useState<'choice' | 'join' | 'purpose' | 'name' | 'invite'>('choice');
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [purpose, setPurpose] = useState('');
const [name, setName] = useState('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [savedNodes, setNodes] = useState<CanvasNode[]>([]);
const nodes = liveNodes ?? savedNodes;
const [subcanvases, setSubcanvases] = useState<Subcanvas[]>([]);
const [selectedCanvas, setSelectedCanvas] = useState(editor ? '' : 'history');
const [draftEditedAt, setDraftEditedAt] = useState<string>();
const [expanded, setExpanded] = useState(true);
const [subsetName, setSubsetName] = useState('');
const [subsetNodes, setSubsetNodes] = useState<string[]>([]);
const subsetDialog = useRef<HTMLDialogElement>(null);
const dialog = useRef<HTMLDialogElement>(null);
const switcher = useRef<HTMLDetailsElement>(null);
const search = useRef<HTMLInputElement>(null);
useEffect(() => {
setSidebar(window.matchMedia('(min-width: 761px)').matches);
const request = new AbortController();
fetch('/api/account/session', {signal: request.signal}).then(async response => {
if (!response.ok) throw Error();
const data = await response.json();
if (!data.user) {
if (editor) setState('ready');
else window.location.replace('/account?next=/workspace');
return;
}
setEmail(data.user.email);
let local: Space[] = [];
let activeKey = '';
try {
const saved = JSON.parse(localStorage.getItem('studio.workspace:' + data.user.email) || 'null');
if (Array.isArray(saved?.spaces)) local = saved.spaces.filter((space: Space) => !space.id);
activeKey = saved?.active || '';
} catch {}
let shared: Space[] = [];
try {
const response = await fetch('/api/account/workspaces', {signal: request.signal});
if (response.ok) shared = (await response.json()).spaces;
} catch {}
if (request.signal.aborted) return;
const available = [...local, ...shared];
setSpaces(available);
setActive(available.find(space => spaceKey(space) === activeKey) || personal);
setState('ready');
}).catch(() => {
if (!request.signal.aborted) setState('error');
});
return () => request.abort();
}, []);
useEffect(() => {
if (!notice) return;
const timeout = window.setTimeout(() => setNotice(''), 4500);
return () => window.clearTimeout(timeout);
}, [notice]);
function selectSpace(space: Space, next = spaces) {
localStorage.setItem('studio.workspace:' + email, JSON.stringify({spaces: next, active: spaceKey(space)}));
setSpaces(next);
setActive(space);
switcher.current?.removeAttribute('open');
}
const subsetKey = 'studio.subcanvases:' + email + ':' + spaceKey(active);
useEffect(() => {
if (!email) return;
setSelectedCanvas(editor ? '' : 'history');
setQuery('');
try {
const draft = localStorage.getItem('studio.infinite-canvas.v1');
setDraftEditedAt(localStorage.getItem('studio.infinite-canvas.v1.editedAt') || undefined);
setNodes(draft ? parseCanvasDocument(draft).cards : []);
} catch {
setNodes([]);
}
try {
const saved = JSON.parse(localStorage.getItem(subsetKey) || '[]');
const list: Subcanvas[] = Array.isArray(saved) ? saved : [];
setSubcanvases(list);
if (editor) {
const id = new URLSearchParams(window.location.search).get('subset');
if (list.some(item => item.id === id)) setSelectedCanvas(id!);
}
} catch {
setSubcanvases([]);
}
}, [email, subsetKey]);
function openSubsetDialog(edit?: Subcanvas) {
setError('');
setSubsetName(edit?.name ?? '');
setSubsetNodes(edit?.nodes ?? []);
subsetDialog.current?.showModal();
}
function saveSubset(event: React.FormEvent) {
event.preventDefault();
const name = subsetName.trim();
if (!name || !subsetNodes.length) return setError('请输入名称并选择至少一个节点。');
const id = selectedCanvas || window.crypto?.randomUUID?.() || String(Date.now());
const subset = {id, name, nodes: subsetNodes, editedAt: new Date().toISOString()};
const next = [...subcanvases.filter(item => item.id !== id), subset];
try {
localStorage.setItem(subsetKey, JSON.stringify(next));
setSubcanvases(next);
setSelectedCanvas(id);
setExpanded(true);
subsetDialog.current?.close();
} catch {
setError('浏览器无法保存子画布。');
}
}
async function signOut() {
try {
const response = await fetch('/api/account/logout', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'});
if (!response.ok) throw Error();
window.location.assign('/account?next=/workspace');
} catch {
setNotice('暂时无法退出登录,请重试。');
}
}
function openDialog() {
setStep('choice');
setCode('');
setPurpose('');
setName('');
setError('');
dialog.current?.showModal();
}
async function workspaceRequest(payload: object) {
const response = await fetch('/api/account/workspaces', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)});
const result = await response.json();
if (!response.ok) throw new Error(result.error || '空间操作失败');
return result;
}
async function submitWorkspace(event: React.FormEvent) {
event.preventDefault();
if (busy) return;
const clean = name.trim();
if (step === 'name' && !clean) return setError('请填写 Workspace 名称。');
if (step === 'join' && !code.trim()) return setError('请输入邀请码。');
setBusy(true);
setError('');
try {
const {space} = await workspaceRequest(step === 'join' ? {action:'join', code:code.trim()} : {action:'create', name:clean, purpose});
selectSpace(space, [...spaces.filter(item => spaceKey(item) !== spaceKey(space)), space]);
dialog.current?.close();
setNotice((step === 'join' ? '已加入' : '已创建') + '「' + space.name + '」');
} catch (error) {
setError(error instanceof Error ? error.message : '空间操作失败');
} finally {
setBusy(false);
}
}
async function inviteMember() {
if (busy || !active.id) return;
switcher.current?.removeAttribute('open');
setCode('');
setError('');
setStep('invite');
dialog.current?.showModal();
setBusy(true);
try {
const result = await workspaceRequest({action:'invite', workspaceId:active.id});
setCode(result.code);
} catch (error) {
setError(error instanceof Error ? error.message : '无法生成邀请码');
} finally {
setBusy(false);
}
}
const subset = subcanvases.find(item => item.id === selectedCanvas);
const visibleNodes = subset ? nodes.filter(node => subset.nodes.includes(node.id)) : nodes;
const visibleSubsets = subcanvases.filter(item => item.name.toLowerCase().includes(query.trim().toLowerCase()));
const canvasName = '无限画布';
const showHistory = selectedCanvas === 'history';
const dates = new Intl.DateTimeFormat('zh-CN', {year: 'numeric', month: 'long', day: 'numeric'});
const times = new Intl.DateTimeFormat('zh-CN', {hour: '2-digit', minute: '2-digit', hour12: false});
const entries = [
...(nodes.length || draftEditedAt ? [{id: '', name: canvasName, editedAt: draftEditedAt, summary: nodes[0]?.text || nodes[0]?.title || '本机草稿'}] : []),
...subcanvases.map(item => ({...item, summary: nodes.find(node => item.nodes.includes(node.id))?.text || '子画布'})),
].filter(item => item.name.toLowerCase().includes(query.trim().toLowerCase()));
entries.sort((a, b) => (b.editedAt ?? '').localeCompare(a.editedAt ?? ''));
const history = new Map<string, typeof entries>();
for (const entry of entries) {
const day = entry.editedAt && Number.isFinite(Date.parse(entry.editedAt)) ? dates.format(new Date(entry.editedAt)) : '编辑时间未记录';
const group = history.get(day) ?? [];
group.push(entry);
history.set(day, group);
}
return <div className={s.app}>
{!editor && <Head><title>工作台 · 啡跃</title></Head>}
<nav className={s.rail} aria-label="全局导航">
<a className={s.logo} href="/" aria-label="啡跃首页"><Brand markOnly/></a>
<button type="button" aria-label="切换工作台导航" title="切换工作台导航" aria-current="page" aria-expanded={sidebar} onClick={() => setSidebar(!sidebar)}><LayoutGrid size={18}/></button>
<a href="/infinite_canvas" aria-label="无限画布" title="无限画布"><Workflow size={18}/></a>
<button type="button" aria-label="搜索画布" title="搜索画布" disabled={state !== 'ready'} onClick={() => {setSidebar(true); requestAnimationFrame(() => search.current?.focus());}}><Search size={18}/></button>
<a href="/graphic" aria-label="图形设计" title="图形设计"><Palette size={18}/></a>
<a href="/studio/api" aria-label="Agent 接入" title="Agent 接入"><Bot size={18}/></a>
<a className={s.settings} href="/account" aria-label="账户设置" title="账户设置"><Settings size={18}/></a>
</nav>
{sidebar && <aside className={s.panel} id="workspace-navigation">
<div className={s.identity}>
<details ref={switcher} className={s.switcher}>
<summary><span className={s.avatar}>{Array.from(active.name)[0]}</span><strong>{active.name}</strong><ChevronDown size={14}/></summary>
{state === 'ready' && <div className={s.menu}>
<div className={s.menuIdentity}><span className={s.avatar}>{Array.from(active.name)[0]}</span><div><strong>{active.name}</strong><small>{active.id ? '当前空间 · 共享空间' : '当前空间 · 保存在此浏览器'}</small></div></div>
{email && <div className={s.menuSection}>
{active.owner && <button type="button" onClick={() => void inviteMember()}><Plus size={16}/>邀请成员</button>}<small>{email}</small>
{[personal, ...spaces].map(space => <button type="button" key={spaceKey(space)} aria-pressed={spaceKey(space) === spaceKey(active)} onClick={() => {
try { selectSpace(space); } catch { setNotice('浏览器无法保存空间切换。'); }
}}><span className={s.avatar}>{Array.from(space.name)[0]}</span><span>{space.name}</span>{spaceKey(space) === spaceKey(active) && <Check size={16}/>}</button>)}
<button className={s.menuAdd} type="button" onClick={() => {switcher.current?.removeAttribute('open'); openDialog();}}><Plus size={16}/>添加 Workspace</button>
</div>}
<div className={s.menuSection}><a href="/account"><User size={16}/>账户管理</a>{email && <button type="button" onClick={() => void signOut()}><LogOut size={16}/>退出登录</button>}</div>
</div>}
</details>
<a className={s.avatar} href="/account" aria-label="账户设置" title={email || '账户设置'}>{email ? email.charAt(0).toUpperCase() : <Settings size={15}/>}</a>
</div>
<div className={s.panelTitle}><strong>画布</strong><button type="button" disabled={!email || !nodes.length} onClick={() => {setSelectedCanvas(''); openSubsetDialog();}} aria-label="新建子画布" title="新建子画布"><Plus size={18}/></button></div>
<label className={s.search}><Search size={16}/><input ref={search} type="search" value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索画布…" aria-label="搜索画布名称"/></label>
{state === 'ready' && <nav className={s.links} aria-label="画布树">
<button className={s.historyLink} type="button" aria-current={showHistory ? 'page' : undefined} onClick={() => editor ? window.location.assign('/workspace') : setSelectedCanvas('history')}>编辑历史</button>
<p>本机画布</p>
<div className={s.treeRoot}>
<button type="button" aria-label="展开或折叠子画布" aria-expanded={expanded || !!query} disabled={!!query} onClick={() => setExpanded(!expanded)}>{expanded || query ? <ChevronDown size={15}/> : <ChevronRight size={15}/>}</button>
<button type="button" aria-current={!subset && !showHistory ? 'page' : undefined} onClick={() => setSelectedCanvas('')}><Layers size={16}/>{canvasName}</button>
</div>
{(expanded || query) && <ul className={s.children}>{visibleSubsets.map(item => <li key={item.id}><button type="button" aria-current={item.id === selectedCanvas ? 'page' : undefined} onClick={() => setSelectedCanvas(item.id)}><LayoutGrid size={15}/>{item.name}</button></li>)}</ul>}
{query && !visibleSubsets.length && !canvasName.includes(query.trim()) && <p role="status">没有匹配的画布。</p>}
</nav>}
<small className={s.local}>空间设置保存在此浏览器</small>
</aside>}
<main className={editor ? s.editorMain : s.main}>
{editor ? <>
<button className={s.editorToggle} type="button" aria-label="切换画布树" aria-expanded={sidebar} onClick={() => setSidebar(!sidebar)}><PanelLeft size={18}/></button>
{editor(subset)}
</> : <>
<header className={s.top}>
<div><button type="button" title="切换导航栏" aria-label="切换导航栏" aria-expanded={sidebar} aria-controls={sidebar ? 'workspace-navigation' : undefined} onClick={() => setSidebar(!sidebar)}><PanelLeft size={18}/></button><span>{active.name} / {showHistory ? '编辑历史' : subset?.name ?? canvasName}</span></div>
<ThemeToggle/>
</header>
{state === 'loading' ? <p className={s.status} role="status">正在打开工作台…</p> : state === 'error' ? <p className={s.status} role="alert">暂时无法读取账户。 <a href="/account?next=/workspace">重新登录</a></p> :
showHistory ? <section className={s.history} aria-labelledby="history-heading">
<header><div><h1 id="history-heading">编辑历史</h1><p>按最近编辑日期排列</p></div><a className={s.primary} href="/infinite_canvas">打开画布 <ArrowUpRight size={16}/></a></header>
{Array.from(history, ([day, items]) => <section className={s.historyGroup} key={day}>
<h2>{day} · {items.length}</h2>
<ul>{items.map(item => <li key={item.id}><button type="button" onClick={() => setSelectedCanvas(item.id)}>
{item.id ? <LayoutGrid size={18}/> : <Layers size={18}/>}
<span className={s.historyItem}><strong>{item.name}</strong><small>{item.summary}</small></span>
{item.editedAt && Number.isFinite(Date.parse(item.editedAt)) && <time dateTime={item.editedAt}>{times.format(new Date(item.editedAt))}</time>}
<ChevronRight size={16}/>
</button></li>)}</ul>
</section>)}
{!entries.length && <p className={s.historyEmpty}>{query ? '没有匹配的画布。' : '还没有编辑记录。打开画布开始创作,保存后就会出现在这里。'}</p>}
</section> : <div className={s.content}>
<section className={s.intro}>
<Layers size={20}/>
<h1>{subset?.name ?? canvasName}</h1>
<p>{subset ? `子画布 · ${visibleNodes.length} 个节点 · 属于无限画布` : `${active.name} · ${nodes.length} 个节点`}</p>
{subset ? <><button className={s.primary} type="button" onClick={() => openSubsetDialog(subset)}>编辑子画布</button><a className={s.openSubset} href={'/infinite_canvas?subset=' + encodeURIComponent(subset.id)}>在编辑器打开 <ArrowUpRight size={16}/></a></> : <a className={s.primary} href="/infinite_canvas">打开画布 <ArrowUpRight size={16}/></a>}
</section>
<section aria-labelledby="workspace-nodes">
<h2 id="workspace-nodes">画布内容</h2>
{visibleNodes.length ? <div className={s.tools}>{visibleNodes.map(node => <article className={s.node} key={node.id}><strong>{node.title || '未命名节点'}</strong>{node.image && <img src={node.image} alt=""/>}{node.text && <p>{node.text}</p>}</article>)}</div> : <p>{subset ? '原画布中已没有这些节点。' : '画布还是空的。添加节点后,就可以在这里创建子画布。'}</p>}
</section>
<nav className={s.shortcuts} aria-label="其他创作工具">{tools.slice(1).map(({label, href}) => <a key={href} href={href}>{label} <ArrowUpRight size={14}/></a>)}</nav>
</div>}
</>}
</main>
<dialog className={s.dialog} ref={subsetDialog} aria-labelledby="subcanvas-title">
<div className={s.dialogTop}><h2 id="subcanvas-title">{subset ? '编辑子画布' : '新建子画布'}</h2><button type="button" aria-label="关闭" onClick={() => subsetDialog.current?.close()}><X size={20}/></button></div>
<form onSubmit={saveSubset}>
<p>从无限画布选择节点。原画布会保留这些节点。</p>
<label htmlFor="subcanvas-name">子画布名称</label>
<input id="subcanvas-name" value={subsetName} maxLength={40} onChange={event => setSubsetName(event.target.value)}/>
<fieldset className={s.members}><legend>包含的节点</legend>{nodes.map(node => <label key={node.id}><input type="checkbox" checked={subsetNodes.includes(node.id)} onChange={event => setSubsetNodes(event.target.checked ? [...subsetNodes, node.id] : subsetNodes.filter(id => id !== node.id))}/>{node.title || '未命名节点'}</label>)}</fieldset>
{error && <p className={s.error} role="alert">{error}</p>}
<button className={s.primary} type="submit">保存子画布</button>
</form>
</dialog>
<dialog className={s.dialog} ref={dialog} aria-labelledby="workspace-dialog-title">
<div className={s.dialogTop}>
{step !== 'choice' && step !== 'invite' && <button type="button" aria-label="返回上一步" onClick={() => {setError(''); setStep(step === 'name' ? 'purpose' : 'choice');}}><ArrowLeft size={20}/></button>}
<h2 id="workspace-dialog-title">{{choice:'建立一个创作空间',join:'加入 Workspace',purpose:'这个空间用来做什么?',name:'给你的 Workspace 命名',invite:'邀请成员'}[step]}</h2>
<button type="button" aria-label="关闭" onClick={() => dialog.current?.close()}><X size={20}/></button>
</div>
{step === 'choice' ? <>
<p>加入已有空间,或创建自己的 Workspace。</p>
<div className={s.options}>
<button type="button" onClick={() => setStep('join')}><User size={22}/><span><strong>加入已有空间</strong><small>使用空间所有者提供的邀请码</small></span></button>
<button type="button" onClick={() => setStep('purpose')}><Plus size={22}/><span><strong>创建 Workspace</strong><small>为新项目建立一个空间</small></span></button>
</div>
</> : step === 'join' ? <form onSubmit={submitWorkspace}>
<label htmlFor="workspace-invite">邀请码</label>
<input id="workspace-invite" value={code} maxLength={80} onChange={event => setCode(event.target.value)}/>
{error && <p className={s.error} role="alert">{error}</p>}
<button className={s.primary} type="submit" disabled={busy}>加入 Workspace</button>
</form> : step === 'invite' ? <>
<p>邀请码有效期为 24 小时。把它交给要加入的成员。</p>
{busy && <p role="status">正在生成邀请码…</p>}
{error && <p className={s.error} role="alert">{error}</p>}
{code && <input aria-label="邀请码" readOnly value={code} onFocus={event => event.target.select()}/>}
</> : step === 'purpose' ? <>
<p>选择一个起点。</p>
<div className={s.options}>{purposes.map(({label, detail, icon: Icon}) => <button key={label} type="button" aria-pressed={purpose === label} onClick={() => setPurpose(label)}><Icon size={23}/><span><strong>{label}</strong><small>{detail}</small></span></button>)}</div>
<button className={s.primary} type="button" disabled={!purpose} onClick={() => setStep('name')}>继续 <ArrowUpRight size={16}/></button>
</> : <form onSubmit={submitWorkspace}>
<p>{purpose} · 一个空间,装下你的想法与作品。</p>
<div className={s.iconPreview}><span className={s.avatar}>{Array.from(name.trim())[0] || '空'}</span>空间图标</div>
<label htmlFor="workspace-name">Workspace 名称</label>
<input id="workspace-name" autoFocus value={name} maxLength={40} onChange={e => {setName(e.target.value); setError('');}} aria-invalid={!!error} aria-describedby={error ? 'workspace-error' : undefined} placeholder="输入空间名称"/>
{error && <p id="workspace-error" role="alert" className={s.error}>{error}</p>}
<button className={s.primary} type="submit" disabled={busy}>创建 Workspace <ArrowUpRight size={16}/></button>
</form>}
</dialog>
{notice && <div role="status" className={s.notice}>{notice}</div>}
</div>;
}
File diff suppressed because one or more lines are too long
-15
View File
@@ -1,15 +0,0 @@
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) {
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} />;
}
@@ -1,69 +0,0 @@
import {randomUUID} from 'node:crypto';
import type {NextApiRequest, NextApiResponse} from 'next';
import {currentAccount} from '@/lib/account';
import {newToken, tokenDigest, trustedOrigin} from '@/lib/account-crypto';
import {db} from '@/lib/db';
const purposes = new Set(['工作与团队', '个人创作', '学习与研究']);
export default async function workspaces(req: NextApiRequest, res: NextApiResponse) {
res.setHeader('Cache-Control', 'private, no-store');
if (req.method !== 'GET' && req.method !== 'POST') {
res.setHeader('Allow', 'GET, POST');
return res.status(405).end();
}
const headers = new Headers(Object.entries(req.headers).filter(([, value]) => typeof value === 'string') as [string, string][]);
try {
const origin = process.env.ACCOUNT_PUBLIC_ORIGIN || 'http://' + req.headers.host;
const request = new Request(new URL('/api/account/workspaces', origin), {headers});
if (req.method === 'POST' && !trustedOrigin(request)) return res.status(403).json({error: '请求来源不受信任'});
const user = await currentAccount(request);
if (!user) return res.status(401).json({error: '请先登录邮箱账户'});
const pool = db();
if (req.method === 'GET') {
const result = await pool.query(
`SELECT w.id, w.name, w.purpose, w.owner_id = $1 AS owner
FROM workspaces w JOIN workspace_members m ON m.workspace_id = w.id
WHERE m.user_id = $1 ORDER BY w.created_at, w.id`, [user.id]);
return res.json({spaces: result.rows});
}
const {action, name, purpose, code, workspaceId} = req.body ?? {};
if (action === 'create') {
const clean = typeof name === 'string' ? name.trim() : '';
if (!clean || clean.length > 40 || !purposes.has(purpose)) return res.status(400).json({error: '请输入有效的空间名称并选择用途'});
const id = randomUUID();
await pool.query(
`WITH created AS (INSERT INTO workspaces(id,owner_id,name,purpose) VALUES($1,$2,$3,$4) RETURNING id)
INSERT INTO workspace_members(workspace_id,user_id) SELECT id,$2 FROM created`,
[id, user.id, clean, purpose]);
return res.status(201).json({space: {id, name: clean, purpose, owner: true}});
}
if (action === 'invite') {
if (typeof workspaceId !== 'string' || !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(workspaceId)) return res.status(400).json({error: '无效的空间'});
const token = newToken();
const result = await pool.query(
`INSERT INTO workspace_invites(digest,workspace_id,expires_at)
SELECT $1,id,now()+interval '1 day' FROM workspaces WHERE id=$2 AND owner_id=$3 RETURNING expires_at`,
[tokenDigest(token), workspaceId, user.id]);
if (!result.rowCount) return res.status(404).json({error: '只有空间所有者可以邀请成员'});
return res.json({code: token});
}
if (action === 'join') {
if (typeof code !== 'string' || !/^[a-f0-9]{64}$/.test(code)) return res.status(400).json({error: '请输入有效的邀请码'});
const result = await pool.query(
`WITH target AS (
SELECT w.id,w.name,w.purpose,w.owner_id=$2 AS owner
FROM workspace_invites i JOIN workspaces w ON w.id=i.workspace_id
WHERE i.digest=$1 AND i.expires_at>now()
), joined AS (
INSERT INTO workspace_members(workspace_id,user_id) SELECT id,$2 FROM target ON CONFLICT DO NOTHING
) SELECT * FROM target`,
[tokenDigest(code), user.id]);
if (!result.rowCount) return res.status(404).json({error: '邀请不存在或已过期'});
return res.json({space: result.rows[0]});
}
return res.status(400).json({error: '未知空间操作'});
} catch {
return res.status(503).json({error: '空间服务暂时不可用,请稍后再试'});
}
}
@@ -1,14 +0,0 @@
import { readFile } from 'node:fs/promises';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (process.env.PREVIEW_MODE !== 'true') return res.status(404).end();
if (req.method !== 'GET' && req.method !== 'HEAD') return res.status(405).end();
res.setHeader('Cache-Control', 'no-store');
try {
const status = JSON.parse(await readFile('/preview-status/status.json', 'utf8'));
return req.method === 'HEAD' ? res.status(200).end() : res.status(200).json({ ...status, mode: 'development' });
} catch {
return res.status(503).json({ error: 'Preview source has not synchronized yet' });
}
}
-5
View File
@@ -1,5 +0,0 @@
import {WorkspaceShell} from '../components/WorkspaceShell';
export default function Workspace() {
return <WorkspaceShell/>;
}
@@ -1 +0,0 @@
export * from '../../lib/canvas-document.ts';
@@ -1 +0,0 @@
export * from '../../lib/layout-parameters.ts';
-1
View File
@@ -1 +0,0 @@
export * from '../../lib/series.ts';
@@ -1,111 +0,0 @@
.app { display: flex; min-height: 100dvh; background: var(--background); color: var(--foreground-strong); font-size: 14px; }
.rail, .panel { position: sticky; top: 0; height: 100dvh; border-right: 1px solid var(--border); flex-shrink: 0; }
.rail { width: 50px; display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 10px 5px; background: var(--surface-muted); }
.rail > a, .rail > button, .panelTitle button, .top button, .dialogTop button { display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border-radius: 7px; color: var(--muted-foreground); }
.rail > :hover, .links a:hover, .top button:hover, .panelTitle button:hover, .dialogTop button:hover, .menu button:hover { background: var(--row-hover); }
.rail [aria-current], .links a[aria-current], .menu button[aria-pressed=true] { background: var(--selection); color: var(--foreground-strong); }
.logo { margin-bottom: 14px; }
.logo :global(.endoleap-brand) :global(.endoleap-mark) { width: 26px; height: 26px; }
.settings { margin-top: auto; }
.panel { width: 248px; padding: 0 12px 14px; display: flex; flex-direction: column; background: var(--surface); }
.identity, .panelTitle { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.identity { min-height: 58px; border-bottom: 1px solid var(--border); }
.panelTitle { padding: 8px 4px; min-height: 50px; }
.avatar { display: grid; place-items: center; flex: 0 0 auto; width: 28px; height: 28px; border-radius: 7px; background: var(--selection); color: var(--foreground-strong); }
.switcher { min-width: 0; position: relative; flex: 1; }
.switcher summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 7px; min-height: 36px; }
.switcher summary::-webkit-details-marker { display: none; }
.switcher strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.switcher summary svg { margin-left: auto; flex-shrink: 0; }
.menu { position: absolute; top: 42px; left: 0; width: 222px; z-index: 2; padding: 6px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface); box-shadow: 0 8px 20px var(--shadow); max-height: 55dvh; overflow: auto; }
.menu button, .links a { display: flex; align-items: center; text-align: left; gap: 9px; width: 100%; min-height: 36px; padding: 6px 9px; border-radius: 6px; overflow-wrap: anywhere; }
.search { display: flex; align-items: center; gap: 8px; background: var(--surface-muted); color: var(--muted-foreground); border-radius: 7px; padding: 8px; }
.search input { min-width: 0; width: 100%; background: transparent; color: var(--foreground); border: 0; }
.search:focus-within, .dialog input:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.links { margin-top: 12px; overflow: auto; }
.links p, .local, .top, .content p, .tools small, .dialog p, .options small { color: var(--muted-foreground); }
.links p { margin: 24px 8px 5px; font-size: 12px; }
.local { margin-top: auto; padding: 12px 5px 0; border-top: 1px solid var(--border); }
.main { flex: 1; min-width: 0; background-image: radial-gradient(var(--border) .75px, transparent .75px); background-size: 24px 24px; }
.top { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin: 12px 16px; }
.top > div { display: flex; gap: 8px; align-items: center; padding: 2px 12px 2px 3px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow-wrap: anywhere; }
.content, .status { max-width: 840px; margin: clamp(30px, 7vh, 90px) auto; padding: 0 clamp(15px, 4vw, 44px); }
.intro { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: clamp(20px, 4vw, 44px); }
.intro > svg { color: var(--primary); }
.intro h1 { margin: 9px 0 14px; font-size: clamp(25px, 4vw, 42px); line-height: 1.2; overflow-wrap: anywhere; }
.intro p { margin: 8px 0; line-height: 1.55; }
.primary { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 8px 15px; border-radius: 7px; background: var(--primary); color: var(--primary-foreground); }
.primary:hover:not(:disabled) { background: var(--primary-hover); }
.intro .primary { margin-top: 15px; }
.content h2 { font-size: 16px; margin: 28px 0 12px; }
.tools { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.tools a, .options button { display: flex; align-items: center; gap: 12px; padding: 16px; text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
.tools a:hover, .options button:hover { border-color: var(--selection-border); background: var(--row-hover); }
.tools a svg, .options svg { flex-shrink: 0; }
.tools a svg:last-child { margin-left: auto; }
.tools small, .options small { display: block; margin-top: 5px; line-height: 1.5; }
.dialog { width: min(535px, calc(100% - 24px)); max-height: calc(100dvh - 24px); padding: clamp(18px, 4vw, 32px); color: var(--foreground-strong); background: var(--surface); border: 1px solid var(--border); border-radius: 16px; box-shadow: 0 18px 48px var(--shadow); }
.dialog::backdrop { background: var(--scrim); }
.dialogTop { display: flex; align-items: center; gap: 6px; }
.dialogTop h2 { flex: 1; font-size: clamp(18px, 3vw, 24px); margin: 0; }
.dialog p { line-height: 1.55; }
.options { display: grid; gap: 12px; margin: 24px 0; }
.options button { min-height: 74px; }
.options button[aria-pressed=true] { background: var(--selection); border-color: var(--primary); }
.dialog .primary { width: 100%; }
.iconPreview { display: flex; align-items: center; gap: 10px; margin: 22px 0; }
.iconPreview .avatar { width: 48px; height: 48px; font-size: 20px; }
.dialog input { width: 100%; min-height: 42px; margin: 8px 0 20px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--foreground-strong); }
.dialog input[aria-invalid=true] { border-color: var(--danger); margin-bottom: 0; }
.dialog .error { color: var(--danger); }
.notice { position: fixed; right: 16px; bottom: 16px; z-index: 4; max-width: calc(100vw - 32px); overflow-wrap: anywhere; padding: 14px 18px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); box-shadow: 0 8px 24px var(--shadow); }
@media (max-width: 760px) { .panel { position: fixed; left: 50px; width: min(248px, calc(100% - 50px)); z-index: 3; box-shadow: 8px 0 20px var(--shadow); } .rail { z-index: 4; } .top { position: sticky; top: 0; z-index: 4; margin-left: 8px; } .app:has(.panel) .top { z-index: 2; } .tools { grid-template-columns: 1fr; } }
.panel { z-index: 3; }
.menu { width: min(286px, calc(100vw - 76px)); padding: 10px; }
.menuIdentity { display: flex; gap: 10px; align-items: center; padding: 4px 4px 10px; }
.menuIdentity small, .menuSection small { display: block; color: var(--muted-foreground); overflow-wrap: anywhere; margin: 4px 0; }
.menuSection { border-top: 1px solid var(--border); padding: 6px 0; }
.menuSection a { display: flex; align-items: center; gap: 9px; padding: 8px 9px; border-radius: 6px; }
.menuSection a:hover, .treeRoot button:hover, .children button:hover { background: var(--row-hover); }
.menuSection button > svg:last-child:not(:first-child) { margin-left: auto; }
.menuSection .menuAdd { justify-content: center; background: var(--surface-muted); margin-top: 4px; }
.treeRoot { display: flex; gap: 2px; }
.treeRoot button, .children button { display: flex; align-items: center; gap: 8px; min-height: 34px; border-radius: 6px; text-align: left; overflow-wrap: anywhere; }
.treeRoot button:last-child, .children button { flex: 1; width: 100%; padding: 5px 8px; }
.treeRoot button[aria-current], .children button[aria-current] { background: var(--selection); }
.children { list-style: none; margin: 2px 0 0 21px; padding: 0 0 0 7px; border-left: 1px solid var(--border); }
.children svg, .treeRoot svg { flex-shrink: 0; }
.node { min-width: 0; padding: 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); overflow-wrap: anywhere; }
.node p { white-space: pre-wrap; }
.node img { max-height: 200px; object-fit: contain; margin-top: 8px; }
.shortcuts { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; }
.shortcuts a { display: inline-flex; align-items: center; gap: 4px; padding: 6px; }
.members { display: grid; gap: 8px; max-height: 30vh; overflow: auto; margin: 0 0 16px; border: 1px solid var(--border); }
.members label { display: flex; align-items: center; gap: 8px; overflow-wrap: anywhere; }
.members input[type=checkbox] { width: auto; min-height: 0; margin: 0; accent-color: var(--primary); }
.historyLink { width: 100%; padding: 9px; text-align: left; border-radius: 6px; }
.historyLink[aria-current] { background: var(--selection); }
.historyLink:hover { background: var(--row-hover); }
.history { margin: 12px 16px 24px; padding: clamp(16px, 3vw, 38px); min-height: calc(100dvh - 92px); background: var(--surface); border: 1px solid var(--border); border-radius: 10px; }
.history > header { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 26px; }
.history h1 { margin: 0; font-size: 23px; }
.history header p, .historyGroup h2, .historyItem small, .history time, .historyEmpty { color: var(--muted-foreground); }
.history header p { margin: 6px 0 0; }
.historyGroup { margin-top: 22px; }
.historyGroup h2 { font-size: 14px; font-weight: 500; margin: 0 0 6px; }
.historyGroup ul { list-style: none; padding: 0; margin: 0; }
.historyGroup button { display: flex; align-items: center; gap: 12px; width: 100%; padding: 10px; min-height: 52px; border-radius: 7px; text-align: left; }
.historyGroup button:hover { background: var(--row-hover); }
.historyGroup button > svg { flex-shrink: 0; color: var(--muted-foreground); }
.historyItem { flex: 1; min-width: 0; }
.historyItem strong, .historyItem small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.historyItem small { margin-top: 4px; }
.history time { font-variant-numeric: tabular-nums; }
.historyEmpty { padding: 18px 0; line-height: 1.65; }
.editorMain { position: relative; flex: 1; min-width: 0; height: 100dvh; }
.editorToggle { position: absolute; z-index: 6; top: 22px; left: 20px; display: grid; place-items: center; width: 32px; height: 34px; border-radius: 6px; color: var(--muted-foreground); }
.editorToggle:hover { background: var(--row-hover); }
.openSubset { display: inline-flex; align-items: center; gap: 6px; margin-inline-start: 12px; }
@media(max-width:760px) {
.editorToggle { top: 16px; left: 12px; }
}
+8
View File
@@ -0,0 +1,8 @@
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
@@ -0,0 +1,12 @@
[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
@@ -0,0 +1,70 @@
"""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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,23 @@
FROM node:24-bookworm-slim AS build
WORKDIR /repo/apps/studio
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/studio/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
COPY packages/brand/ /repo/packages/brand/
COPY apps/studio/package.json ./
RUN pnpm --filter @prompt/studio install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
COPY apps/studio/ .
# Compare the two public parameter validators inside the isolated CI build.
COPY apps/waterfall-wiki/src/lib/layout-parameters.ts /repo/apps/waterfall-wiki/src/lib/layout-parameters.ts
COPY apps/waterfall-wiki/src/lib/series.ts /repo/apps/waterfall-wiki/src/lib/series.ts
COPY apps/waterfall-wiki/src/lib/canvas-document.ts /repo/apps/waterfall-wiki/src/lib/canvas-document.ts
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
FROM node:24-bookworm-slim
WORKDIR /app/apps/studio
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
COPY --from=build --chown=node:node /repo/apps/studio/.next/standalone/ /app/
COPY --from=build --chown=node:node /repo/apps/studio/.next/static/ ./.next/static/
COPY --from=build --chown=node:node /repo/apps/studio/public/ ./public/
USER node
EXPOSE 3000
CMD ["node", "server.js"]
+4
View File
@@ -0,0 +1,4 @@
import { resolve } from "node:path";
import type { NextConfig } from "next";
const config: NextConfig = { turbopack: { root: resolve(process.cwd(), "../..") }, basePath: "/studio", output: "standalone", outputFileTracingRoot: resolve(process.cwd(), "../.."), poweredByHeader: false };
export default config;
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@prompt/studio",
"private": true,
"scripts": {
"dev": "pnpm brand:prepare && next dev -p 3001",
"build": "pnpm brand:prepare && next build",
"start": "next start",
"typecheck": "next typegen && tsc --noEmit",
"test": "node --test tests/*.test.mjs",
"test:smoke": "node scripts/smoke.mjs",
"brand:prepare": "node ../../packages/brand/scripts/prepare-app.mjs"
},
"dependencies": {
"next": "16.4.2",
"react": "19.3.1",
"react-dom": "19.3.1",
"lucide-react": "1.61.0",
"@endoleap/brand": "workspace:*"
},
"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"
}
+1033
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
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});
@@ -0,0 +1,128 @@
// 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});
let page;
try{
const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined});
page=await context.newPage();const 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 capture=async name=>{if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/'+name+'.png'})}};
async function unobstructed(id){const box=await node(id).boundingBox();assert.ok(box);for(const card of (await snapshot()).cards.filter(n=>n.id!==id)){const other=await node(card.id).boundingBox();assert.ok(other);assert.equal(box.x<other.x+other.width&&box.x+box.width>other.x&&box.y<other.y+other.height&&box.y+box.height>other.y,false,`${id} overlaps ${card.id}`)}}
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()}
const canvasResponse=await page.goto(base+'/infinite_canvas',{waitUntil:'commit',timeout:60000});
assert.ok(canvasResponse?.ok(),'Canvas document returns HTTP success');
await page.locator('button[data-theme-ready="true"]').waitFor();
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 node('brief').getByRole('button',{name:'斜体',exact:true}).click();
await node('brief').getByLabel('文字颜色',{exact:true}).fill('#78905e');
await node('brief').getByRole('combobox',{name:'文字样式',exact:true}).selectOption('24');
await page.waitForFunction(()=>{const n=JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='brief');return n.style.bold&&n.style.italic&&n.style.color==='#78905e'&&n.style.size===24});
assert.deepEqual(await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).evaluate(el=>{const s=getComputedStyle(el);return {size:s.fontSize,weight:s.fontWeight,style:s.fontStyle,color:s.color}}),{size:'24px',weight:'700',style:'italic',color:'rgb(120, 144, 94)'});
const compact=(await snapshot()).cards.find(n=>n.id==='brief');
await node('brief').getByRole('button',{name:'展开文本节点',exact:true}).click();
const expanded=(await snapshot()).cards.find(n=>n.id==='brief');assert.ok(expanded.width>(compact.width||320)&&expanded.height>(compact.height||350));
await capture('canvas-text-format-desktop');
await page.getByRole('button',{name:'撤销',exact:true}).click();
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,compact.width);assert.equal((await snapshot()).cards.find(n=>n.id==='brief').height,compact.height);
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 capture('canvas-composer-desktop');
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);
await unobstructed(upscale.id);
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 unobstructed(script.id);
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({waitUntil:'commit',timeout:60000});await page.locator('button[data-theme-ready="true"]').waitFor();await page.waitForFunction(()=>!!localStorage.getItem('studio.infinite-canvas.v1'));assert.deepEqual(await snapshot(),saved);
const themeToggle=page.getByRole('button',{name:'切换明暗主题',exact:true});
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
const initialMode=await page.evaluate(()=>document.documentElement.dataset.mode);
assert.ok(['light','dark'].includes(initialMode));
for(const mode of [initialMode==='light'?'dark':'light',initialMode]){
await themeToggle.click();
const matchesTheme=mode=>{
const root=document.documentElement,canvas=document.querySelector('main');
return root.dataset.mode===mode&&root.dataset.theme===`canvas-${mode}`&&localStorage.getItem('endoleap.theme')===mode&&getComputedStyle(canvas).backgroundColor===(mode==='dark'?'rgb(20, 20, 20)':'rgb(250, 250, 250)');
};
await page.waitForFunction(matchesTheme,mode);
await page.reload({waitUntil:'commit'});
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
await page.waitForFunction(matchesTheme,mode);
assert.deepEqual(await snapshot(),saved,'Theme changes must preserve canvas content');
await capture('canvas-theme-'+mode);
}
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, both persistent root themes and mobile bounds');
}catch(error){if(process.env.SCREENSHOT_DIR&&page){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-failure.png'}).catch(()=>{})}throw error}finally{await browser.close()}
})().catch(e=>{console.error(e);process.exitCode=1});
+29
View File
@@ -0,0 +1,29 @@
/** 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/apps/studio/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))}
@@ -9,7 +9,7 @@ import type {Design} from './native-layout';
/** Only forward the session to our fixed private Wiki service, never a request Host. */
export async function readDesign(id:unknown,req:IncomingMessage):Promise<Design|null>{
if(!uuid(id))return null;
const origin=process.env.STUDIO_WIKI_ORIGIN||`http://127.0.0.1:${process.env.PORT || '3000'}`;
const origin=process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000';
const headers:Record<string,string>={};
if(req.headers.cookie)headers.cookie=req.headers.cookie;
if(req.headers.authorization)headers.authorization=req.headers.authorization;
@@ -4,7 +4,7 @@ import type {Parameters} from './layout-parameters';
export async function embedLayoutImages(req:NextApiRequest,params:Parameters){
const ids=[...new Set(Object.values(params.images).flatMap(v=>v.assetId?[v.assetId]:[]))],sources:Record<string,string>={};let total=0;
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
for(const id of ids){const url=(process.env.STUDIO_WIKI_ORIGIN||`http://127.0.0.1:${process.env.PORT || '3000'}`)+'/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
for(const id of ids){const url=(process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+'/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
return sources;
}
@@ -14,7 +14,7 @@ export async function embedSvgMedia(req:NextApiRequest,svg:string){
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
let total=0;
for(const path of paths){
const response=await fetch((process.env.STUDIO_WIKI_ORIGIN||`http://127.0.0.1:${process.env.PORT || '3000'}`)+path,{headers,redirect:'error',signal:AbortSignal.timeout(15000)});
const response=await fetch((process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+path,{headers,redirect:'error',signal:AbortSignal.timeout(15000)});
const mime=response.headers.get('content-type')?.split(';')[0];
if(!response.ok||!mime||!['image/png','image/jpeg','image/webp'].includes(mime))throw Error('模板图片不可访问');
const chunks:Uint8Array[]=[];const reader=response.body!.getReader();
+8
View File
@@ -0,0 +1,8 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import type { AppProps } from "next/app";
import "../styles/globals.css";
import "../styles/brand.css";
export default function App({Component,pageProps}:AppProps){const router = useRouter();
useEffect(() => { const scope = /^\/(?: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]);
return <Component {...pageProps}/>;}
+3
View File
@@ -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="/studio/favicon.ico?v=endoleap-1.2" sizes="any"/><link rel="icon" type="image/svg+xml" href="/studio/brand/icon-dark.svg?v=endoleap-1.2"/><link rel="apple-touch-icon" href="/studio/apple-touch-icon.png"/><script dangerouslySetInnerHTML={{__html:initializeTheme}}/></Head><body><Main/><NextScript/></body></Html>; }
@@ -1,11 +1,11 @@
import {embedSvgMedia} from '../../../../studio/lib/layout-images';
import {embedSvgMedia} from '../../../lib/layout-images';
import {renderToStaticMarkup} from 'react-dom/server';
import {serveApiDocumentation} from '../../../../studio/lib/api-documentation';
import {serveApiDocumentation} from '../../../lib/api-documentation';
import type {NextApiRequest,NextApiResponse} from 'next';
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';
import {ideas,ideaMarkdown} from '../../../content/ideas';
import {selections} from '../../../components/SourceCover';
import {renderCover} from '../../../lib/render-cover';
import {tutorialPost} from '../../../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 '../../../studio/lib/api-documentation';
import {serveApiDocumentation} from '../../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 '../../../studio/content/ideas';
import {ideas} from '../../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 '../../../../studio/lib/layout-images';
import {embedSvgMedia} from '../../../lib/layout-images';
import type {NextApiRequest,NextApiResponse} from 'next';
import {embedLayoutImages} from '../../../../studio/lib/layout-images';
import {embedLayoutImages} from '../../../lib/layout-images';
import {createElement} from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
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';
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';
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 '../../../../studio/content/templates/layouts.json';
import standard from '../../../../studio/content/standard-layout.json';
import fixed from '../../../content/templates/layouts.json';
import standard from '../../../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,11 +1,11 @@
import type {GetServerSideProps} from 'next';
import {readDesign} from '../../../studio/lib/design-server';
import {designUrl} from '../../../studio/lib/native-layout';
import {readDesign} from '../../lib/design-server';
import {designUrl} from '../../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};
// The unified application has no basePath; keep the full historical URL.
return {redirect:{destination:designUrl(design.layoutId,design.id),permanent:false}};
// Next adds the configured /studio basePath to server redirects.
return {redirect:{destination:designUrl(design.layoutId,design.id).slice('/studio'.length),permanent:false}};
};
@@ -1,11 +1,11 @@
import { ThemeToggle } from "../../studio/components/ThemeToggle";
import { Brand } from "../../studio/components/Brand";
import { ThemeToggle } from "../../components/ThemeToggle";
import { Brand } from "../../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 '../../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';
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';
const blank:Doc={name:'未命名设计',background:'#ffffff',items:[]};
const storage='studio.graphic.v1';
export default function Graphic(){
@@ -1,10 +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';
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}>← 啡跃使用教程</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}};
+3
View File
@@ -0,0 +1,3 @@
import type {GetServerSideProps} from 'next';
export default function StudioEntry(){return null}
export const getServerSideProps:GetServerSideProps=async()=>({redirect:{destination:'/graphic',permanent:false}});
@@ -1,34 +1,23 @@
import { ThemeToggle } from "../../studio/components/ThemeToggle";
import { Brand } from "../../studio/components/Brand";
import {WorkspaceShell, type Subcanvas} from '@/components/WorkspaceShell';
import { ThemeToggle } from "../../components/ThemeToggle";
import { Brand } from "../../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 '../../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';
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';
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 [nodes, setNodes] = useState<Card[]>();
return <WorkspaceShell liveNodes={nodes} editor={subset => <CanvasEditor subset={subset} onNodesChange={setNodes}/>}/>;
}
function CanvasEditor({subset,onNodesChange}:{subset?:Subcanvas;onNodesChange:(nodes:Card[])=>void}){
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 visibleCards=subset?doc.cards.filter(card=>subset.nodes.includes(card.id)):doc.cards;
const visibleIds=new Set(visibleCards.map(card=>card.id));
const active=visibleCards.find(c=>c.id===selected);
useEffect(()=>{onNodesChange(doc.cards)},[doc.cards,onNodesChange]);
useEffect(()=>{select('');setEditing('');setMenu(null);setRefMenu('');setComposerTarget('')},[subset?.id]);
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{}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{const serialized=JSON.stringify(doc);const previous=localStorage.getItem(key);if(previous!==serialized){localStorage.setItem(key,serialized);if(previous||doc.cards.length)localStorage.setItem(key+'.editedAt',new Date().toISOString());}setStatus('已保存到此浏览器')}catch{setStatus('本机空间不足,请导出项目备份')}},[doc,ready]);
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)});
@@ -36,12 +25,12 @@ function CanvasEditor({subset,onNodesChange}:{subset?:Subcanvas;onNodesChange:(n
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)}
function add(kind:Card['kind'],image?:string){if(subset){setStatus('请在父画布中添加节点');return;}if(doc.cards.length>=80){setStatus('最多 80 个节点');return}const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');const rect=viewport.current?.getBoundingClientRect();commit({...doc,cards:[...doc.cards,{id,kind,title:kind==='text'?'灵感笔记':kind==='image'?'图片节点':kind==='script'?'脚本生成器':'生成提示词',text:'',x:((menu?.x??(rect?.width||700)/2)-pan.x)/zoom-160,y:((menu?.y??(rect?.height||600)/2)-pan.y)/zoom-100,image}]});select(id)}
function add(kind:Card['kind'],image?:string){if(doc.cards.length>=80){setStatus('最多 80 个节点');return}const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');const rect=viewport.current?.getBoundingClientRect();commit({...doc,cards:[...doc.cards,{id,kind,title:kind==='text'?'灵感笔记':kind==='image'?'图片节点':kind==='script'?'脚本生成器':'生成提示词',text:'',x:((menu?.x??(rect?.width||700)/2)-pan.x)/zoom-160,y:((menu?.y??(rect?.height||600)/2)-pan.y)/zoom-100,image}]});select(id)}
function change(field:'title'|'text'|'instruction',value:string,id=selected){setDoc(d=>({...d,cards:d.cards.map(c=>c.id===id?{...c,[field]:value}:c)}))}
function beginEdit(){setPast(p=>[...p.slice(-29),doc]);setFuture([])}
function undo(){if(!past.length)return;setFuture(f=>[doc,...f]);setDoc(past[past.length-1]);setPast(p=>p.slice(0,-1))}
function redo(){if(!future.length)return;setPast(p=>[...p,doc]);setDoc(future[0]);setFuture(f=>f.slice(1))}
function fit(){const el=viewport.current;if(!el||!visibleCards.length)return;const minX=Math.min(...visibleCards.map(c=>c.x)),minY=Math.min(...visibleCards.map(c=>c.y));const w=Math.max(...visibleCards.map(c=>c.x+(c.width||320)))-minX,h=Math.max(...visibleCards.map(c=>c.y+(c.height||350)))-minY;const z=Math.max(.2,Math.min(1,(el.clientWidth-80)/w,(el.clientHeight-100)/h));setZoom(z);setPan({x:(el.clientWidth-w*z)/2-minX*z,y:(el.clientHeight-h*z)/2-minY*z})}
function fit(){const el=viewport.current;if(!el||!doc.cards.length)return;const minX=Math.min(...doc.cards.map(c=>c.x)),minY=Math.min(...doc.cards.map(c=>c.y));const w=Math.max(...doc.cards.map(c=>c.x+(c.width||320)))-minX,h=Math.max(...doc.cards.map(c=>c.y+(c.height||350)))-minY;const z=Math.max(.2,Math.min(1,(el.clientWidth-80)/w,(el.clientHeight-100)/h));setZoom(z);setPan({x:(el.clientWidth-w*z)/2-minX*z,y:(el.clientHeight-h*z)/2-minY*z})}
function selectNode(id:string){if(id!==selected)setEditing('');select(id)}
function editNode(id:string){setPickerRequest(0);setComposerTarget('');select(id);setEditing(id);setMenu(null);setRefMenu('');setTaskFeedback('');setTool('select')}
function patchNode(id:string,patch:Partial<Card>){commit({...doc,cards:doc.cards.map(n=>n.id===id?{...n,...patch}:n)})}
@@ -54,18 +43,18 @@ function CanvasEditor({subset,onNodesChange}:{subset?:Subcanvas;onNodesChange:(n
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 <div className={s.app}><Head><title>无限画布 · 啡跃 Endoleap</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
<header className={s.header}><a href="/workspace" className={s.back} aria-label="返回工作台"><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>
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}>{subset && <div className={s.subsetLabel}>{subset.name} · {visibleCards.length} 个节点</div>}{!subset&&!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>
<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}}>
<div className={s.world} style={{transform:`translate(${pan.x}px,${pan.y}px) scale(${zoom})`}}><svg className={s.wires} aria-hidden="true">{doc.links.filter(([a,b])=>visibleIds.has(a)&&visibleIds.has(b)).map(([a,b])=>{const from=doc.cards.find(c=>c.id===a),to=doc.cards.find(c=>c.id===b);if(!from||!to)return null;return <path key={a+b} d={`M ${from.x+(from.width||320)} ${from.y+(from.height||350)/2} C ${from.x+(from.width||320)+70} ${from.y+(from.height||350)/2},${to.x-45} ${to.y+(to.height||350)/2},${to.x} ${to.y+(to.height||350)/2}`}/>})}</svg>
{visibleCards.map(c=><CanvasNodeView key={c.id} node={c} selected={selected===c.id} editing={editing===c.id&&composerTarget!==c.id} hand={tool==='hand'} onSelect={()=>selectNode(c.id)} onChooseReference={()=>{editNode(c.id);setComposerTarget(c.id);setPickerRequest(++pickerSequence.current)}} onEdit={()=>editNode(c.id)} onDrag={e=>{setMenu(null);if(c.id!==editing)setEditing('');drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}} onResize={e=>{viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.width||320,oy:c.height||350,before:doc,resize:true}}} onChange={value=>change('text',value,c.id)} onRename={value=>change('title',value,c.id)} onStyle={style=>patchNode(c.id,{style})} onReference={e=>{select(c.id);setRefMenu(c.id);setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}} onMenu={e=>{select(c.id);setRefMenu('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onUpload={()=>{select(c.id);file.current?.click()}} onUpscale={()=>derive(c.id,'image','upscale')} onExpand={()=>patchNode(c.id,{width:Math.min(1200,(c.width||320)*1.5),height:Math.min(1200,(c.height||350)*1.3)})} onBeginEdit={beginEdit}/>)}
<div className={s.world} style={{transform:`translate(${pan.x}px,${pan.y}px) scale(${zoom})`}}><svg className={s.wires} aria-hidden="true">{doc.links.map(([a,b])=>{const from=doc.cards.find(c=>c.id===a),to=doc.cards.find(c=>c.id===b);if(!from||!to)return null;return <path key={a+b} d={`M ${from.x+(from.width||320)} ${from.y+(from.height||350)/2} C ${from.x+(from.width||320)+70} ${from.y+(from.height||350)/2},${to.x-45} ${to.y+(to.height||350)/2},${to.x} ${to.y+(to.height||350)/2}`}/>})}</svg>
{doc.cards.map(c=><CanvasNodeView key={c.id} node={c} selected={selected===c.id} editing={editing===c.id&&composerTarget!==c.id} hand={tool==='hand'} onSelect={()=>selectNode(c.id)} onChooseReference={()=>{editNode(c.id);setComposerTarget(c.id);setPickerRequest(++pickerSequence.current)}} onEdit={()=>editNode(c.id)} onDrag={e=>{setMenu(null);if(c.id!==editing)setEditing('');drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}} onResize={e=>{viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.width||320,oy:c.height||350,before:doc,resize:true}}} onChange={value=>change('text',value,c.id)} onRename={value=>change('title',value,c.id)} onStyle={style=>patchNode(c.id,{style})} onReference={e=>{select(c.id);setRefMenu(c.id);setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}} onMenu={e=>{select(c.id);setRefMenu('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onUpload={()=>{select(c.id);file.current?.click()}} onUpscale={()=>derive(c.id,'image','upscale')} onExpand={()=>patchNode(c.id,{width:Math.min(1200,(c.width||320)*1.5),height:Math.min(1200,(c.height||350)*1.3)})} onBeginEdit={beginEdit}/>)}
</div></div><div className={s.canvasBottom}><span role="status">● {status}</span><div className={s.controls}><button aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={16}/></button><button aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={16}/></button><i/><button aria-label="缩小" onClick={()=>scaleAt(zoom-.1)}><Minus size={16}/></button><span>{Math.round(zoom*100)}%</span><button aria-label="放大" onClick={()=>scaleAt(zoom+.1)}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Maximize size={16}/></button></div></div></section>
{menu&&<div className={s.contextMenu} role="dialog" aria-label="画布操作" style={{left:Math.max(8,menu.x),top:Math.max(8,menu.y)}}><div className={s.menuTitle}>{refMenu?'引用该节点生成':active?active.title:'添加到画布'}<button aria-label="关闭画布操作" onClick={()=>setMenu(null)}>×</button></div>{refMenu?<>{(['text','image','script'] as const).map(kind=><button key={kind} onClick={()=>derive(refMenu,kind,kind==='script'?'script':'generate')}>{kind==='text'?'文本':kind==='image'?'图片':'脚本'}</button>)}</>:active?<><button onClick={()=>{editNode(active.id);setComposerTarget(active.id)}}>创作要求</button><button onClick={duplicate}>复制节点</button><button onClick={()=>{commit(removeCanvasNode(doc,selected));select('');setEditing('');setMenu(null)}}>删除节点</button><label>连接到<select value={link} onChange={e=>setLink(e.target.value)}><option value="">选择目标节点</option>{doc.cards.filter(c=>c.id!==selected).map(c=><option key={c.id} value={c.id}>{c.title}</option>)}</select></label><button disabled={!link} onClick={()=>{connect(selected,link);setLink('')}}>连接节点</button>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=><button key={a+b} onClick={()=>commit({...doc,links:doc.links.filter(l=>l[0]!==a||l[1]!==b)})}>断开:{doc.cards.find(c=>c.id===(a===selected?b:a))?.title}</button>)}</>:<><button onClick={()=>{add('text');setMenu(null)}}>添加笔记</button><button onClick={()=>{add('image');setMenu(null)}}>添加图片</button><button onClick={()=>{add('script');setMenu(null)}}>添加脚本</button></>}</div>}</div>
{active&&editing===active.id&&(active.kind!=='text'||composerTarget===active.id)&&<CanvasComposer pickerRequest={pickerRequest} key={active.id} node={active} nodes={doc.cards} references={referencedNodes(doc,active.id)} onChange={value=>change('instruction',value,active.id)} onRename={value=>change('title',value,active.id)} onBeginEdit={beginEdit} onUpload={()=>file.current?.click()} onAddReference={id=>connect(id,active.id)} onRemoveReference={id=>commit({...doc,links:doc.links.filter(([from,to])=>from!==id||to!==active.id)})} onQueue={()=>void agent.current?.queue(active.id)} onExport={()=>exportTask(active.id)} onClose={()=>setEditing('')} feedback={taskFeedback}/>}
<input hidden ref={file} type="file" accept="image/png,image/jpeg,image/webp" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;if(!['image/png','image/jpeg','image/webp'].includes(f.type)||f.size>2000000){setStatus('请选择 2MB 以内的 PNG、JPEG 或 WebP');return}const reader=new FileReader();reader.onload=()=>{const image=String(reader.result);if(active?.kind==='image')commit({...doc,cards:doc.cards.map(c=>c.id===selected?{...c,image}:c)});else add('image',image)};reader.readAsDataURL(f)}}/>
<input hidden ref={json} 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>15000000)throw Error();const d=parseCanvasDocument(await f.text());commit(d);select(d.cards[0]?.id||'');setPan({x:0,y:0});setStatus('项目已导入')}catch{setStatus('无法导入:项目格式错误或文件过大')}}}/>
</div>;
</main>;
}
@@ -1,11 +1,11 @@
import type {GetServerSideProps} from 'next';
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';
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';
const layouts=[...fixedLayouts,standardLayout];
import {nativeLayout,designUrl} from '../../../studio/lib/native-layout';
import {readDesign} from '../../../studio/lib/design-server';
import {nativeLayout,designUrl} from '../../lib/native-layout';
import {readDesign} from '../../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 '../../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';
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';
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}/>}
+24
View File
@@ -0,0 +1,24 @@
/* Shared chrome uses the canonical package tokens; artwork keeps its own palette. */
html,body{background:var(--background);color:var(--foreground);color-scheme:inherit}
body{margin:0}
.endoleap-brand{display:inline-flex;align-items:center;gap:9px;white-space:nowrap;line-height:1.15;vertical-align:middle;letter-spacing:normal;font-weight:650}
.endoleap-brand .endoleap-mark{width:32px;height:32px;object-fit:contain;flex-shrink:0}
.endoleap-mark-dark{display:none!important}
[data-theme$="-dark"] .endoleap-mark-light{display:none!important}
[data-theme$="-dark"] .endoleap-mark-dark{display:block!important}
.endoleap-wordmark{display:flex!important;flex-direction:column;font-size:17px!important;color:var(--foreground-strong)!important;letter-spacing:.02em!important;margin:0!important;gap:3px}
.endoleap-wordmark small{display:block;font-size:9px;font-weight:500;letter-spacing:.08em;color:var(--muted-foreground)}
button.endoleap-theme-toggle{display:inline-flex;align-items:center;justify-content:center;gap:5px;border:1px solid var(--border);background:var(--surface);color:var(--foreground);border-radius:8px;padding:7px 9px;font-size:12px;line-height:1.2;flex-shrink:0;cursor:pointer}
button.endoleap-theme-toggle:hover{background:var(--row-hover)}
:where(a,button,input,textarea,select):focus-visible{outline:2px solid var(--ring);outline-offset:3px}
::selection{background:var(--selection);color:var(--foreground)}
@media(max-width:600px){.endoleap-theme-toggle span{display:none}.endoleap-brand{gap:5px}.endoleap-brand .endoleap-mark{width:26px;height:26px}.endoleap-wordmark{font-size:14px!important}}
button:disabled{cursor:default}
input,textarea,select{accent-color:var(--primary)}
.is-editing.native-layout-page{background:var(--background);color:var(--foreground)}
.studio-mark,.page-tools,.workbench-top,.workbench-layers,.workbench-properties,.element-popover,.page-edit-info{color:var(--foreground)}
.workbench-top button.primary,.page-tools .page-save,.trial-actions .trial-primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}
.workbench-top button.primary:hover,.page-tools .page-save:hover{background:var(--primary-hover)}
.native-element.is-selected,.proof-active .proof-select{outline-color:var(--selection-border)}
@@ -1,8 +1,7 @@
@scope (.studio-app) {
*{box-sizing:border-box}:scope{margin:0;color:var(--foreground-strong);background:var(--selection);font-family:Arial,"PingFang SC",sans-serif}a{color:inherit;text-decoration:none}button,a{cursor:pointer}button{border:0;padding:12px 22px;border-radius:24px}.workspace{display:grid;grid-template-columns:240px 1fr;min-height:100vh}aside{padding:38px 26px;border-right:1px solid var(--border);background:var(--surface)}aside a{display:block;margin-top:30px}.logo{color:var(--primary)}section{padding:70px 6vw}h1{font-size:40px;letter-spacing:-1.5px}section>p,.hint{color:var(--muted-foreground)}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,340px));gap:24px;margin-top:45px}.card{background:var(--surface);padding:14px;border-radius:22px;border:1px solid var(--border)}.cover{background:var(--selection);aspect-ratio:4/5;border-radius:14px;padding:36px;display:flex;align-items:center;font-size:35px;font-weight:bold}.card h2{font-size:18px}.card p{line-height:1.8;color:var(--muted-foreground)}.card span{color:var(--primary)}.hint{margin-top:40px;font-size:13px}.editor{max-width:1200px;margin:auto;padding:30px}header{display:flex;align-items:center;gap:24px}header a:first-child{margin-right:auto}.slides{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:40px}.slide{aspect-ratio:3/4;padding:35px;border-radius:20px;display:flex;flex-direction:column}.slide h2{font-size:38px;margin-top:auto}.slide p{line-height:1.9}.slide footer{margin-top:auto;font-size:12px}@media(max-width:760px){.workspace{grid-template-columns:1fr}aside{border-bottom:1px solid var(--border)}aside a{display:inline-block;margin:20px 20px 0 0}section{padding:30px}.slides{grid-template-columns:1fr}header{flex-wrap:wrap}h1{font-size:30px}}@media print{header{display:none}.slides{display:block}.slide{width:180mm;height:240mm;break-after:page;print-color-adjust:exact;border-radius:0}.editor>h1,.editor>p{display:none}}
@import "tailwindcss";
@import "@endoleap/brand/themes.css";
@import "@endoleap/brand/tailwind.css";
*{box-sizing:border-box}body{margin:0;color:var(--foreground-strong);background:var(--selection);font-family:Arial,"PingFang SC",sans-serif}a{color:inherit;text-decoration:none}button,a{cursor:pointer}button{border:0;padding:12px 22px;border-radius:24px}.workspace{display:grid;grid-template-columns:240px 1fr;min-height:100vh}aside{padding:38px 26px;border-right:1px solid var(--border);background:var(--surface)}aside a{display:block;margin-top:30px}.logo{color:var(--primary)}section{padding:70px 6vw}h1{font-size:40px;letter-spacing:-1.5px}section>p,.hint{color:var(--muted-foreground)}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,340px));gap:24px;margin-top:45px}.card{background:var(--surface);padding:14px;border-radius:22px;border:1px solid var(--border)}.cover{background:var(--selection);aspect-ratio:4/5;border-radius:14px;padding:36px;display:flex;align-items:center;font-size:35px;font-weight:bold}.card h2{font-size:18px}.card p{line-height:1.8;color:var(--muted-foreground)}.card span{color:var(--primary)}.hint{margin-top:40px;font-size:13px}.editor{max-width:1200px;margin:auto;padding:30px}header{display:flex;align-items:center;gap:24px}header a:first-child{margin-right:auto}.slides{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:40px}.slide{aspect-ratio:3/4;padding:35px;border-radius:20px;display:flex;flex-direction:column}.slide h2{font-size:38px;margin-top:auto}.slide p{line-height:1.9}.slide footer{margin-top:auto;font-size:12px}@media(max-width:760px){.workspace{grid-template-columns:1fr}aside{border-bottom:1px solid var(--border)}aside a{display:inline-block;margin:20px 20px 0 0}section{padding:30px}.slides{grid-template-columns:1fr}header{flex-wrap:wrap}h1{font-size:30px}}@media print{header{display:none}.slides{display:block}.slide{width:180mm;height:240mm;break-after:page;print-color-adjust:exact;border-radius:0}.editor>h1,.editor>p{display:none}}
/* The reference art stays inside its native 113:174 canvas. */
.cover-template{padding:0;border-radius:0;display:block;aspect-ratio:113/174;background:#f4f2eb}
@@ -94,5 +93,3 @@
.native-series{padding-top:24px}.series-page{margin:0 auto 32px}.series-page-number{text-align:center;font-size:12px;letter-spacing:.14em;color:var(--muted-foreground);margin:14px 0 0}
.native-export{padding:0;background:transparent;min-height:0}.native-export .series-page{margin:0}.native-export .native-layout{width:1080px;max-width:none;margin:0}
@media print{.native-series{padding:0}.series-page{margin:0;break-after:page}.series-page:last-child{break-after:auto}.series-page-number{display:none}}
}
@@ -20,5 +20,3 @@
.header .export:hover{background:var(--primary-hover)}
.wires path{stroke:var(--connector)}
.brand{display:inline-flex;align-items:center;letter-spacing:normal}
.header { padding-left: 39px; }
.subsetLabel { position: absolute; top: 76px; left: 16px; z-index: 4; max-width: calc(100% - 32px); padding: 6px 10px; border-radius: 6px; background: var(--surface); color: var(--muted-foreground); overflow-wrap: anywhere; pointer-events: none; }
@@ -1,7 +1,7 @@
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/studio/lib/canvas-document.ts';
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',()=>{
@@ -61,5 +61,5 @@ test('a free requested position is retained and large nodes near the coordinate
});
test('agent and browser parse the same canvas document contract',()=>{
assert.equal(readFileSync(new URL('../../src/studio/lib/canvas-document.ts',import.meta.url),'utf8'),"export * from '../../lib/canvas-document.ts';\n");
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,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {parseGraphicDocument} from '../../src/studio/lib/graphic-document.ts';
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)});
@@ -1,11 +1,11 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {embedSvgMedia} from '../../src/studio/lib/layout-images.ts';
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,(process.env.STUDIO_WIKI_ORIGIN || `http://127.0.0.1:${process.env.PORT || '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'}});};
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;}
@@ -1,7 +1,7 @@
import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';
import {parameters} from '../../src/studio/lib/layout-parameters.ts';
const layouts=JSON.parse(readFileSync(new URL('../../src/studio/content/templates/layouts.json',import.meta.url)));
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('../../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 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))});
@@ -1,8 +1,8 @@
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/studio/lib/native-layout.ts';
const layouts=JSON.parse(readFileSync(new URL('../../src/studio/content/templates/layouts.json',import.meta.url)));
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)}}
@@ -3,9 +3,10 @@ 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/studio/lib/series.ts');
const layout=JSON.parse(readFileSync(new URL('../../src/studio/content/standard-layout.json',import.meta.url)));
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/studio/autumn-journal.json',import.meta.url)));
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')));
@@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -18,20 +14,8 @@
"jsx": "react-jsx",
"incremental": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
"allowImportingTsExtensions": true
"paths": { "@/*": ["./src/*"] }
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+22
View File
@@ -0,0 +1,22 @@
# 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
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.
AUTH_COOKIE_SECURE=true
ACCOUNT_PUBLIC_ORIGIN=http://8.211.154.134:18131
# Enable only behind an ingress that overwrites x-forwarded-for.
ACCOUNT_TRUST_PROXY=false
SMTP_HOST=smtpdm.aliyun.com
SMTP_PORT=465
SMTP_USERNAME=noreply@notice.entroia.com
SMTP_FROM=noreply@notice.entroia.com
SMTP_PASSWORD=
+39
View File
@@ -0,0 +1,39 @@
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
WORKDIR /repo/apps/waterfall-wiki
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
COPY packages/brand/ /repo/packages/brand/
COPY apps/waterfall-wiki/package.json ./
RUN pnpm --filter @prompt/waterfall-wiki install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
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
WORKDIR /app/apps/waterfall-wiki
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 /repo/apps/waterfall-wiki/.next/standalone/ /app/
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/dist/worker.cjs /repo/apps/waterfall-wiki/dist/bootstrap.cjs /repo/apps/waterfall-wiki/dist/publish-learning-series.cjs /repo/apps/waterfall-wiki/dist/auth-cli.cjs ./
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/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"]

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