@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.idea
|
||||
docs
|
||||
data
|
||||
web/.next
|
||||
web/node_modules
|
||||
web/out
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
@@ -0,0 +1,17 @@
|
||||
# 管理员账号,首次启动会自动创建。
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=infinite-canvas
|
||||
|
||||
# JWT 登录密钥和过期时间,正式部署请修改 JWT_SECRET。
|
||||
JWT_SECRET=infinite-canvas
|
||||
JWT_EXPIRE_HOURS=168
|
||||
|
||||
# 后端对外监听端口,Docker 默认由 Go 统一暴露 3000。
|
||||
PORT=3000
|
||||
|
||||
# 数据库配置,默认使用本地 SQLite。
|
||||
STORAGE_DRIVER=sqlite
|
||||
# sqlite: DATABASE_DSN=data/infinite-canvas.db
|
||||
# mysql: DATABASE_DSN=user:password@tcp(127.0.0.1:3306)/infinite_canvas?parseTime=true
|
||||
# postgres: DATABASE_DSN=postgres://user:password@127.0.0.1:5432/infinite_canvas?sslmode=disable
|
||||
DATABASE_DSN=data/infinite-canvas.db
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
.next/
|
||||
out/
|
||||
node_modules/
|
||||
.env*
|
||||
!.env.example
|
||||
data/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
.idea
|
||||
@@ -0,0 +1,73 @@
|
||||
# AGENTS.md
|
||||
|
||||
本文档用于约束本项目中的 AI / 自动化开发行为。开发时优先遵循本文件,其次遵循用户当前消息。
|
||||
|
||||
## 基本原则
|
||||
|
||||
- 先读现有代码,再动手修改,优先沿用项目已有结构和写法。
|
||||
- 写代码保持最少行数,能简单实现就不要引入复杂抽象。
|
||||
- 不要为了“兼容更多场景”写大量分支,只实现当前明确需要的功能。
|
||||
- 项目尚未上线,不需要兼容旧数据;表结构或字段调整时直接按新设计修改,不写旧字段兼容、数据迁移兜底或删除旧表的清理逻辑,除非用户明确要求。
|
||||
- 每次写完代码,不需要检查语法,不需要执行构建,用户会自己做。
|
||||
- 不要改无关文件,不要顺手重构。
|
||||
- 如果工作区已有用户改动,不要回滚,不要覆盖;只在必要范围内追加修改。
|
||||
|
||||
## 反复提醒沉淀
|
||||
|
||||
- 如果开发过程中总是遇到某个问题,或者用户反复提醒同一个注意事项,需要把该注意事项补充到本文件。
|
||||
- 补充时写成明确、可执行的规则,避免只写模糊描述。
|
||||
- 新规则应放到最相关的章节;找不到合适章节时放到“项目注意事项”。
|
||||
|
||||
## 后端规范
|
||||
|
||||
- 后端使用 Go + Gin + GORM。
|
||||
- `handler/` 只处理 HTTP 入参、调用 service、返回 `OK` / `Fail`。
|
||||
- `service/` 放业务逻辑、默认值、校验、时间、ID、鉴权等处理。
|
||||
- `repository/` 只做数据库访问和 GORM 查询。
|
||||
- `model/` 只定义数据结构、枚举和简单模型方法。
|
||||
- 列表接口优先沿用 `model.Query`、`Normalize`、分页和标签筛选方式。
|
||||
- 业务接口保持 `{ code, data, msg }` 的响应结构。
|
||||
- 新增数据表时同步更新 `docs/backend-database.md`。
|
||||
|
||||
## 前端规范
|
||||
|
||||
- 前端使用 Next.js App Router、React、TypeScript、Ant Design、Tailwind、Zustand。
|
||||
- API 请求统一放在 `web/src/services/api/`。
|
||||
- 全局或跨页面状态优先放在 `web/src/stores/`。
|
||||
- 画布相关状态和组件放在 `web/src/app/(user)/canvas/` 内部。
|
||||
- 页面里只有一个主业务组件时直接写在 `page.tsx`,不要单独拆 `Manager` 组件再传一堆 props。
|
||||
- 管理后台页面私有组件放到各自页面目录的 `components/` 下,例如 `admin/assets/components/`、`admin/prompts/components/`;不要为了单页面使用放到 `admin/components/` 共享目录。
|
||||
- 管理后台主题、背景、卡片阴影、表格配色等统一在全局 `AntThemeProvider` 或全局 CSS 作用域中配置;页面私有组件不要自己写 `dark ? ...` 主题分支。
|
||||
- 组件优先使用函数组件和现有 hooks,不新增大型状态管理方案。
|
||||
- UI 图标优先使用 `lucide-react` 或项目已经使用的 Ant Design 图标。
|
||||
- 页面文案保持中文。
|
||||
- 不要在组件里堆太多无关逻辑;复杂逻辑优先抽成同目录工具函数或小组件。
|
||||
- 前端业务数据需要浏览器本地持久化时,默认使用 `localforage`;`localStorage` 只用于极小的简单配置,不要用来保存业务列表、生成记录、图片、base64 或大 JSON。
|
||||
|
||||
## 画布 UI 规范
|
||||
|
||||
- 做 canvas 前端 UI 时必须遵循当前画布主题。
|
||||
- 优先使用 `canvasThemes`、`useThemeStore` 或 Ant Design `ConfigProvider` token。
|
||||
- 不要硬编码黑白、stone、slate 等颜色导致浅色/深色主题不一致。
|
||||
- 新增画布按钮、弹窗、浮层时,尽量复用已有工具栏、节点面板、Modal 的视觉风格。
|
||||
- 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。
|
||||
- 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。
|
||||
|
||||
## 文档规范
|
||||
|
||||
- README 保持简洁,只放项目介绍、核心功能、快速开始和文档入口。
|
||||
- 详细功能介绍写到 `docs/features.md`。
|
||||
- 后续待办写到 `docs/todo.md`。
|
||||
- 已实现但还需要用户测试确认的事项写到 `docs/pending-test.md`。
|
||||
- 面向用户的新增、调整、修复等版本变更写到根目录 `CHANGELOG.md` 的 `Unreleased` 中。
|
||||
- 每次 todo 事项完成后,先从 `docs/todo.md` 移到 `docs/pending-test.md`,不要直接写进正式功能说明;用户确认测试通过后再更新 `docs/features.md`。
|
||||
- 每次任务完成前,都要根据实际变更检查并更新 `docs/todo.md` 和 `docs/pending-test.md`;如果功能或待办没有变化,也要确认无需修改。
|
||||
- 接口响应规则写到 `docs/api-response.md`。
|
||||
- 数据库结构写到 `docs/backend-database.md`。
|
||||
- 文档不要写过期日期;除非用户明确要求记录具体时间。
|
||||
|
||||
## 项目注意事项
|
||||
|
||||
- 当前画布项目和“我的素材”主要保存在浏览器本地,不要在文档中误写成已支持云同步。
|
||||
- 当前 AI API Key 存在浏览器本地,并由前端直接请求 OpenAI 兼容接口;涉及安全说明时要写清楚。
|
||||
- Docker 静态资源路径目前仍是待办项,文档中不要过度承诺生产部署已经完全验证。
|
||||
@@ -0,0 +1,14 @@
|
||||
# CHANGELOG
|
||||
|
||||
## Unreleased
|
||||
|
||||
1. [新增] 增加生图工作台功能,支持文生图、图生图、查看历史记录,并增加移动端适配。
|
||||
2. [修复] Docker 镜像启动时 Next.js 监听地址改为 `0.0.0.0`,便于容器环境访问。
|
||||
3. [修复] 画布生成尺寸控件支持选择更多常用比例,并可直接输入自定义比例。
|
||||
|
||||
## v0.0.1 - 2026-05-19
|
||||
|
||||
1. [新增] 首次开源版本,包含无限画布能力:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。
|
||||
2. [新增] AI 创作能力:支持 OpenAI 兼容接口的文生图、图生图、参考图编辑和文本问答。
|
||||
3. [新增] 画布助手能力:支持围绕选中节点和上游节点对话、生图,并把结果插回画布。
|
||||
4. [新增] 提示词库能力:抓取多个 GitHub 开源项目,按案例整理数百个图片提示词。
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# 构建 Next.js 前端产物。
|
||||
FROM oven/bun:1 AS web-build
|
||||
|
||||
WORKDIR /app/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile --registry=https://registry.npmmirror.com --cache-dir=/root/.bun/install/cache
|
||||
COPY VERSION /app/VERSION
|
||||
COPY web ./
|
||||
RUN bun run build
|
||||
|
||||
# 构建 Go 后端入口。
|
||||
FROM golang:1.25-alpine AS api-build
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
COPY config ./config
|
||||
COPY handler ./handler
|
||||
COPY middleware ./middleware
|
||||
COPY model ./model
|
||||
COPY repository ./repository
|
||||
COPY router ./router
|
||||
COPY service ./service
|
||||
COPY main.go ./
|
||||
RUN go build -o /server .
|
||||
|
||||
# 运行镜像:Go 对外监听 3000,Next.js 只在容器内部监听 3001。
|
||||
FROM oven/bun:1
|
||||
|
||||
WORKDIR /app
|
||||
COPY VERSION /app/VERSION
|
||||
COPY --from=api-build /server /app/server
|
||||
COPY --from=web-build /app/web /app/web
|
||||
ENV PROMPT_DATA_DIR=/app/data/prompts
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
RUN mkdir -p /app/data/prompts
|
||||
|
||||
EXPOSE 3000
|
||||
# 先启动内部 Next.js,再由 Go 统一处理 /api/* 和页面反代。
|
||||
CMD ["sh", "-c", "cd /app/web && HOSTNAME=0.0.0.0 PORT=3001 bun run start & PORT=3000 /app/server"]
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,86 @@
|
||||
<p align="center">
|
||||
<img src="web/public/logo.svg" width="96" alt="infinite-canvas logo">
|
||||
</p>
|
||||
|
||||
<h1 align="center">无限画布 (infinite-canvas)</h1>
|
||||
|
||||
无限画布是一款面向图片创作的开源工作台。它把画布编排、AI 图片生成、参考图编辑、对话助手、提示词库和素材沉淀放在同一个界面里,适合用来探索视觉方案并连续迭代图片结果。
|
||||
|
||||
> [!CAUTION]
|
||||
> 项目目前处于开发阶段,不保证历史数据兼容。各种数据库结构和存储格式都可能直接调整,欢迎关注后续更新,当前更适合个人/本地部署,不建议直接公网多人共用。
|
||||
>
|
||||
> 如果你需要稳定维护自己的分支,建议自行 fork 后独立开发。二次开发与 PR 请保留原作者信息和前端页面标识。
|
||||
|
||||
## 核心功能
|
||||
|
||||
- 无限画布:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。
|
||||
- AI 创作:支持 OpenAI 兼容接口的文生图、图生图、参考图编辑和文本问答。
|
||||
- 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。
|
||||
- 提示词库:抓取多个 GitHub 开源项目,按案例整理数百个图片提示词。
|
||||
|
||||
完整功能说明见 [docs/features.md](docs/features.md)。
|
||||
|
||||
如果你在为担心没有合适的生图API来发愁,可以查看该免费生图项目:[chatgpt2api](https://github.com/basketikun/chatgpt2api)
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 前端:Next.js、React、TypeScript、Tailwind CSS、Ant Design、Zustand、TanStack Query。
|
||||
- 后端:Go、Gin、GORM。
|
||||
- 部署:Docker。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
git clone git@github.com:basketikun/infinite-canvas.git
|
||||
cd infinite-canvas
|
||||
cp .env.example .env
|
||||
# 修改默认账号密码等信息
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
本地源码构建运行:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.local.yml up -d --build
|
||||
```
|
||||
|
||||
运行后默认端口3000,可访问 `http://localhost:3000`。
|
||||
|
||||
如需要拉取提示词,可前往:`http://localhost:3000/admin/prompts`
|
||||
|
||||
## 效果展示
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td width="50%"><img src="https://i.ibb.co/TDFvGWDT/image.png" alt="image" border="0"></td>
|
||||
<td width="50%"><img src="https://i.ibb.co/zVwJq3YS/image.png" alt="image" border="0"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="https://i.ibb.co/PvY3qhhK/image.png" alt="image" border="0"></td>
|
||||
<td width="50%"><img src="https://i.ibb.co/7D04LwN/image.png" alt="image" border="0"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="https://i.ibb.co/bj30FtS5/5.png" alt="5" border="0"></td>
|
||||
<td width="50%"><img src="https://i.ibb.co/hxRvjw51/image.png" alt="image" border="0"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 文档
|
||||
|
||||
- [功能介绍](docs/features.md)
|
||||
- [画布节点操作手册](docs/canvas-node-manual.md)
|
||||
- [画布快捷键](docs/canvas-shortcuts.md)
|
||||
- [待办事项](docs/todo.md)
|
||||
- [后端数据库说明](docs/backend-database.md)
|
||||
- [接口响应约定](docs/api-response.md)
|
||||
|
||||
## 社区支持
|
||||
|
||||
学 AI,上 L 站:[LinuxDO](https://linux.do/)
|
||||
|
||||
点击链接加入群聊【AI开源交流】:https://qm.qq.com/q/DFnKzZ807u
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目使用 GNU Affero General Public License v3.0,见 [LICENSE](LICENSE)。
|
||||
@@ -0,0 +1,23 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/caarlos0/env/v11"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string `env:"PORT" envDefault:"8080"`
|
||||
AdminUsername string `env:"ADMIN_USERNAME" envDefault:"admin"`
|
||||
AdminPassword string `env:"ADMIN_PASSWORD" envDefault:"infinite-canvas"`
|
||||
JWTSecret string `env:"JWT_SECRET" envDefault:"infinite-canvas"`
|
||||
JWTExpireHours int `env:"JWT_EXPIRE_HOURS" envDefault:"168"`
|
||||
StorageDriver string `env:"STORAGE_DRIVER" envDefault:"sqlite"`
|
||||
DatabaseDSN string `env:"DATABASE_DSN" envDefault:"data/infinite-canvas.db"`
|
||||
}
|
||||
|
||||
var Cfg Config
|
||||
|
||||
func Load() error {
|
||||
_ = godotenv.Load()
|
||||
return env.Parse(&Cfg)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
app:
|
||||
image: infinite-canvas:local
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
app:
|
||||
image: ghcr.io/basketikun/infinite-canvas:latest
|
||||
container_name: infinite-canvas
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,19 @@
|
||||
# 前后端接口响应约定
|
||||
|
||||
后端业务接口统一返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {},
|
||||
"msg": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
- `code`: 业务状态码,`0` 表示成功,非 `0` 表示失败。
|
||||
- `data`: 业务数据。失败时通常为 `null`。
|
||||
- `msg`: 响应消息。成功默认为 `ok`,失败时放错误原因。
|
||||
|
||||
前端请求逻辑以 `code` 判断业务是否成功。当前后端业务失败也会返回 HTTP 200,前端不要只依赖 HTTP 状态码判断结果。
|
||||
|
||||
接口连接失败、服务不可达、返回体不是约定 JSON 时,前端按网络或接口异常处理。
|
||||
@@ -0,0 +1,234 @@
|
||||
# 后端数据库说明
|
||||
|
||||
本文档记录后端当前已经使用,以及后续规划会用到的主要数据表。
|
||||
|
||||
## 数据库
|
||||
|
||||
后端使用 GORM 管理数据库连接和表结构迁移。
|
||||
|
||||
支持的存储驱动:
|
||||
|
||||
- `sqlite`
|
||||
- `mysql`
|
||||
- `postgresql`
|
||||
|
||||
当前启动时执行 `AutoMigrate`,自动维护以下表:
|
||||
|
||||
- `users`
|
||||
- `prompts`
|
||||
- `assets`
|
||||
|
||||
后续新增表时,优先保持表数量少,能用字段或 JSON 表达的配置、状态、统计和扩展信息先不拆表。
|
||||
|
||||
### users
|
||||
|
||||
系统用户表。用户基础信息、角色、算力点余额和邀请关系放在该表中。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----------------|--------|--------------------------|
|
||||
| `id` | string | 主键 |
|
||||
| `username` | string | 用户名,唯一索引 |
|
||||
| `password` | string | 密码哈希 |
|
||||
| `email` | string | 邮箱 |
|
||||
| `display_name` | string | 昵称 |
|
||||
| `avatar_url` | string | 头像地址 |
|
||||
| `role` | string | 角色:`user`、`admin` |
|
||||
| `credits` | number | 算力点余额,规划字段 |
|
||||
| `aff_code` | string | 用户自己的邀请码,唯一索引,规划字段 |
|
||||
| `aff_count` | number | 已邀请用户数量,冗余统计字段,规划字段 |
|
||||
| `inviter_id` | string | 邀请人用户 ID,规划字段 |
|
||||
| `github_id` | string | GitHub 用户 ID,规划字段 |
|
||||
| `linux_do_id` | string | Linux.do 用户 ID,规划字段 |
|
||||
| `wechat_id` | string | 微信用户 ID,规划字段 |
|
||||
| `status` | string | 用户状态:`active`、`ban`,规划字段 |
|
||||
| `last_login_at` | string | 最近登录时间 |
|
||||
| `extra` | json | 扩展信息 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `updated_at` | string | 更新时间 |
|
||||
|
||||
### prompts
|
||||
|
||||
提示词表。后续公开提示词、内置 GitHub 系统提示词、分类和扩展信息都优先放在该表字段或 JSON 中。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|--------------|--------|------------------------------|
|
||||
| `id` | string | 主键 |
|
||||
| `title` | string | 标题 |
|
||||
| `cover_url` | string | 封面图 |
|
||||
| `prompt` | string | 提示词内容 |
|
||||
| `tags` | json | 标签列表 |
|
||||
| `category` | string | 分类标识 |
|
||||
| `visibility` | string | 可见性:公开、私有、系统内置等,规划字段 |
|
||||
| `preview` | text | Markdown 展示内容,可包含文本、图片、视频链接等 |
|
||||
| `extra` | json | 扩展信息 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `updated_at` | string | 更新时间 |
|
||||
|
||||
`github_url` 仅用于接口返回,不写入数据库。
|
||||
|
||||
### assets
|
||||
|
||||
素材表。当前用于素材库;后续通过 `user_id`、`visibility`、`type` 区分系统公开素材和用户私有素材。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------------------|--------|-------------------------------|
|
||||
| `id` | string | 主键 |
|
||||
| `user_id` | string | 所属用户,为空或系统用户表示公开素材,规划字段 |
|
||||
| `title` | string | 标题 |
|
||||
| `type` | string | 素材类型:`text`、`image`、`video` 等 |
|
||||
| `visibility` | string | 可见性:公开、私有,规划字段 |
|
||||
| `cover_url` | string | 封面图 |
|
||||
| `tags` | json | 标签列表 |
|
||||
| `category` | string | 分类标识 |
|
||||
| `description` | string | 描述 |
|
||||
| `content` | text | 文本或 Markdown 内容 |
|
||||
| `url` | string | 图片、视频等媒体地址 |
|
||||
| `like_count` | number | 点赞量,规划字段 |
|
||||
| `favorite_count` | number | 收藏量,规划字段 |
|
||||
| `view_count` | number | 查看量,规划字段 |
|
||||
| `extra` | json | 扩展信息,规划字段 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `updated_at` | string | 更新时间 |
|
||||
|
||||
### settings
|
||||
|
||||
系统配置表,只保存两行数据:`public` 放前端可读取的公开配置,`private` 放仅后端和管理员可读取的私有配置,配置值都用 JSON。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|--------------|--------|-----------------------|
|
||||
| `key` | string | 主键:`public`、`private` |
|
||||
| `value` | json | 配置内容 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `updated_at` | string | 更新时间 |
|
||||
|
||||
`public.value` 常放前端展示和可公开读取的配置,例如模型列表、订阅套餐、功能开关等。
|
||||
`private.value` 常放渠道密钥、支付配置、奖励规则、后台内部开关等。
|
||||
|
||||
### dicts
|
||||
|
||||
字典表。一个字典一行,具体字典项数据放在 `items`。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|--------------|--------|-------|
|
||||
| `code` | string | 字典编码 |
|
||||
| `name` | string | 字典名称 |
|
||||
| `remark` | string | 备注 |
|
||||
| `items` | text | 字典值数据 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `updated_at` | string | 更新时间 |
|
||||
|
||||
可维护分类、标签、业务枚举、模型分类、日志类型等。
|
||||
|
||||
### credit_logs
|
||||
|
||||
用户算力点变更流水表。充值、消费、订阅扣减、邀请奖励、后台调整等余额变化都写入该表。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|--------------|--------|--------------------------|
|
||||
| `id` | string | 主键 |
|
||||
| `user_id` | string | 关联用户 ID |
|
||||
| `type` | string | 类型:充值、消费、订阅扣减、邀请奖励、后台调整等 |
|
||||
| `amount` | number | 本次变动数量,增加为正,扣减为负 |
|
||||
| `balance` | number | 变动后的用户算力点余额 |
|
||||
| `related_id` | string | 关联订单、任务或日志 ID,可为空 |
|
||||
| `remark` | string | 备注 |
|
||||
| `extra` | json | 扩展信息 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
|
||||
### orders
|
||||
|
||||
订单表。统一记录充值、订阅购买等支付订单。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---------------------|--------|----------------------|
|
||||
| `id` | string | 主键 |
|
||||
| `user_id` | string | 关联用户 ID |
|
||||
| `type` | string | 订单类型:充值、订阅等 |
|
||||
| `provider` | string | 支付渠道:Linux LDC、聚合支付等 |
|
||||
| `amount` | number | 支付金额 |
|
||||
| `credits` | number | 到账算力点 |
|
||||
| `status` | string | 订单状态:待支付、已支付、失败、关闭等 |
|
||||
| `provider_order_id` | string | 第三方订单号 |
|
||||
| `extra` | json | 扩展信息 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `paid_at` | string | 支付时间 |
|
||||
| `updated_at` | string | 更新时间 |
|
||||
|
||||
### subscriptions
|
||||
|
||||
用户订阅表。一个用户可以有多个订阅记录,套餐配置放在 `settings.public.value` 中。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----------------|--------|------------------------------------------|
|
||||
| `id` | string | 主键 |
|
||||
| `user_id` | string | 关联用户 ID |
|
||||
| `plan_key` | string | 套餐标识,对应 `settings.public.value` 中的订阅套餐配置 |
|
||||
| `order_id` | string | 关联订单 ID,可为空 |
|
||||
| `status` | string | 状态:生效中、已过期、已取消等 |
|
||||
| `total_credits` | number | 订阅总额度 |
|
||||
| `used_credits` | number | 已使用额度 |
|
||||
| `started_at` | string | 开始时间 |
|
||||
| `expired_at` | string | 过期时间 |
|
||||
| `extra` | json | 扩展信息 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `updated_at` | string | 更新时间 |
|
||||
|
||||
### files
|
||||
|
||||
文件表。用于统一管理上传图片、视频等文件,保存最终可访问地址。缩略图和视频封面优先按 URL 命名规则推导,特殊情况放在
|
||||
`extra.coverUrl`。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|--------------|--------|-------------|
|
||||
| `id` | string | 主键 |
|
||||
| `user_id` | string | 上传用户 ID,可为空 |
|
||||
| `name` | string | 原始文件名 |
|
||||
| `url` | string | 完整可访问地址 |
|
||||
| `mime_type` | string | MIME 类型 |
|
||||
| `size` | number | 文件大小 |
|
||||
| `extra` | json | 扩展信息 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
|
||||
### canvases
|
||||
|
||||
画布表。保存用户私有画布、公开画布和模板,分享、协作、审核等低频配置放在 `extra`。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------------------|--------|---------------------------------------------|
|
||||
| `id` | string | 主键 |
|
||||
| `user_id` | string | 所属用户 ID |
|
||||
| `title` | string | 画布标题 |
|
||||
| `description` | string | 描述 |
|
||||
| `cover_url` | string | 封面图 |
|
||||
| `data` | json | 画布节点、边、视图等数据 |
|
||||
| `visibility` | string | 可见性:`private`、`public` |
|
||||
| `status` | string | 状态:`draft`、`pending`、`published`、`rejected` |
|
||||
| `is_template` | bool | 是否模板 |
|
||||
| `view_count` | number | 查看量 |
|
||||
| `like_count` | number | 点赞量 |
|
||||
| `favorite_count` | number | 收藏量 |
|
||||
| `copy_count` | number | 复制量 |
|
||||
| `extra` | json | 扩展信息,如分享、协作、审核备注等 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `updated_at` | string | 更新时间 |
|
||||
|
||||
### generation_tasks
|
||||
|
||||
接口调用队列表。用于图片、文本、图生图等后端模型调用的排队、状态和结果记录。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---------------|--------|----------------------|
|
||||
| `id` | string | 主键 |
|
||||
| `user_id` | string | 发起用户 ID |
|
||||
| `type` | string | 任务类型:文本生成、文生图、图生图等 |
|
||||
| `model` | string | 使用模型 |
|
||||
| `channel` | string | 使用渠道 |
|
||||
| `status` | string | 状态:排队中、执行中、成功、失败、取消等 |
|
||||
| `credits` | number | 扣除算力点 |
|
||||
| `input` | json | 请求参数 |
|
||||
| `output` | json | 生成结果 |
|
||||
| `error` | string | 错误信息 |
|
||||
| `extra` | json | 扩展信息 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `started_at` | string | 开始时间 |
|
||||
| `finished_at` | string | 完成时间 |
|
||||
@@ -0,0 +1,282 @@
|
||||
# 画布数据结构
|
||||
|
||||
本文档说明当前画布在前端本地保存的数据结构、图片文件的存储和清理方式,以及后续接入后端存储时建议保持的兼容边界。
|
||||
|
||||
## 当前存储位置
|
||||
|
||||
当前画布项目主要保存在浏览器本地:
|
||||
|
||||
- 画布项目 JSON:`localForage`,数据库名 `infinite-canvas`,storeName `app_state`,key 为 `infinite-canvas:canvas_store`。
|
||||
- 我的素材 JSON:`localForage`,数据库名 `infinite-canvas`,storeName `app_state`,key 为 `infinite-canvas:asset_store`。
|
||||
- 图片 Blob:单独存到 `localForage` 实例,数据库名 `infinite-canvas`,storeName `image_files`。
|
||||
|
||||
画布 JSON 不直接长期保存大体积 base64 图片。图片节点、助手图片和素材图片只保存展示 URL、`storageKey` 和图片元信息,真实图片 Blob 通过 `storageKey` 读取。
|
||||
|
||||
## 画布项目结构
|
||||
|
||||
每个画布项目是一个 `CanvasProject`:
|
||||
|
||||
```ts
|
||||
type CanvasProject = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
nodes: CanvasNodeData[];
|
||||
connections: CanvasConnection[];
|
||||
chatSessions: CanvasAssistantSession[];
|
||||
activeChatId: string | null;
|
||||
backgroundMode: "lines" | "dots" | "blank";
|
||||
viewport: { x: number; y: number; k: number };
|
||||
};
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `id`:画布项目 ID,当前前端生成。
|
||||
- `title`:画布名称。
|
||||
- `createdAt` / `updatedAt`:ISO 字符串。
|
||||
- `nodes`:画布节点列表。
|
||||
- `connections`:节点连线列表。
|
||||
- `chatSessions`:右侧画布助手会话。
|
||||
- `activeChatId`:当前选中的助手会话 ID。
|
||||
- `backgroundMode`:画布背景模式。
|
||||
- `viewport`:视口变换,`x/y` 是屏幕平移,`k` 是缩放比例。
|
||||
|
||||
## 节点结构
|
||||
|
||||
每个节点是一个 `CanvasNodeData`:
|
||||
|
||||
```ts
|
||||
type CanvasNodeData = {
|
||||
id: string;
|
||||
type: "image" | "text" | "config";
|
||||
title: string;
|
||||
position: { x: number; y: number };
|
||||
width: number;
|
||||
height: number;
|
||||
metadata?: CanvasNodeMetadata;
|
||||
};
|
||||
```
|
||||
|
||||
通用字段:
|
||||
|
||||
- `id`:节点 ID。
|
||||
- `type`:节点类型,当前有图片、文本、生成配置三类。
|
||||
- `title`:节点标题。
|
||||
- `position`:画布世界坐标,不是屏幕坐标。
|
||||
- `width` / `height`:画布世界坐标下的节点尺寸。
|
||||
- `metadata`:节点内容和业务状态。
|
||||
|
||||
`metadata` 当前常用字段:
|
||||
|
||||
```ts
|
||||
type CanvasNodeMetadata = {
|
||||
content?: string;
|
||||
prompt?: string;
|
||||
status?: "idle" | "success" | "loading" | "error";
|
||||
errorDetails?: string;
|
||||
fontSize?: number;
|
||||
generationMode?: "text" | "image";
|
||||
model?: string;
|
||||
size?: string;
|
||||
count?: number;
|
||||
naturalWidth?: number;
|
||||
naturalHeight?: number;
|
||||
freeResize?: boolean;
|
||||
isBatchRoot?: boolean;
|
||||
batchRootId?: string;
|
||||
batchChildIds?: string[];
|
||||
primaryImageId?: string;
|
||||
imageBatchExpanded?: boolean;
|
||||
inputOrder?: string[];
|
||||
storageKey?: string;
|
||||
mimeType?: string;
|
||||
bytes?: number;
|
||||
};
|
||||
```
|
||||
|
||||
不同节点的使用方式:
|
||||
|
||||
- 图片节点:`content` 是当前可展示的图片 URL,通常是 `blob:` URL;`storageKey` 指向本地图片 Blob;`naturalWidth/naturalHeight/bytes/mimeType` 保存原图信息。
|
||||
- 文本节点:`content` 保存文本内容;`fontSize` 保存字体大小;`prompt/status/errorDetails` 保存生成状态。
|
||||
- 生成配置节点:`generationMode/model/size/count/inputOrder` 保存生成配置;上游输入通过 `connections` 计算。
|
||||
- 图片组节点:根节点用 `isBatchRoot/batchChildIds/primaryImageId/imageBatchExpanded` 记录批量生成结果;子图节点用 `batchRootId` 指回根节点。
|
||||
|
||||
## 连线结构
|
||||
|
||||
每条连线是一个 `CanvasConnection`:
|
||||
|
||||
```ts
|
||||
type CanvasConnection = {
|
||||
id: string;
|
||||
fromNodeId: string;
|
||||
toNodeId: string;
|
||||
};
|
||||
```
|
||||
|
||||
连线只保存节点 ID,不保存端口坐标。渲染时根据节点位置和尺寸计算路径。
|
||||
|
||||
删除节点时会同步删除以该节点为起点或终点的连线。删除图片组根节点时,会把对应子节点一起删除。
|
||||
|
||||
## 助手会话结构
|
||||
|
||||
助手会话保存在画布项目内:
|
||||
|
||||
```ts
|
||||
type CanvasAssistantSession = {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: CanvasAssistantMessage[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
```
|
||||
|
||||
消息结构:
|
||||
|
||||
```ts
|
||||
type CanvasAssistantMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
mode: "ask" | "image";
|
||||
text: string;
|
||||
isLoading?: boolean;
|
||||
references?: CanvasAssistantReference[];
|
||||
images?: CanvasAssistantImage[];
|
||||
};
|
||||
```
|
||||
|
||||
图片引用和助手生成图片也遵循同一套图片存储规则:
|
||||
|
||||
- `dataUrl` 字段当前可能是 `blob:` URL,也可能是旧数据中的 `data:image/...`。
|
||||
- `storageKey` 存在时,以 `storageKey` 为准读取图片 Blob。
|
||||
- 发送到 AI 接口前,如果接口需要 base64,会通过 `imageToDataUrl` 临时把 Blob URL 转成 data URL。
|
||||
|
||||
## 图片写入流程
|
||||
|
||||
所有新增图片应通过 `uploadImage(input)` 写入:
|
||||
|
||||
1. 传入 `Blob` 或 data URL。
|
||||
2. 内部转成 `Blob`。
|
||||
3. 生成 `storageKey`,格式为 `image:<id>`。
|
||||
4. 把 Blob 写入 `image_files`。
|
||||
5. 创建 `blob:` URL,并缓存在内存 `objectUrls`。
|
||||
6. 读取图片宽高,返回:
|
||||
|
||||
```ts
|
||||
type UploadedImage = {
|
||||
url: string;
|
||||
storageKey: string;
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: number;
|
||||
mimeType: string;
|
||||
};
|
||||
```
|
||||
|
||||
图片节点会通过 `imageMetadata(image)` 写入:
|
||||
|
||||
```ts
|
||||
{
|
||||
content: image.url,
|
||||
storageKey: image.storageKey,
|
||||
status: "success",
|
||||
naturalWidth: image.width,
|
||||
naturalHeight: image.height,
|
||||
bytes: image.bytes,
|
||||
mimeType: image.mimeType
|
||||
}
|
||||
```
|
||||
|
||||
因此,`content` 只适合当前浏览器会话展示,不能作为长期文件标识;长期标识是 `storageKey`。
|
||||
|
||||
## 图片读取和旧数据迁移
|
||||
|
||||
打开画布时会执行图片补水:
|
||||
|
||||
- 如果图片节点有 `storageKey`,通过 `resolveImageUrl(storageKey, fallback)` 读取 Blob 并生成新的 `blob:` URL。
|
||||
- 如果图片节点没有 `storageKey`,但 `content` 是旧的 `data:image/...`,会调用 `uploadImage(content)` 迁移到 `image_files`,并补上 `storageKey`。
|
||||
- 助手消息里的引用图和生成图也会执行同类逻辑。
|
||||
|
||||
我的素材读取时也会做迁移:
|
||||
|
||||
- 有 `storageKey`:恢复 `coverUrl` 和 `data.dataUrl` 的可展示 URL。
|
||||
- 无 `storageKey` 且保存了 base64:写入 `image_files`,然后更新素材里的 `storageKey`。
|
||||
|
||||
## 图片移除和清理
|
||||
|
||||
图片不是在删除节点时立即按节点逐张删除,而是做引用清理:
|
||||
|
||||
1. 删除节点、清空画布、删除画布、删除素材、删除助手会话时,会触发 `cleanupImages`。
|
||||
2. `cleanupImages` 会收集当前仍被画布项目、素材和额外传入数据引用的所有 `storageKey`。
|
||||
3. `cleanupUnusedImages` 遍历 `image_files` 中的全部图片。
|
||||
4. 不在引用集合里的图片会被删除。
|
||||
5. 删除时会同时 `URL.revokeObjectURL`,并从内存缓存 `objectUrls` 移除。
|
||||
|
||||
这套方式可以避免同一张图片被画布、素材或助手同时引用时误删。
|
||||
|
||||
需要注意:
|
||||
|
||||
- 只要某个 JSON 结构里仍有 `storageKey`,清理逻辑就会认为图片仍被使用。
|
||||
- `collectImageStorageKeys` 会递归扫描对象中的 `storageKey` 字段,字段值必须以 `image:` 开头才会被当成本地图片。
|
||||
- 如果后续新增保存图片引用的数据结构,也要确保它能传入清理上下文,或者位于现有项目/素材结构内。
|
||||
|
||||
## 后端存储兼容建议
|
||||
|
||||
后续接入后端时,建议保持“画布 JSON”和“图片文件”分离:
|
||||
|
||||
- 画布表保存项目元信息和画布 JSON。
|
||||
- 文件表保存图片文件、访问 URL、哈希、大小、MIME、宽高、归属用户等信息。
|
||||
- 画布节点中继续保存轻量图片引用,不把图片二进制或 base64 写进画布 JSON。
|
||||
|
||||
建议图片引用逐步扩展为兼容本地和云端的结构:
|
||||
|
||||
```ts
|
||||
type ImageRef = {
|
||||
storageKey?: string;
|
||||
fileId?: string;
|
||||
url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
bytes?: number;
|
||||
mimeType?: string;
|
||||
};
|
||||
```
|
||||
|
||||
兼容规则:
|
||||
|
||||
- 本地旧数据:有 `storageKey`,无 `fileId`,通过 IndexedDB 读取。
|
||||
- 已上传后端:有 `fileId`,展示时优先使用后端返回的签名 URL 或公开 URL。
|
||||
- 迁移过渡期:可以同时保留 `storageKey` 和 `fileId`;确认云端文件可用后,再按清理策略删除本地 Blob。
|
||||
- `content/dataUrl/coverUrl` 仍只作为当前可展示 URL,不作为稳定 ID。
|
||||
|
||||
建议读取优先级:
|
||||
|
||||
1. 有 `fileId`:向后端换取可访问 URL。
|
||||
2. 有 `storageKey`:从本地 IndexedDB 生成 `blob:` URL。
|
||||
3. 有旧 `data:image/...`:先写入本地图片存储,再视需要上传后端。
|
||||
4. 只有普通 URL:直接展示,但不要假设可长期访问。
|
||||
|
||||
建议删除策略:
|
||||
|
||||
- 删除节点只删除画布 JSON 引用,不直接删除后端文件。
|
||||
- 后端文件删除应按引用计数或定期扫描未引用文件处理。
|
||||
- 保存到“我的素材”的图片,即使原画布节点删除,也应继续保留文件引用。
|
||||
- 删除画布、删除素材、删除助手会话后,再由后端清理任务判断文件是否无人引用。
|
||||
|
||||
建议同步流程:
|
||||
|
||||
1. 前端保存画布 JSON 时,保持节点 ID、连线 ID、`storageKey/fileId` 不变。
|
||||
2. 遇到只有 `storageKey` 的图片,后台同步前先上传 Blob,得到 `fileId`。
|
||||
3. 上传成功后给对应图片引用补 `fileId` 和云端元信息。
|
||||
4. 服务端保存更新后的画布 JSON。
|
||||
5. 前端下次打开时优先走 `fileId`,本地 `storageKey` 只作为缓存或离线回退。
|
||||
|
||||
## 后续改动约束
|
||||
|
||||
- 不要把新生成的大图直接长期写入画布 JSON。
|
||||
- 新增图片来源时统一走 `uploadImage` 或未来的文件上传服务。
|
||||
- 新增图片引用字段时,应保留 `storageKey` 兼容旧本地数据。
|
||||
- 新增清理入口时,要把仍需保留的画布、素材、助手数据传给 `cleanupUnusedImages`。
|
||||
- 后端同步完成前,文档和 UI 不要写成已支持云同步。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 画布节点操作手册
|
||||
|
||||
本文档记录画布节点的主要用途和操作流程,方便使用和后续开发维护。当前先介绍文本节点。
|
||||
|
||||
## 文本节点
|
||||
|
||||
文本节点用于保存提示词、草稿、说明文案和 AI 生成的文字结果。它既可以作为独立文本内容,也可以作为下游生成配置节点的输入。
|
||||
|
||||
### 编辑文本内容
|
||||
|
||||
- 双击文本节点内容区域,直接编辑节点里的文字。
|
||||
- 选中文本节点后,点击顶部工具栏的“编辑文字”,进入文字编辑状态。
|
||||
- 顶部工具栏的“缩小”和“放大”用于调整文本节点字号。
|
||||
|
||||
### 用下方对话框生成或修改文本
|
||||
|
||||
- 选中文本节点后,点击顶部工具栏的“编辑”,打开节点下方对话框。
|
||||
- 文本节点下方对话框只用于生成或改写文本,不承担生图功能。
|
||||
- 当文本节点为空时,输入框用于填写想生成的文本内容;点击发送后,结果会回填到当前文本节点。
|
||||
- 当文本节点已有内容时,输入框用于填写想把本段文本修改成什么;点击发送后,会在右侧生成新的文本节点,并自动连接原节点和新节点。
|
||||
- 输入内容可以手写,也可以从提示词库选择。
|
||||
- 对话框里的模型下拉来自全局配置里已拉取的模型列表;选择结果只作用于当前节点,不会修改其它节点或全局默认模型。
|
||||
- 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。
|
||||
|
||||
### 用文本节点生成图片
|
||||
|
||||
- 先在文本节点中准备好要用于生图的文本内容。
|
||||
- 点击文本节点顶部工具栏的“生图”按钮。
|
||||
- 系统会在文本节点右侧自动创建一个生成配置节点,并连接文本节点到生成配置节点。
|
||||
- 生成配置节点会读取上游文本内容作为生图提示词,并立即开始生成图片。
|
||||
- 后续需要调整模型、比例、数量时,可以在生成配置节点里修改后再次生成。
|
||||
|
||||
### 推荐流程
|
||||
|
||||
1. 新建文本节点,写入图片创作想法。
|
||||
2. 打开文本节点下方对话框,让 AI 优化或扩写这段提示词。
|
||||
3. 改写结果会生成到新的文本节点,保留原始文本方便对比。
|
||||
4. 确认文本节点内容后,点击顶部工具栏“生图”。
|
||||
5. 在自动创建的生成配置节点中继续调整图片参数或重新生成。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 画布快捷键
|
||||
|
||||
本文档记录画布里常用的鼠标和键盘操作。
|
||||
|
||||
## 视图
|
||||
|
||||
- 拖动画布空白处:平移视图。
|
||||
- 鼠标滚轮:缩放画布。
|
||||
- 缩放滑杆:精确调整缩放。
|
||||
- 重置视图按钮:回到默认缩放和居中位置。
|
||||
|
||||
## 选择
|
||||
|
||||
- `Ctrl / Cmd` + 拖动:框选多个节点。
|
||||
- `Shift / Ctrl / Cmd` + 点击节点:追加或取消选择节点。
|
||||
- `Ctrl / Cmd` + `A`:全选画布节点。
|
||||
- `Esc`:取消选择,并关闭当前浮层。
|
||||
|
||||
## 编辑
|
||||
|
||||
- `Ctrl / Cmd` + `C`:复制选中节点。
|
||||
- `Ctrl / Cmd` + `V`:粘贴节点。
|
||||
- `Delete / Backspace`:删除选中的节点或连线。
|
||||
- `Ctrl / Cmd` + `Z`:撤销。
|
||||
- `Ctrl / Cmd` + `Shift` + `Z`:重做。
|
||||
- `Ctrl / Cmd` + `Y`:重做。
|
||||
|
||||
## 图片和素材
|
||||
|
||||
- 拖入图片文件:上传图片到画布。
|
||||
- 导入图片按钮:从本地选择图片。
|
||||
- 素材库或我的素材:选择素材后插入画布。
|
||||
|
||||
## 撤销和重做范围
|
||||
|
||||
撤销和重做会记录画布节点、连线、视口、背景模式和助手会话变化。画布项目名称、账号状态、全局 AI 配置和“我的素材”保存操作不属于当前画布历史。
|
||||
@@ -0,0 +1,166 @@
|
||||
# 功能介绍
|
||||
|
||||
本文档记录当前项目已经实现的主要功能。
|
||||
|
||||
## 画布项目
|
||||
|
||||
- 支持创建多个画布项目。
|
||||
- 支持项目重命名、删除、批量选择和批量删除。
|
||||
- 支持单个画布项目导出为 JSON,也支持从 JSON 导入画布。
|
||||
- 画布项目保存在浏览器本地,登录账号后暂不会自动同步到服务器。
|
||||
|
||||
## 无限画布
|
||||
|
||||
- 支持拖动画布、滚轮缩放、缩放滑杆和重置视图。
|
||||
- 支持小地图定位,可开关小地图。
|
||||
- 支持点阵、网格线、空白三种背景。
|
||||
- 支持浅色和深色主题。
|
||||
- 支持框选、多选、全选、取消选择、删除选中。
|
||||
- 支持复制粘贴节点和节点之间的连线。
|
||||
- 支持撤销和重做节点、连线、视口、背景和助手会话变化。
|
||||
- 支持节点连线,并高亮当前节点相关的上下游节点和连线。
|
||||
- 支持快捷键帮助,覆盖缩放、框选、全选、复制粘贴、撤销重做、删除、退出选择和拖入图片。
|
||||
|
||||
## 节点
|
||||
|
||||
目前画布中有三类节点:
|
||||
|
||||
- 图片节点:展示上传图片、生成图片或素材库图片。
|
||||
- 文本节点:保存提示词、说明文案、AI 文字回答等文本内容。
|
||||
- 生成配置节点:汇总上游文本和图片,统一配置模型、比例、数量后批量生成图片或文本。
|
||||
|
||||
节点支持:
|
||||
|
||||
- 拖拽移动。
|
||||
- 四角缩放。
|
||||
- 图片节点等比缩放或自由比例切换。
|
||||
- 查看节点基础信息和 JSON。
|
||||
- 删除、复制、粘贴。
|
||||
- 通过左右连接点建立上下游关系。
|
||||
|
||||
## 图片工作流
|
||||
|
||||
- 支持上传图片到新节点。
|
||||
- 支持拖拽图片文件到画布。
|
||||
- 支持替换已有图片节点内容。
|
||||
- 支持下载图片节点。
|
||||
- 支持把图片节点保存到“我的素材”。
|
||||
- 支持图片裁剪,并把裁剪结果生成为新的图片节点。
|
||||
- 支持本地多角度变换,并把结果生成为新的图片节点。
|
||||
- 支持生成失败后重试。
|
||||
- 批量生成多张图片时会先展示为图片组节点,支持叠卡预览、展开查看全部结果并设置主图。
|
||||
|
||||
## AI 生成
|
||||
|
||||
项目通过前端直接请求 OpenAI 兼容接口:
|
||||
|
||||
- `/v1/images/generations`:文生图。
|
||||
- `/v1/images/edits`:图生图/参考图编辑。
|
||||
- `/v1/chat/completions`:文本问答和带图问答。
|
||||
- `/v1/models`:读取模型列表。
|
||||
|
||||
可配置项:
|
||||
|
||||
- Base URL。
|
||||
- API Key。
|
||||
- 默认模型。
|
||||
- 图片质量。
|
||||
- 图片比例。
|
||||
- 生成数量。
|
||||
|
||||
普通图片/文本节点可以直接输入提示词生成结果。生成配置节点可以读取上游节点内容,并按节点自己的配置批量生成多个图片或文本结果。生成配置节点支持预览当前提示词和参考图输入,并调整输入顺序。
|
||||
|
||||
## 画布助手
|
||||
|
||||
画布右侧助手面板支持:
|
||||
|
||||
- 文本问答。
|
||||
- 生图。
|
||||
- 读取当前选中节点作为引用。
|
||||
- 自动把选中节点的上游节点也纳入引用。
|
||||
- 粘贴图片到助手输入框并插入画布。
|
||||
- 历史会话。
|
||||
- 删除单条或多条会话。
|
||||
- 重试回答。
|
||||
- 把助手生成的文本插入画布。
|
||||
- 把助手生成的图片插入画布。
|
||||
- 折叠和展开助手面板。
|
||||
|
||||
## 提示词库
|
||||
|
||||
前台提示词库支持:
|
||||
|
||||
- 按标题搜索。
|
||||
- 按标签筛选。
|
||||
- 按来源筛选。
|
||||
- 查看提示词详情。
|
||||
- 查看封面和结果图。
|
||||
- 复制提示词。
|
||||
- 把提示词加入“我的素材”。
|
||||
|
||||
后台提示词管理支持:
|
||||
|
||||
- 查询提示词。
|
||||
- 新增、编辑、删除提示词。
|
||||
- 按分组和标签筛选。
|
||||
- 查看远程提示词源。
|
||||
- 同步内置远程提示词源。
|
||||
|
||||
当前内置远程源包括多个 GPT Image / GPT-4o / Nano Banana Pro 相关提示词仓库。
|
||||
|
||||
## 素材
|
||||
|
||||
“我的素材”是浏览器本地素材库,支持:
|
||||
|
||||
- 新增文本素材和图片素材。
|
||||
- 编辑素材标题、封面、标签、来源、备注和内容。
|
||||
- 删除素材。
|
||||
- 按关键词搜索。
|
||||
- 按类型筛选。
|
||||
- 分页浏览。
|
||||
- 复制文本素材。
|
||||
- 下载图片素材。
|
||||
- 从提示词库、画布节点和服务器素材库加入素材。
|
||||
- 在画布中插入素材。
|
||||
|
||||
“素材库”是服务器素材库,支持:
|
||||
|
||||
- 按标题搜索。
|
||||
- 按类型筛选。
|
||||
- 按标签筛选。
|
||||
- 查看素材详情。
|
||||
- 复制文本或图片链接。
|
||||
- 加入“我的素材”。
|
||||
- 在画布中插入素材。
|
||||
|
||||
后台素材库管理支持:
|
||||
|
||||
- 查询素材。
|
||||
- 新增、编辑、删除素材。
|
||||
- 按类型和标签筛选。
|
||||
|
||||
## 账号和后台
|
||||
|
||||
- 注册功能暂时关闭。
|
||||
- 仅允许管理员账号登录。
|
||||
- 支持 JWT 会话。
|
||||
- `/api/auth/me` 可读取当前用户,未登录时返回访客用户。
|
||||
- 首次启动时可根据环境变量创建默认管理员。
|
||||
- 管理员后台目前包含提示词管理和素材库管理。
|
||||
- 后端已有用户管理接口,但前端暂未实现用户管理页面。
|
||||
|
||||
## 后端能力
|
||||
|
||||
- Gin 提供 API 服务。
|
||||
- Docker 运行时由 Go 提供统一入口,`/api/*` 直接处理,其它页面请求转到内部 Next.js 服务。
|
||||
- GORM 管理数据库连接和自动迁移。
|
||||
- 支持 SQLite、MySQL、PostgreSQL。
|
||||
- 数据库保存用户、提示词分组、提示词和服务器素材。
|
||||
- 业务接口统一返回 `{ code, data, msg }`。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 画布项目和“我的素材”目前只保存在浏览器本地,不会随账号同步。
|
||||
- AI API Key 目前保存在浏览器本地,并由浏览器直接请求配置的 OpenAI 兼容接口。
|
||||
- 服务器素材库目前主要保存 URL 或文本,暂未提供文件上传接口。
|
||||
- 画布更适合桌面端使用,移动端触控体验还未系统完善。
|
||||
@@ -0,0 +1 @@
|
||||
# 待测试
|
||||
@@ -0,0 +1,9 @@
|
||||
# 第三方 GitHub 提示词仓库
|
||||
|
||||
| 地址 | 状态 |
|
||||
| --- | --- |
|
||||
| https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts | 已实现同步逻辑 |
|
||||
| https://github.com/ZeroLu/awesome-gpt-image | 已实现同步逻辑 |
|
||||
| https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts | 已实现同步逻辑 |
|
||||
| https://github.com/YouMind-OpenLab/awesome-gpt-image-2 | 已实现同步逻辑 |
|
||||
| https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts | 已实现同步逻辑 |
|
||||
@@ -0,0 +1,49 @@
|
||||
# TODO
|
||||
|
||||
本文档用来记录当前项目后续比较值得处理的事项。
|
||||
|
||||
## P0 近期优先
|
||||
|
||||
暂无。
|
||||
|
||||
## P1 核心账号和后台
|
||||
|
||||
- 登录注册和用户模块:新增 `users` 表,用户角色、算力点余额、邀请码、邀请人、邀请人数和第三方平台用户 ID
|
||||
等直接放在用户表字段里;补齐登录、注册、第三方登录、用户信息、管理员用户管理等基础能力。
|
||||
- 系统设置和模型配置:新增 `settings` 表,只保存 `public` 和 `private` 两行 JSON 配置,用于系统可用模型、模型渠道、系统提示词、用户是否允许自定义模型等后台开关。
|
||||
- 字典管理:新增 `dicts` 表,一个字典一行,具体字典项用 JSON 保存;用于维护分类、标签、业务枚举、模型分类、日志类型等可配置项。
|
||||
- 日志管理:新增 `credit_logs` 记录用户算力点变更流水,新增 `api_logs` 记录第三方大模型 API 调用情况。
|
||||
- 对话工具调用:在助手对话中增加类似工具调用的形式,展示模型调用、生成图片、插入画布等步骤。
|
||||
- 画布助手对话框优化:把当前对话面板调整得更简洁,减少干扰,突出输入、引用和结果。
|
||||
- 考虑引入视频生成能力
|
||||
|
||||
## P1 算力点和商业化
|
||||
|
||||
- 算力点模块:使用 `users` 表里的算力点余额字段,用于调用后端生图、文本等接口时扣除;扣除记录写入 `credit_logs`。
|
||||
- 算力点订阅模块:订阅套餐配置先放 `settings.public.value`,用户订阅记录新增 `subscriptions`
|
||||
表;用户购买订阅后,模型调用优先从可用订阅额度扣除,不足时再扣用户余额。
|
||||
- 支付模块:新增 `orders` 表,统一记录充值、订阅购买等订单,预留 Linux LDC 支付和第三方聚合支付;该模块优先级较低,先只设计结构,暂不重点实现。
|
||||
- 邀请奖励模块:邀请码、邀请人和邀请人数放 `users`,注册奖励、充值奖励配置放 `settings.private.value`,奖励发放记录写入
|
||||
`credit_logs`。
|
||||
|
||||
## P1 内容、素材和画布
|
||||
|
||||
- 文件存储管理:新增 `files` 表和存储接口,文件表只记录最终可访问 URL 等基础信息;后续再考虑图片缩略图、文件清理等能力,后台可查询所有图片和文件。
|
||||
- 提示词管理:新增 `prompts` 表,公开提示词、内置 GitHub 系统提示词、分类和扩展信息等尽量放字段或 JSON,支持后台维护和随时爬取更新。
|
||||
- 提示词分类:已移除 `prompt_groups` 表;分类数据很少,并且必须和抓取代码一一对应,直接在代码内存里写死维护。
|
||||
- 画布管理:新增 `canvases` 表,后台管理员可以查看每个人的画布数据,普通用户可以查看自己的画布列表。
|
||||
- 公开画布:在 `canvases` 表增加公开状态、审核状态、公开信息等字段;管理员可将自己的画布设为公开,用户也可以申请公开并由管理员审核。
|
||||
- 画布分享与模板:在云端画布基础上支持只读分享、复制为模板、团队内共享,分享配置和模板标记优先放在 `canvases` 字段或 JSON 中。
|
||||
- 项目库:基于公开画布增加系统公开项目库,用于筛选、打开、复制公开画布项目。
|
||||
- 画布协作能力:后续如果支持多人协作,协作成员、节点操作事件、锁定/光标、冲突处理和增量同步优先放在 `canvases` 的协作 JSON
|
||||
中,确实不够用时再拆表。
|
||||
- 素材管理和我的素材:新增 `assets` 表,用 `user_id`、`visibility`、`type` 区分系统公开素材和用户私有素材;管理员可管理公开素材,也可以查看每个用户自己的素材。
|
||||
- 素材互动:公开素材增加点赞量、收藏量、查看量等统计字段;用户侧我的收藏、我的点赞先放在 `assets` 或用户偏好 JSON
|
||||
中,后续数据量大了再拆互动表。
|
||||
|
||||
## P2 体验和工程
|
||||
|
||||
- 移动端适配:先不整体展开,只围绕在线生图等移动端必要页面做基础适配。
|
||||
- 接口调用队列:图片/文本生成接口增加队列、并发限制、后台状态和基础失败处理,避免大量请求直接打到模型渠道。
|
||||
- README 优化:保持 README 简洁,补项目介绍、核心功能、快速开始和文档入口。
|
||||
- 版本发布流程:规范版本号、changelog、release 脚本、镜像 tag 规则和升级说明。
|
||||
@@ -0,0 +1,64 @@
|
||||
module github.com/basketikun/infinite-canvas
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/caarlos0/env/v11 v11.3.1
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.48.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,138 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
||||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
@@ -0,0 +1,60 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/service"
|
||||
)
|
||||
|
||||
type adminSyncRequest struct {
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
func AdminPromptCategories(w http.ResponseWriter, r *http.Request) {
|
||||
OK(w, service.ListPromptCategories())
|
||||
}
|
||||
|
||||
func AdminPrompts(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := service.ListPrompts(parseQuery(r))
|
||||
if err != nil {
|
||||
Fail(w, err.Error())
|
||||
return
|
||||
}
|
||||
OK(w, result)
|
||||
}
|
||||
|
||||
func AdminSavePrompt(w http.ResponseWriter, r *http.Request) {
|
||||
var item model.Prompt
|
||||
_ = json.NewDecoder(r.Body).Decode(&item)
|
||||
result, err := service.SavePrompt(item)
|
||||
if err != nil {
|
||||
Fail(w, err.Error())
|
||||
return
|
||||
}
|
||||
OK(w, result)
|
||||
}
|
||||
|
||||
func AdminDeletePrompt(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if err := service.DeletePrompt(id); err != nil {
|
||||
Fail(w, err.Error())
|
||||
return
|
||||
}
|
||||
OK(w, true)
|
||||
}
|
||||
|
||||
func AdminSyncPromptCategories(w http.ResponseWriter, r *http.Request) {
|
||||
var request adminSyncRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
log.Printf("sync prompt category start category=%s", request.Category)
|
||||
categories, err := service.SyncPromptCategory(request.Category)
|
||||
if err != nil {
|
||||
log.Printf("sync prompt category failed category=%s err=%v", request.Category, err)
|
||||
Fail(w, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("sync prompt category done category=%s", request.Category)
|
||||
OK(w, categories)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/service"
|
||||
)
|
||||
|
||||
func Assets(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := service.ListAssets(parseQuery(r))
|
||||
if err != nil {
|
||||
Fail(w, err.Error())
|
||||
return
|
||||
}
|
||||
OK(w, result)
|
||||
}
|
||||
|
||||
func AdminAssets(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := service.ListAssets(parseQuery(r))
|
||||
if err != nil {
|
||||
Fail(w, err.Error())
|
||||
return
|
||||
}
|
||||
OK(w, result)
|
||||
}
|
||||
|
||||
func AdminSaveAsset(w http.ResponseWriter, r *http.Request) {
|
||||
var item model.Asset
|
||||
_ = json.NewDecoder(r.Body).Decode(&item)
|
||||
result, err := service.SaveAsset(item)
|
||||
if err != nil {
|
||||
Fail(w, err.Error())
|
||||
return
|
||||
}
|
||||
OK(w, result)
|
||||
}
|
||||
|
||||
func AdminDeleteAsset(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if err := service.DeleteAsset(id); err != nil {
|
||||
Fail(w, err.Error())
|
||||
return
|
||||
}
|
||||
OK(w, true)
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/service"
|
||||
)
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type saveUserRequest struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role model.UserRole `json:"role"`
|
||||
}
|
||||
|
||||
func Register(w http.ResponseWriter, r *http.Request) {
|
||||
var request registerRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
session, err := service.Register(request.Username, request.Password)
|
||||
if err != nil {
|
||||
Fail(w, err.Error())
|
||||
return
|
||||
}
|
||||
OK(w, session)
|
||||
}
|
||||
|
||||
func Login(w http.ResponseWriter, r *http.Request) {
|
||||
var request loginRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
session, err := service.Login(request.Username, request.Password)
|
||||
if err != nil {
|
||||
Fail(w, err.Error())
|
||||
return
|
||||
}
|
||||
if session.User.Role != model.UserRoleAdmin {
|
||||
Fail(w, "需要管理员权限")
|
||||
return
|
||||
}
|
||||
OK(w, session)
|
||||
}
|
||||
|
||||
func AdminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var request loginRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
session, err := service.Login(request.Username, request.Password)
|
||||
if err != nil {
|
||||
Fail(w, err.Error())
|
||||
return
|
||||
}
|
||||
if session.User.Role != model.UserRoleAdmin {
|
||||
Fail(w, "需要管理员权限")
|
||||
return
|
||||
}
|
||||
OK(w, session)
|
||||
}
|
||||
|
||||
func CurrentUser(w http.ResponseWriter, r *http.Request) {
|
||||
if user, ok := service.UserFromContext(r.Context()); ok {
|
||||
OK(w, user)
|
||||
return
|
||||
}
|
||||
OK(w, service.GuestUser())
|
||||
}
|
||||
|
||||
func AdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := service.ListUsers(parseQuery(r))
|
||||
if err != nil {
|
||||
Fail(w, err.Error())
|
||||
return
|
||||
}
|
||||
OK(w, users)
|
||||
}
|
||||
|
||||
func AdminSaveUser(w http.ResponseWriter, r *http.Request) {
|
||||
var request saveUserRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
user, err := service.SaveUser(model.User{
|
||||
ID: request.ID,
|
||||
Username: request.Username,
|
||||
Role: request.Role,
|
||||
}, request.Password)
|
||||
if err != nil {
|
||||
Fail(w, err.Error())
|
||||
return
|
||||
}
|
||||
OK(w, user)
|
||||
}
|
||||
|
||||
func AdminDeleteUser(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if err := service.DeleteUser(id); err != nil {
|
||||
Fail(w, err.Error())
|
||||
return
|
||||
}
|
||||
OK(w, true)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/service"
|
||||
)
|
||||
|
||||
func Prompts(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := service.ListPrompts(parseQuery(r))
|
||||
if err != nil {
|
||||
Fail(w, err.Error())
|
||||
return
|
||||
}
|
||||
OK(w, result)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
)
|
||||
|
||||
type response struct {
|
||||
Code int `json:"code"`
|
||||
Data any `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
func OK(w http.ResponseWriter, data any) {
|
||||
writeJSON(w, response{Code: 0, Data: data, Msg: "ok"})
|
||||
}
|
||||
|
||||
func Fail(w http.ResponseWriter, msg string) {
|
||||
writeJSON(w, response{Code: 1, Data: nil, Msg: msg})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func parseQuery(r *http.Request) model.Query {
|
||||
q := r.URL.Query()
|
||||
page, _ := strconv.Atoi(q.Get("page"))
|
||||
pageSize, _ := strconv.Atoi(q.Get("pageSize"))
|
||||
return model.Query{
|
||||
Keyword: q.Get("keyword"),
|
||||
Tags: q["tag"],
|
||||
Category: q.Get("category"),
|
||||
Type: q.Get("type"),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/config"
|
||||
"github.com/basketikun/infinite-canvas/router"
|
||||
"github.com/basketikun/infinite-canvas/service"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := config.Load(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := service.EnsureDefaultAdmin(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Fatal(router.New().Run(":" + config.Cfg.Port))
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/handler"
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AdminAuth(c *gin.Context) {
|
||||
user, ok := authUser(c)
|
||||
if !ok || user.Role != model.UserRoleAdmin {
|
||||
handler.Fail(c.Writer, "未登录或权限不足")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Request = c.Request.WithContext(service.WithUser(c.Request.Context(), user))
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func OptionalAuth(c *gin.Context) {
|
||||
if user, ok := authUser(c); ok {
|
||||
c.Request = c.Request.WithContext(service.WithUser(c.Request.Context(), user))
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func NotFoundJSON(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 1, "data": nil, "msg": "接口不存在"})
|
||||
}
|
||||
|
||||
func authUser(c *gin.Context) (model.AuthUser, bool) {
|
||||
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return model.AuthUser{}, false
|
||||
}
|
||||
return service.CurrentAuthUser(token)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
type AssetType string
|
||||
|
||||
const (
|
||||
AssetTypeText AssetType = "text"
|
||||
AssetTypeImage AssetType = "image"
|
||||
)
|
||||
|
||||
// Asset 素材记录。
|
||||
type Asset struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
Title string `json:"title"`
|
||||
Type AssetType `json:"type"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Tags []string `json:"tags" gorm:"serializer:json"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// AssetList 素材分页结果。
|
||||
type AssetList struct {
|
||||
Items []Asset `json:"items"`
|
||||
Tags []string `json:"tags"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package model
|
||||
|
||||
// Prompt 提示词记录。
|
||||
type Prompt struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
Title string `json:"title"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Prompt string `json:"prompt"`
|
||||
Tags []string `json:"tags" gorm:"serializer:json"`
|
||||
Category string `json:"category" gorm:"index"`
|
||||
GithubURL string `json:"githubUrl" gorm:"-"`
|
||||
Preview string `json:"preview"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// PromptList 提示词分页结果。
|
||||
type PromptList struct {
|
||||
Items []Prompt `json:"items"`
|
||||
Tags []string `json:"tags"`
|
||||
Categories []string `json:"categories"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// PromptCategory 提示词分类。
|
||||
type PromptCategory struct {
|
||||
Category string `json:"category" gorm:"primaryKey"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
GithubURL string `json:"githubUrl"`
|
||||
Remote bool `json:"remote"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package model
|
||||
|
||||
const MaxPageSize = 500
|
||||
|
||||
// Query 列表筛选和分页参数。
|
||||
type Query struct {
|
||||
Keyword string
|
||||
Tags []string
|
||||
Category string
|
||||
Type string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func (q *Query) Normalize() {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.PageSize < 1 {
|
||||
q.PageSize = 20
|
||||
}
|
||||
if q.PageSize > MaxPageSize {
|
||||
q.PageSize = MaxPageSize
|
||||
}
|
||||
}
|
||||
|
||||
func (q *Query) Offset() int {
|
||||
return (q.Page - 1) * q.PageSize
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package model
|
||||
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
UserRoleGuest UserRole = "guest"
|
||||
UserRoleUser UserRole = "user"
|
||||
UserRoleAdmin UserRole = "admin"
|
||||
)
|
||||
|
||||
// User 系统用户。
|
||||
type User struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
Username string `json:"username" gorm:"uniqueIndex"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Role UserRole `json:"role"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// UserList 用户分页结果。
|
||||
type UserList struct {
|
||||
Items []User `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// AuthUser 用户公开信息。
|
||||
type AuthUser struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role UserRole `json:"role"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// AuthSession 登录会话信息。
|
||||
type AuthSession struct {
|
||||
Token string `json:"token"`
|
||||
User AuthUser `json:"user"`
|
||||
}
|
||||
|
||||
func PublicUser(user User) AuthUser {
|
||||
return AuthUser{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ListAssets 按查询条件返回素材分页列表。
|
||||
func ListAssets(q model.Query) ([]model.Asset, int64, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q.Normalize()
|
||||
tx := applyAssetFilters(db.Model(&model.Asset{}), q)
|
||||
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var items []model.Asset
|
||||
err = tx.Order("updated_at desc").Offset(q.Offset()).Limit(q.PageSize).Find(&items).Error
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
// ListAssetTags 返回当前素材查询条件下的全部标签。
|
||||
func ListAssetTags(q model.Query) ([]string, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.Normalize()
|
||||
q.Tags = nil
|
||||
tx := applyAssetFilters(db.Model(&model.Asset{}), q)
|
||||
|
||||
var items []model.Asset
|
||||
if err := tx.Select("tags").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return assetTagsFromItems(items), nil
|
||||
}
|
||||
|
||||
// SaveAsset 保存素材,并在更新时保留原创建时间。
|
||||
func SaveAsset(item model.Asset) (model.Asset, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
if saved, ok, err := findAsset(db, item.ID); err != nil {
|
||||
return item, err
|
||||
} else if ok && item.CreatedAt == "" {
|
||||
item.CreatedAt = saved.CreatedAt
|
||||
}
|
||||
return item, db.Save(&item).Error
|
||||
}
|
||||
|
||||
// DeleteAsset 删除指定素材。
|
||||
func DeleteAsset(id string) error {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Delete(&model.Asset{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// applyAssetFilters 应用素材列表的搜索条件。
|
||||
func applyAssetFilters(tx *gorm.DB, q model.Query) *gorm.DB {
|
||||
if q.Keyword != "" {
|
||||
like := "%" + q.Keyword + "%"
|
||||
tx = tx.Where("title LIKE ? OR description LIKE ? OR content LIKE ?", like, like, like)
|
||||
}
|
||||
if isActiveAssetOption(q.Type) {
|
||||
tx = tx.Where("type = ?", q.Type)
|
||||
}
|
||||
return applyAssetTagsFilter(tx, q.Tags)
|
||||
}
|
||||
|
||||
// findAsset 根据 ID 查询素材。
|
||||
func findAsset(db *gorm.DB, id string) (model.Asset, bool, error) {
|
||||
item := model.Asset{}
|
||||
err := db.Where("id = ?", id).First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.Asset{}, false, nil
|
||||
}
|
||||
return item, err == nil, err
|
||||
}
|
||||
|
||||
// applyAssetTagsFilter 应用 JSON 标签条件。
|
||||
func applyAssetTagsFilter(tx *gorm.DB, tags []string) *gorm.DB {
|
||||
if len(tags) == 0 {
|
||||
return tx
|
||||
}
|
||||
condition := tx.Session(&gorm.Session{NewDB: true})
|
||||
for _, tag := range tags {
|
||||
condition = condition.Or(assetJSONTagsContains(tx), tag)
|
||||
}
|
||||
return tx.Where(condition)
|
||||
}
|
||||
|
||||
func assetTagsFromItems(items []model.Asset) []string {
|
||||
seen := map[string]bool{}
|
||||
tags := []string{}
|
||||
for _, item := range items {
|
||||
for _, tag := range item.Tags {
|
||||
if tag != "" && !seen[tag] {
|
||||
seen[tag] = true
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// assetJSONTagsContains 返回素材 tags 的 JSON 包含条件。
|
||||
func assetJSONTagsContains(tx *gorm.DB) string {
|
||||
switch tx.Dialector.Name() {
|
||||
case "mysql":
|
||||
return "JSON_CONTAINS(tags, JSON_QUOTE(?))"
|
||||
case "postgres":
|
||||
return "jsonb_exists(tags::jsonb, ?)"
|
||||
default:
|
||||
return "EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)"
|
||||
}
|
||||
}
|
||||
|
||||
// isActiveAssetOption 判断素材筛选项有效状态。
|
||||
func isActiveAssetOption(value string) bool {
|
||||
return value != "" && value != "全部" && value != "all"
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/config"
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var promptCategories = []model.PromptCategory{
|
||||
{Category: "system", Name: "系统", Description: "系统提示词分类"},
|
||||
{Category: "gpt-image-2-prompts", Name: "GPT Image 2 Prompts", Description: "EvoLinkAI 的 GPT Image 2 案例提示词分类", GithubURL: "https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts", Remote: true},
|
||||
{Category: "awesome-gpt-image", Name: "Awesome GPT Image", Description: "ZeroLu 的中文 GPT Image 提示词分类", GithubURL: "https://github.com/ZeroLu/awesome-gpt-image", Remote: true},
|
||||
{Category: "awesome-gpt4o-image-prompts", Name: "Awesome GPT4o Image Prompts", Description: "ImgEdify 的 GPT-4o 图像提示词分类", GithubURL: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", Remote: true},
|
||||
{Category: "youmind-gpt-image-2", Name: "YouMind GPT Image 2", Description: "YouMind OpenLab 的 GPT Image 2 中文提示词分类", GithubURL: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", Remote: true},
|
||||
{Category: "youmind-nano-banana-pro", Name: "YouMind Nano Banana Pro", Description: "YouMind OpenLab 的 Nano Banana Pro 中文提示词分类", GithubURL: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", Remote: true},
|
||||
}
|
||||
|
||||
var (
|
||||
db *gorm.DB
|
||||
dbOnce sync.Once
|
||||
dbErr error
|
||||
)
|
||||
|
||||
// DB 初始化并返回全局数据库连接。
|
||||
func DB() (*gorm.DB, error) {
|
||||
dbOnce.Do(func() {
|
||||
driver := strings.ToLower(strings.TrimSpace(config.Cfg.StorageDriver))
|
||||
if driver == "" {
|
||||
driver = "sqlite"
|
||||
}
|
||||
dsn := config.Cfg.DatabaseDSN
|
||||
if driver == "sqlite" && dsn != ":memory:" {
|
||||
_ = os.MkdirAll(filepath.Dir(dsn), 0755)
|
||||
}
|
||||
db, dbErr = gorm.Open(dialector(driver, dsn), &gorm.Config{})
|
||||
if dbErr != nil {
|
||||
return
|
||||
}
|
||||
dbErr = db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.Prompt{},
|
||||
&model.Asset{},
|
||||
)
|
||||
})
|
||||
return db, dbErr
|
||||
}
|
||||
|
||||
func dialector(driver string, dsn string) gorm.Dialector {
|
||||
switch driver {
|
||||
case "mysql":
|
||||
return mysql.Open(dsn)
|
||||
case "postgres", "postgresql":
|
||||
return postgres.Open(dsn)
|
||||
default:
|
||||
return sqlite.Open(dsn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PromptCategories 返回内置提示词分类的副本。
|
||||
func PromptCategories() []model.PromptCategory {
|
||||
result := make([]model.PromptCategory, len(promptCategories))
|
||||
copy(result, promptCategories)
|
||||
return result
|
||||
}
|
||||
|
||||
// PromptCategoryByCode 根据分类编码查找内置提示词分类。
|
||||
func PromptCategoryByCode(category string) (model.PromptCategory, bool) {
|
||||
for _, item := range promptCategories {
|
||||
if item.Category == category {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return model.PromptCategory{}, false
|
||||
}
|
||||
|
||||
// ListPromptCategories 返回内置提示词分类。
|
||||
func ListPromptCategories() ([]model.PromptCategory, error) {
|
||||
return PromptCategories(), nil
|
||||
}
|
||||
|
||||
// ListPrompts 按查询条件返回提示词分页列表。
|
||||
func ListPrompts(q model.Query) ([]model.Prompt, int64, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q.Normalize()
|
||||
tx := applyPromptFilters(db.Model(&model.Prompt{}), q)
|
||||
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var items []model.Prompt
|
||||
if err := tx.Order("updated_at desc").Offset(q.Offset()).Limit(q.PageSize).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
categories, _ := ListPromptCategories()
|
||||
githubURLs := map[string]string{}
|
||||
for _, item := range categories {
|
||||
githubURLs[item.Category] = item.GithubURL
|
||||
}
|
||||
for i := range items {
|
||||
items[i].GithubURL = githubURLs[items[i].Category]
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// ListPromptTags 返回当前提示词查询条件下的全部标签。
|
||||
func ListPromptTags(q model.Query) ([]string, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.Normalize()
|
||||
q.Tags = nil
|
||||
tx := applyPromptFilters(db.Model(&model.Prompt{}), q)
|
||||
|
||||
var items []model.Prompt
|
||||
if err := tx.Select("tags").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return promptTagsFromItems(items), nil
|
||||
}
|
||||
|
||||
// SavePrompt 保存提示词,并在更新时保留原创建时间。
|
||||
func SavePrompt(item model.Prompt) (model.Prompt, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
if saved, ok, err := findPrompt(db, item.ID); err != nil {
|
||||
return item, err
|
||||
} else if ok && item.CreatedAt == "" {
|
||||
item.CreatedAt = saved.CreatedAt
|
||||
}
|
||||
item.GithubURL = ""
|
||||
return item, db.Save(&item).Error
|
||||
}
|
||||
|
||||
// DeletePrompt 删除指定提示词。
|
||||
func DeletePrompt(id string) error {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Delete(&model.Prompt{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// ReplacePromptCategory 用远程同步结果替换整个提示词分类。
|
||||
func ReplacePromptCategory(category model.PromptCategory, items []model.Prompt) error {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("category = ?", category.Category).Delete(&model.Prompt{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range items {
|
||||
items[i].Category = category.Category
|
||||
items[i].GithubURL = ""
|
||||
}
|
||||
return tx.Create(&items).Error
|
||||
})
|
||||
}
|
||||
|
||||
// applyPromptFilters 应用提示词列表的搜索条件。
|
||||
func applyPromptFilters(tx *gorm.DB, q model.Query) *gorm.DB {
|
||||
if q.Keyword != "" {
|
||||
like := "%" + q.Keyword + "%"
|
||||
tx = tx.Where("title LIKE ? OR prompt LIKE ?", like, like)
|
||||
}
|
||||
if isActivePromptOption(q.Category) {
|
||||
tx = tx.Where("category = ?", q.Category)
|
||||
}
|
||||
return applyPromptTagsFilter(tx, q.Tags)
|
||||
}
|
||||
|
||||
// findPrompt 根据 ID 查询提示词。
|
||||
func findPrompt(db *gorm.DB, id string) (model.Prompt, bool, error) {
|
||||
item := model.Prompt{}
|
||||
err := db.Where("id = ?", id).First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.Prompt{}, false, nil
|
||||
}
|
||||
return item, err == nil, err
|
||||
}
|
||||
|
||||
// applyPromptTagsFilter 应用 JSON 标签条件。
|
||||
func applyPromptTagsFilter(tx *gorm.DB, tags []string) *gorm.DB {
|
||||
if len(tags) == 0 {
|
||||
return tx
|
||||
}
|
||||
condition := tx.Session(&gorm.Session{NewDB: true})
|
||||
for _, tag := range tags {
|
||||
condition = condition.Or(promptJSONTagsContains(tx), tag)
|
||||
}
|
||||
return tx.Where(condition)
|
||||
}
|
||||
|
||||
func promptTagsFromItems(items []model.Prompt) []string {
|
||||
seen := map[string]bool{}
|
||||
tags := []string{}
|
||||
for _, item := range items {
|
||||
for _, tag := range item.Tags {
|
||||
if tag != "" && !seen[tag] {
|
||||
seen[tag] = true
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// promptJSONTagsContains 返回提示词 tags 的 JSON 包含条件。
|
||||
func promptJSONTagsContains(tx *gorm.DB) string {
|
||||
switch tx.Dialector.Name() {
|
||||
case "mysql":
|
||||
return "JSON_CONTAINS(tags, JSON_QUOTE(?))"
|
||||
case "postgres":
|
||||
return "jsonb_exists(tags::jsonb, ?)"
|
||||
default:
|
||||
return "EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)"
|
||||
}
|
||||
}
|
||||
|
||||
// isActivePromptOption 判断提示词筛选项有效状态。
|
||||
func isActivePromptOption(value string) bool {
|
||||
return value != "" && value != "全部" && value != "all"
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ListUsers 分页查询用户。
|
||||
func ListUsers(q model.Query) ([]model.User, int64, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q.Normalize()
|
||||
tx := db.Model(&model.User{})
|
||||
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var users []model.User
|
||||
err = tx.Order("created_at desc").Offset(q.Offset()).Limit(q.PageSize).Find(&users).Error
|
||||
return users, total, err
|
||||
}
|
||||
|
||||
// CountUsers 返回用户总数。
|
||||
func CountUsers() (int64, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
return total, db.Model(&model.User{}).Count(&total).Error
|
||||
}
|
||||
|
||||
// HasAdmin 判断系统中是否存在管理员。
|
||||
func HasAdmin() (bool, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var total int64
|
||||
err = db.Model(&model.User{}).Where("role = ?", model.UserRoleAdmin).Count(&total).Error
|
||||
return total > 0, err
|
||||
}
|
||||
|
||||
// GetUserByID 根据 ID 查询用户。
|
||||
func GetUserByID(id string) (model.User, bool, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return model.User{}, false, err
|
||||
}
|
||||
return findUser(db, "id = ?", id)
|
||||
}
|
||||
|
||||
// GetUserByUsername 根据用户名查询用户。
|
||||
func GetUserByUsername(username string) (model.User, bool, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return model.User{}, false, err
|
||||
}
|
||||
return findUser(db, "username = ?", username)
|
||||
}
|
||||
|
||||
// SaveUser 保存用户信息。
|
||||
func SaveUser(user model.User) (model.User, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
return user, db.Save(&user).Error
|
||||
}
|
||||
|
||||
// DeleteUser 删除指定用户。
|
||||
func DeleteUser(id string) error {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Delete(&model.User{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// findUser 查询单个用户,并将未命中转换为 ok=false。
|
||||
func findUser(db *gorm.DB, query string, args ...any) (model.User, bool, error) {
|
||||
user := model.User{}
|
||||
err := db.Where(query, args...).First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, false, nil
|
||||
}
|
||||
return user, err == nil, err
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/handler"
|
||||
"github.com/basketikun/infinite-canvas/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const webBaseURL = "http://127.0.0.1:3001"
|
||||
|
||||
func New() *gin.Engine {
|
||||
router := gin.Default()
|
||||
router.RedirectTrailingSlash = false
|
||||
_ = router.SetTrustedProxies(nil)
|
||||
api := router.Group("/api")
|
||||
api.GET("/health", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
api.POST("/auth/register", gin.WrapF(handler.Register))
|
||||
api.POST("/auth/login", gin.WrapF(handler.Login))
|
||||
api.GET("/auth/me", middleware.OptionalAuth, gin.WrapF(handler.CurrentUser))
|
||||
api.GET("/prompts", middleware.OptionalAuth, gin.WrapF(handler.Prompts))
|
||||
api.GET("/assets", middleware.OptionalAuth, gin.WrapF(handler.Assets))
|
||||
api.POST("/admin/login", gin.WrapF(handler.AdminLogin))
|
||||
|
||||
admin := api.Group("/admin", middleware.AdminAuth)
|
||||
admin.GET("/users", gin.WrapF(handler.AdminUsers))
|
||||
admin.POST("/users", gin.WrapF(handler.AdminSaveUser))
|
||||
admin.DELETE("/users/:id", func(c *gin.Context) {
|
||||
handler.AdminDeleteUser(c.Writer, c.Request, c.Param("id"))
|
||||
})
|
||||
admin.GET("/prompt-categories", gin.WrapF(handler.AdminPromptCategories))
|
||||
admin.POST("/prompt-categories/sync", gin.WrapF(handler.AdminSyncPromptCategories))
|
||||
admin.GET("/prompts", gin.WrapF(handler.AdminPrompts))
|
||||
admin.POST("/prompts", gin.WrapF(handler.AdminSavePrompt))
|
||||
admin.DELETE("/prompts/:id", func(c *gin.Context) {
|
||||
handler.AdminDeletePrompt(c.Writer, c.Request, c.Param("id"))
|
||||
})
|
||||
admin.GET("/assets", gin.WrapF(handler.AdminAssets))
|
||||
admin.POST("/assets", gin.WrapF(handler.AdminSaveAsset))
|
||||
admin.DELETE("/assets/:id", func(c *gin.Context) {
|
||||
handler.AdminDeleteAsset(c.Writer, c.Request, c.Param("id"))
|
||||
})
|
||||
|
||||
webURL, _ := url.Parse(webBaseURL)
|
||||
webProxy := httputil.NewSingleHostReverseProxy(webURL)
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if path == "/api" || strings.HasPrefix(path, "/api/") {
|
||||
middleware.NotFoundJSON(c)
|
||||
return
|
||||
}
|
||||
webProxy.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/repository"
|
||||
)
|
||||
|
||||
func ListAssets(q model.Query) (model.AssetList, error) {
|
||||
items, total, err := repository.ListAssets(q)
|
||||
if err != nil {
|
||||
return model.AssetList{}, err
|
||||
}
|
||||
tags, err := repository.ListAssetTags(q)
|
||||
if err != nil {
|
||||
return model.AssetList{}, err
|
||||
}
|
||||
return model.AssetList{Items: items, Tags: tags, Total: int(total)}, nil
|
||||
}
|
||||
|
||||
func SaveAsset(item model.Asset) (model.Asset, error) {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
if item.Type == "" {
|
||||
item.Type = model.AssetTypeText
|
||||
}
|
||||
if item.ID == "" {
|
||||
item.ID = newID("asset")
|
||||
item.CreatedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
if item.CoverURL == "" {
|
||||
item.CoverURL = assetCoverURL(item)
|
||||
}
|
||||
return repository.SaveAsset(item)
|
||||
}
|
||||
|
||||
func DeleteAsset(id string) error {
|
||||
return repository.DeleteAsset(id)
|
||||
}
|
||||
|
||||
func assetCoverURL(item model.Asset) string {
|
||||
if item.CoverURL != "" {
|
||||
return item.CoverURL
|
||||
}
|
||||
if item.Type == model.AssetTypeImage {
|
||||
return item.URL
|
||||
}
|
||||
return ""
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/config"
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/repository"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type TokenClaims struct {
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Role model.UserRole `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func EnsureDefaultAdmin() error {
|
||||
if strings.TrimSpace(config.Cfg.AdminUsername) == "" || strings.TrimSpace(config.Cfg.AdminPassword) == "" {
|
||||
return nil
|
||||
}
|
||||
WarnDefaultSecurityConfig()
|
||||
hasAdmin, err := repository.HasAdmin()
|
||||
if err != nil || hasAdmin {
|
||||
return err
|
||||
}
|
||||
hash, err := hashPassword(config.Cfg.AdminPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = repository.SaveUser(model.User{
|
||||
ID: newID("user"),
|
||||
Username: strings.TrimSpace(config.Cfg.AdminUsername),
|
||||
Password: hash,
|
||||
Role: model.UserRoleAdmin,
|
||||
CreatedAt: now(),
|
||||
UpdatedAt: now(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func Register(username string, password string) (model.AuthSession, error) {
|
||||
return model.AuthSession{}, errors.New("注册功能暂时关闭")
|
||||
username = strings.TrimSpace(username)
|
||||
if strings.ContainsAny(username, " \t\r\n") {
|
||||
return model.AuthSession{}, errors.New("用户名不能包含空格")
|
||||
}
|
||||
if username == "" || password == "" {
|
||||
return model.AuthSession{}, errors.New("用户名和密码不能为空")
|
||||
}
|
||||
if _, ok, err := repository.GetUserByUsername(username); err != nil || ok {
|
||||
if err != nil {
|
||||
return model.AuthSession{}, err
|
||||
}
|
||||
return model.AuthSession{}, errors.New("用户名已存在")
|
||||
}
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return model.AuthSession{}, err
|
||||
}
|
||||
user, err := repository.SaveUser(model.User{
|
||||
ID: newID("user"),
|
||||
Username: username,
|
||||
Password: hash,
|
||||
Role: model.UserRoleUser,
|
||||
CreatedAt: now(),
|
||||
UpdatedAt: now(),
|
||||
})
|
||||
if err != nil {
|
||||
return model.AuthSession{}, err
|
||||
}
|
||||
return newSession(user)
|
||||
}
|
||||
|
||||
func Login(username string, password string) (model.AuthSession, error) {
|
||||
user, ok, err := repository.GetUserByUsername(strings.TrimSpace(username))
|
||||
if err != nil {
|
||||
return model.AuthSession{}, err
|
||||
}
|
||||
if !ok || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
|
||||
return model.AuthSession{}, errors.New("用户名或密码错误")
|
||||
}
|
||||
return newSession(user)
|
||||
}
|
||||
|
||||
func ParseToken(tokenText string) (TokenClaims, error) {
|
||||
claims := TokenClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenText, &claims, func(token *jwt.Token) (any, error) {
|
||||
return []byte(config.Cfg.JWTSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return TokenClaims{}, errors.New("登录状态无效")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func CurrentAuthUser(tokenText string) (model.AuthUser, bool) {
|
||||
claims, err := ParseToken(tokenText)
|
||||
if err != nil {
|
||||
return model.AuthUser{}, false
|
||||
}
|
||||
user, ok, err := repository.GetUserByID(claims.UserID)
|
||||
if err != nil || !ok {
|
||||
return model.AuthUser{}, false
|
||||
}
|
||||
return model.PublicUser(user), true
|
||||
}
|
||||
|
||||
func ListUsers(q model.Query) (model.UserList, error) {
|
||||
users, total, err := repository.ListUsers(q)
|
||||
if err != nil {
|
||||
return model.UserList{}, err
|
||||
}
|
||||
for i := range users {
|
||||
users[i].Password = ""
|
||||
}
|
||||
return model.UserList{Items: users, Total: int(total)}, nil
|
||||
}
|
||||
|
||||
func SaveUser(user model.User, password string) (model.User, error) {
|
||||
user.Username = strings.TrimSpace(user.Username)
|
||||
if strings.ContainsAny(user.Username, " \t\r\n") {
|
||||
return user, errors.New("用户名不能包含空格")
|
||||
}
|
||||
if user.Username == "" {
|
||||
return user, errors.New("用户名不能为空")
|
||||
}
|
||||
if user.Role == "" || user.Role == model.UserRoleGuest {
|
||||
user.Role = model.UserRoleUser
|
||||
}
|
||||
if saved, ok, err := repository.GetUserByUsername(user.Username); err != nil {
|
||||
return user, err
|
||||
} else if ok && saved.ID != user.ID {
|
||||
return user, errors.New("用户名已存在")
|
||||
}
|
||||
if user.ID == "" {
|
||||
user.ID = newID("user")
|
||||
user.CreatedAt = now()
|
||||
} else if saved, ok, err := repository.GetUserByID(user.ID); err != nil {
|
||||
return user, err
|
||||
} else if ok {
|
||||
user.CreatedAt = saved.CreatedAt
|
||||
user.Password = saved.Password
|
||||
}
|
||||
if password != "" {
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
user.Password = hash
|
||||
}
|
||||
if user.Password == "" {
|
||||
return user, errors.New("密码不能为空")
|
||||
}
|
||||
user.UpdatedAt = now()
|
||||
user, err := repository.SaveUser(user)
|
||||
user.Password = ""
|
||||
return user, err
|
||||
}
|
||||
|
||||
func DeleteUser(id string) error {
|
||||
return repository.DeleteUser(id)
|
||||
}
|
||||
|
||||
func GuestUser() model.AuthUser {
|
||||
return model.AuthUser{ID: "", Username: "guest", Role: model.UserRoleGuest}
|
||||
}
|
||||
|
||||
func newSession(user model.User) (model.AuthSession, error) {
|
||||
token, err := newToken(user)
|
||||
if err != nil {
|
||||
return model.AuthSession{}, err
|
||||
}
|
||||
return model.AuthSession{Token: token, User: model.PublicUser(user)}, nil
|
||||
}
|
||||
|
||||
func newToken(user model.User) (string, error) {
|
||||
expireHours := config.Cfg.JWTExpireHours
|
||||
if expireHours <= 0 {
|
||||
expireHours = 168
|
||||
}
|
||||
claims := TokenClaims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireHours) * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Subject: user.ID,
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(config.Cfg.JWTSecret))
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(hash), err
|
||||
}
|
||||
|
||||
func now() string {
|
||||
return time.Now().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func newID(prefix string) string {
|
||||
return prefix + "-" + uuid.NewString()
|
||||
}
|
||||
|
||||
func WarnDefaultSecurityConfig() {
|
||||
if config.Cfg.AdminUsername == "admin" && config.Cfg.AdminPassword == "infinite-canvas" {
|
||||
log.Println("WARNING: using default admin credentials, please set ADMIN_USERNAME and ADMIN_PASSWORD to safer values before deployment")
|
||||
}
|
||||
if config.Cfg.JWTSecret == "infinite-canvas" {
|
||||
log.Println("WARNING: using default JWT_SECRET, please set a long random value before deployment")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
)
|
||||
|
||||
type userContextKey struct{}
|
||||
|
||||
func WithUser(ctx context.Context, user model.AuthUser) context.Context {
|
||||
return context.WithValue(ctx, userContextKey{}, user)
|
||||
}
|
||||
|
||||
func UserFromContext(ctx context.Context) (model.AuthUser, bool) {
|
||||
user, ok := ctx.Value(userContextKey{}).(model.AuthUser)
|
||||
return user, ok
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
gptImage2RawBase = "https://raw.githubusercontent.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts/main"
|
||||
awesomeGptImageRawBase = "https://raw.githubusercontent.com/ZeroLu/awesome-gpt-image/main"
|
||||
awesomeGpt4oImagePromptsBase = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/main"
|
||||
youMindGptImage2RawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main"
|
||||
youMindNanoBananaProRawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main"
|
||||
)
|
||||
|
||||
var gptImage2CaseFiles = []string{"README.md", "cases/ad-creative.md", "cases/character.md", "cases/comparison.md", "cases/ecommerce.md", "cases/portrait.md", "cases/poster.md", "cases/ui.md"}
|
||||
|
||||
type gptImage2Data struct {
|
||||
Records []struct {
|
||||
Title string `json:"title"`
|
||||
TweetURL string `json:"tweet_url"`
|
||||
ImageDir string `json:"image_dir"`
|
||||
Category string `json:"category"`
|
||||
AddedAt string `json:"added_at"`
|
||||
} `json:"records"`
|
||||
}
|
||||
|
||||
func SyncPromptCategory(category string) ([]model.PromptCategory, error) {
|
||||
for _, item := range repository.PromptCategories() {
|
||||
if item.Category != category {
|
||||
continue
|
||||
}
|
||||
items, err := buildPromptCategory(item.Category)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := repository.ReplacePromptCategory(item, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repository.ListPromptCategories()
|
||||
}
|
||||
return nil, errors.New("未知提示词分类")
|
||||
}
|
||||
|
||||
func buildPromptCategory(category string) ([]model.Prompt, error) {
|
||||
switch category {
|
||||
case "gpt-image-2-prompts":
|
||||
return buildGptImage2Prompts()
|
||||
case "awesome-gpt-image":
|
||||
return buildAwesomeGptImagePrompts()
|
||||
case "awesome-gpt4o-image-prompts":
|
||||
return buildAwesomeGpt4oImagePrompts()
|
||||
case "youmind-gpt-image-2":
|
||||
return buildYouMindGptImage2Prompts()
|
||||
case "youmind-nano-banana-pro":
|
||||
return buildYouMindNanoBananaProPrompts()
|
||||
}
|
||||
return nil, errors.New("未知提示词分类")
|
||||
}
|
||||
|
||||
func fetchText(baseURL, file string) (string, error) {
|
||||
request, _ := http.NewRequest(http.MethodGet, baseURL+"/"+file, nil)
|
||||
client := http.Client{Timeout: 30 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return "", errors.New(file + " 拉取失败")
|
||||
}
|
||||
data, err := io.ReadAll(response.Body)
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
func buildGptImage2Prompts() ([]model.Prompt, error) {
|
||||
cases := map[string]string{}
|
||||
raw, err := fetchText(gptImage2RawBase, "data/ingested_tweets.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := gptImage2Data{}
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, file := range gptImage2CaseFiles {
|
||||
markdown, err := fetchText(gptImage2RawBase, file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
collectGptImage2Cases(cases, markdown)
|
||||
}
|
||||
items := []model.Prompt{}
|
||||
for _, item := range data.Records {
|
||||
prompt := cases[item.TweetURL]
|
||||
if prompt == "" {
|
||||
continue
|
||||
}
|
||||
image := gptImage2RawBase + "/" + item.ImageDir + "/output.jpg"
|
||||
items = append(items, model.Prompt{ID: "gpt-image-2-prompts-" + leftPad(len(items)+1), Title: item.Title, CoverURL: image, Prompt: prompt, Tags: tagsFromCategory(item.Category), CreatedAt: item.AddedAt, UpdatedAt: item.AddedAt, Preview: markdownPreview([]string{image})})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func collectGptImage2Cases(cases map[string]string, markdown string) {
|
||||
re := regexp.MustCompile("(?s)### Case \\d+: \\[[^\\]]+\\]\\(([^)]+)\\).*?\\*\\*Prompt:\\*\\*\\s*\\r?\\n\\s*```[\\w-]*\\r?\\n(.*?)\\r?\\n```")
|
||||
for _, match := range re.FindAllStringSubmatch(markdown, -1) {
|
||||
cases[match[1]] = strings.TrimSpace(match[2])
|
||||
}
|
||||
}
|
||||
|
||||
func buildAwesomeGptImagePrompts() ([]model.Prompt, error) {
|
||||
markdown, err := fetchText(awesomeGptImageRawBase, "README.zh-CN.md")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []model.Prompt{}
|
||||
for _, section := range splitBeforeHeading(markdown, "## ") {
|
||||
tags := tagsFromHeading(firstMatch(section, `(?m)^##\s+(.+)$`))
|
||||
for _, block := range splitBeforeHeading(section, "### ") {
|
||||
title := strings.TrimSpace(regexp.MustCompile(`\[([^\]]+)]\([^)]+\)`).ReplaceAllString(firstMatch(block, `(?m)^###\s+(.+)$`), "$1"))
|
||||
prompt := strings.TrimSpace(firstMatch(block, "(?s)\\*\\*提示词:\\*\\*\\s*\\r?\\n\\s*```[\\w-]*\\r?\\n(.*?)\\r?\\n```"))
|
||||
if title == "" || prompt == "" {
|
||||
continue
|
||||
}
|
||||
images := extractMarkdownImages(awesomeGptImageRawBase, block)
|
||||
cover := ""
|
||||
if len(images) > 0 {
|
||||
cover = images[0]
|
||||
}
|
||||
items = append(items, model.Prompt{ID: "awesome-gpt-image-" + leftPad(len(items)+1), Title: title, CoverURL: cover, Prompt: prompt, Tags: tags, Preview: markdownPreview(images)})
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func buildAwesomeGpt4oImagePrompts() ([]model.Prompt, error) {
|
||||
markdown, err := fetchText(awesomeGpt4oImagePromptsBase, "README.zh-CN.md")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []model.Prompt{}
|
||||
for _, block := range splitBeforeHeading(markdown, "### ") {
|
||||
title := strings.TrimSpace(firstMatch(block, `(?m)^###\s+(.+)$`))
|
||||
prompt := strings.TrimSpace(firstMatch(block, "(?s)- \\*\\*提示词文本:\\*\\*\\s*`(.*?)`"))
|
||||
if title == "" || prompt == "" {
|
||||
continue
|
||||
}
|
||||
images := extractMarkdownImages(awesomeGpt4oImagePromptsBase, block)
|
||||
cover := ""
|
||||
if len(images) > 0 {
|
||||
cover = images[0]
|
||||
}
|
||||
items = append(items, model.Prompt{ID: "awesome-gpt4o-image-prompts-" + leftPad(len(items)+1), Title: title, CoverURL: cover, Prompt: prompt, Tags: []string{"gpt4o"}, Preview: markdownPreview(images)})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func buildYouMindGptImage2Prompts() ([]model.Prompt, error) {
|
||||
return buildYouMindPrompts(youMindGptImage2RawBase, "youmind-gpt-image-2", "gpt-image-2")
|
||||
}
|
||||
|
||||
func buildYouMindNanoBananaProPrompts() ([]model.Prompt, error) {
|
||||
return buildYouMindPrompts(youMindNanoBananaProRawBase, "youmind-nano-banana-pro", "nano-banana-pro")
|
||||
}
|
||||
|
||||
func buildYouMindPrompts(baseURL, idPrefix, modelTag string) ([]model.Prompt, error) {
|
||||
markdown, err := fetchText(baseURL, "README_zh.md")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []model.Prompt{}
|
||||
for _, block := range splitBeforeHeading(markdown, "### ") {
|
||||
title := strings.TrimSpace(firstMatch(block, `(?m)^###\s+No\.\s*\d+:\s*(.+)$`))
|
||||
prompt := strings.TrimSpace(firstMatch(block, "(?s)#### .*?提示词\\s*\\r?\\n\\s*```[\\w-]*\\r?\\n(.*?)\\r?\\n```"))
|
||||
if title == "" || prompt == "" {
|
||||
continue
|
||||
}
|
||||
images := extractMarkdownImages(baseURL, block)
|
||||
cover := ""
|
||||
if len(images) > 0 {
|
||||
cover = images[0]
|
||||
}
|
||||
items = append(items, model.Prompt{ID: idPrefix + "-" + leftPad(len(items)+1), Title: title, CoverURL: cover, Prompt: prompt, Tags: youMindTags(title, modelTag), Preview: markdownPreview(images)})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func splitBeforeHeading(markdown string, prefix string) []string {
|
||||
blocks := []string{}
|
||||
lines := strings.Split(markdown, "\n")
|
||||
current := []string{}
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, prefix) && len(current) > 0 {
|
||||
blocks = append(blocks, strings.Join(current, "\n"))
|
||||
current = []string{}
|
||||
}
|
||||
current = append(current, line)
|
||||
}
|
||||
return append(blocks, strings.Join(current, "\n"))
|
||||
}
|
||||
|
||||
func firstMatch(value string, pattern string) string {
|
||||
match := regexp.MustCompile(pattern).FindStringSubmatch(value)
|
||||
if len(match) > 1 {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func tagsFromCategory(category string) []string {
|
||||
return splitTags(regexp.MustCompile(`(?i)\s+Cases$`).ReplaceAllString(category, ""), `\s*(&|and)\s*`)
|
||||
}
|
||||
|
||||
func tagsFromHeading(heading string) []string {
|
||||
return splitTags(regexp.MustCompile(`[^\p{L}\p{N}/&、与 ]`).ReplaceAllString(heading, ""), `\s*(/|&|、|与)\s*`)
|
||||
}
|
||||
|
||||
func youMindTags(title, modelTag string) []string {
|
||||
tags := []string{modelTag}
|
||||
parts := strings.SplitN(title, " - ", 2)
|
||||
if len(parts) > 1 {
|
||||
tags = append(tags, tagsFromHeading(parts[0])...)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func splitTags(value string, pattern string) []string {
|
||||
tags := []string{}
|
||||
for _, tag := range regexp.MustCompile(pattern).Split(value, -1) {
|
||||
if tag = strings.ToLower(strings.TrimSpace(tag)); tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func markdownPreview(images []string) string {
|
||||
lines := []string{}
|
||||
for _, image := range images {
|
||||
if image != "" {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n\n")
|
||||
}
|
||||
|
||||
func extractMarkdownImages(baseURL string, block string) []string {
|
||||
seen := map[string]bool{}
|
||||
images := []string{}
|
||||
for _, pattern := range []string{`<img[^>]+src="([^"]+)"`, `!\[[^\]]*]\(([^)]+)\)`} {
|
||||
for _, match := range regexp.MustCompile(pattern).FindAllStringSubmatch(block, -1) {
|
||||
image := absoluteImage(baseURL, match[1])
|
||||
if image != "" && !seen[image] {
|
||||
seen[image] = true
|
||||
images = append(images, image)
|
||||
}
|
||||
}
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
func absoluteImage(baseURL, image string) string {
|
||||
if image == "" || strings.HasPrefix(image, "http://") || strings.HasPrefix(image, "https://") {
|
||||
return image
|
||||
}
|
||||
return baseURL + "/" + strings.TrimLeft(strings.TrimPrefix(image, "."), "/")
|
||||
}
|
||||
|
||||
func leftPad(value int) string {
|
||||
if value >= 1000 {
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
text := "000" + strconv.Itoa(value)
|
||||
return text[len(text)-3:]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/repository"
|
||||
)
|
||||
|
||||
func ListPrompts(q model.Query) (model.PromptList, error) {
|
||||
items, total, err := repository.ListPrompts(q)
|
||||
if err != nil {
|
||||
return model.PromptList{}, err
|
||||
}
|
||||
tags, err := repository.ListPromptTags(q)
|
||||
if err != nil {
|
||||
return model.PromptList{}, err
|
||||
}
|
||||
categories := promptCategoryCodes(ListPromptCategories())
|
||||
return model.PromptList{Items: items, Tags: tags, Categories: categories, Total: int(total)}, nil
|
||||
}
|
||||
|
||||
func ListPromptCategories() []model.PromptCategory {
|
||||
categories, _ := repository.ListPromptCategories()
|
||||
return categories
|
||||
}
|
||||
|
||||
func SavePrompt(item model.Prompt) (model.Prompt, error) {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
if item.Category == "" {
|
||||
item.Category = repository.PromptCategories()[0].Category
|
||||
}
|
||||
if item.ID == "" {
|
||||
item.ID = newID(item.Category)
|
||||
item.CreatedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
category, ok := repository.PromptCategoryByCode(item.Category)
|
||||
if !ok {
|
||||
category = repository.PromptCategories()[0]
|
||||
item.Category = category.Category
|
||||
}
|
||||
item.GithubURL = ""
|
||||
return repository.SavePrompt(item)
|
||||
}
|
||||
|
||||
func DeletePrompt(id string) error {
|
||||
return repository.DeletePrompt(id)
|
||||
}
|
||||
|
||||
func promptCategoryCodes(items []model.PromptCategory) []string {
|
||||
codes := []string{}
|
||||
for _, item := range items {
|
||||
if item.Category != "" {
|
||||
codes = append(codes, item.Category)
|
||||
}
|
||||
}
|
||||
return codes
|
||||
}
|
||||
+1554
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {
|
||||
"@magicui": "https://magicui.design/r/{name}"
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { PHASE_DEVELOPMENT_SERVER } from "next/constants";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const apiBaseUrl = process.env.API_BASE_URL || "http://127.0.0.1:8080";
|
||||
const webDir = dirname(fileURLToPath(import.meta.url));
|
||||
const version = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim() || "dev";
|
||||
|
||||
export default function nextConfig(phase: string): NextConfig {
|
||||
const isDev = phase === PHASE_DEVELOPMENT_SERVER;
|
||||
return {
|
||||
env: {
|
||||
NEXT_PUBLIC_APP_VERSION: version,
|
||||
},
|
||||
async rewrites() {
|
||||
return isDev ? [{ source: "/api/:path*", destination: `${apiBaseUrl}/api/:path*` }] : [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "infinite-canvas",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack -H 0.0.0.0",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@ant-design/pro-components": "3.0.0-beta.3",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"antd": "^6.4.2",
|
||||
"axios": "^1.16.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"localforage": "^1.10.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"motion": "^12.38.0",
|
||||
"next": "16.2.3",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"shadcn": "^4.7.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32 8L58 54H46L32 29L18 54H6L32 8Z" fill="currentColor"/>
|
||||
<path d="M32 40L40 54H24L32 40Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { CopyOutlined, DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { ProTable, type ProColumns } from "@ant-design/pro-components";
|
||||
import { App, Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Tag, Tooltip, Typography } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { AdminAsset } from "@/services/api/admin";
|
||||
import { useAdminAssets } from "../hooks/use-admin-assets";
|
||||
|
||||
type AssetFormValues = Partial<AdminAsset> & { tagText?: string };
|
||||
|
||||
const typeOptions = [
|
||||
{ label: "全部类型", value: "" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
];
|
||||
|
||||
const editTypeOptions = typeOptions.slice(1);
|
||||
|
||||
export default function AdminAssetsPage() {
|
||||
const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets();
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm<AssetFormValues>();
|
||||
const [editingAsset, setEditingAsset] = useState<Partial<AdminAsset> | null>(null);
|
||||
const [detailAsset, setDetailAsset] = useState<AdminAsset | null>(null);
|
||||
const [deletingAsset, setDeletingAsset] = useState<AdminAsset | null>(null);
|
||||
const formType = Form.useWatch("type", form) || editingAsset?.type || "text";
|
||||
const tagOptions = tags.map((item) => ({ label: item, value: item }));
|
||||
|
||||
useEffect(() => {
|
||||
if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" });
|
||||
}, [editingAsset, form]);
|
||||
|
||||
const copyValue = async (value: string) => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
message.success("已复制");
|
||||
};
|
||||
|
||||
const saveAsset = async () => {
|
||||
const value = await form.validateFields();
|
||||
const nextType = value.type || "text";
|
||||
await saveAdminAsset({
|
||||
...editingAsset,
|
||||
...value,
|
||||
type: nextType,
|
||||
coverUrl: value.coverUrl || (nextType === "image" ? value.url : ""),
|
||||
tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean),
|
||||
});
|
||||
setEditingAsset(null);
|
||||
};
|
||||
|
||||
const columns: ProColumns<AdminAsset>[] = [
|
||||
{
|
||||
title: "封面",
|
||||
dataIndex: "coverUrl",
|
||||
width: 88,
|
||||
render: (_, item) => <Image src={item.coverUrl || item.url || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />,
|
||||
},
|
||||
{
|
||||
title: "标题",
|
||||
dataIndex: "title",
|
||||
width: 260,
|
||||
render: (_, item) => <Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailAsset(item)}>{item.title}</Typography.Link>,
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "type",
|
||||
width: 84,
|
||||
render: (_, item) => <Tag>{item.type === "image" ? "图片" : "文本"}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "标签",
|
||||
dataIndex: "tags",
|
||||
width: 180,
|
||||
render: (_, item) => <Space size={[4, 4]} wrap>{(item.tags || []).slice(0, 3).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>,
|
||||
},
|
||||
{
|
||||
title: "分类",
|
||||
dataIndex: "category",
|
||||
width: 120,
|
||||
render: (_, item) => <Typography.Text type="secondary">{item.category || "未标注"}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 112,
|
||||
align: "right",
|
||||
render: (_, item) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title="详情"><Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailAsset(item)} /></Tooltip>
|
||||
<Tooltip title="编辑"><Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingAsset(item)} /></Tooltip>
|
||||
<Tooltip title="删除"><Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingAsset(item)} /></Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<main style={{ padding: 24 }}>
|
||||
<Flex vertical gap={16}>
|
||||
<Card variant="borderless">
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16} align="bottom">
|
||||
<Col flex="360px"><Form.Item label="关键词"><Input.Search value={keyword} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={searchAssets} onChange={(event) => searchAssets(event.target.value)} /></Form.Item></Col>
|
||||
<Col flex="180px"><Form.Item label="类型"><Select value={kind} onChange={changeKind} options={typeOptions} /></Form.Item></Col>
|
||||
<Col flex="220px"><Form.Item label="标签"><Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" /></Form.Item></Col>
|
||||
<Col flex="none"><Form.Item><Space><Button onClick={resetFilters}>重置</Button><Button type="primary" icon={<ReloadOutlined />} onClick={refreshAssets}>查询</Button></Space></Form.Item></Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
<ProTable<AdminAsset>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={assets}
|
||||
loading={isLoading}
|
||||
search={false}
|
||||
defaultSize="middle"
|
||||
tableLayout="fixed"
|
||||
cardProps={{ variant: "borderless" }}
|
||||
headerTitle={<Space><Typography.Text strong>素材列表</Typography.Text><Tag>{total} 条</Tag></Space>}
|
||||
options={{ density: true, setting: true, reload: () => void refreshAssets() }}
|
||||
toolBarRender={() => [<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingAsset({ type: "text", tags: [] })}>新增</Button>]}
|
||||
pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], showTotal: (value) => `共 ${value} 条`, onChange: (nextPage, nextPageSize) => nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage) }}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Modal title={editingAsset?.id ? "编辑素材" : "新增素材"} open={Boolean(editingAsset)} width={760} onCancel={() => setEditingAsset(null)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item name="type" label="类型" rules={[{ required: true, message: "请选择类型" }]}><Select options={editTypeOptions} /></Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}><Input /></Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL"><Input /></Form.Item>
|
||||
<Form.Item name="tagText" label="标签,用逗号分隔"><Input /></Form.Item>
|
||||
<Form.Item name="category" label="分类"><Input /></Form.Item>
|
||||
<Form.Item name="description" label="描述"><Input.TextArea rows={3} /></Form.Item>
|
||||
{formType === "image" ? <Form.Item name="url" label="图片 URL" rules={[{ required: true, message: "请输入图片 URL" }]}><Input /></Form.Item> : <Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}><Input.TextArea rows={6} /></Form.Item>}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="素材详情" open={Boolean(detailAsset)} width={760} onCancel={() => setDetailAsset(null)} footer={<Button onClick={() => setDetailAsset(null)}>关闭</Button>}>
|
||||
{detailAsset ? (
|
||||
<Flex vertical gap={14}>
|
||||
<Flex gap={14} align="start">
|
||||
<Image src={detailAsset.coverUrl || detailAsset.url || "/logo.svg"} alt={detailAsset.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
|
||||
<Flex vertical gap={8} style={{ minWidth: 0 }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>{detailAsset.title}</Typography.Title>
|
||||
<Space wrap><Tag>{detailAsset.type === "image" ? "图片" : "文本"}</Tag>{detailAsset.category ? <Tag>{detailAsset.category}</Tag> : null}{(detailAsset.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{detailAsset.description ? <Typography.Paragraph type="secondary" style={{ margin: 0 }}>{detailAsset.description}</Typography.Paragraph> : null}
|
||||
<Input.TextArea value={detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content} rows={7} readOnly />
|
||||
<Button icon={<CopyOutlined />} onClick={() => void copyValue(detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content)}>复制内容</Button>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={async () => { if (!deletingAsset) return; await deleteAsset(deletingAsset.id); setDeletingAsset(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除「{deletingAsset?.title}」吗?删除后会从服务器素材库中移除。
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { App } from "antd";
|
||||
|
||||
import { deleteAdminAsset, fetchAdminAssets, saveAdminAsset, type AdminAsset } from "@/services/api/admin";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
const defaultPageSize = 10;
|
||||
|
||||
export function useAdminAssets() {
|
||||
const { message } = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const token = useUserStore((state) => state.token);
|
||||
const clearSession = useUserStore((state) => state.clearSession);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [tag, setTag] = useState<string[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(defaultPageSize);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["admin", "assets", token, keyword, type, tag, page, pageSize],
|
||||
queryFn: () => fetchAdminAssets(token, { keyword, type, tag, page, pageSize }),
|
||||
enabled: Boolean(token),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (asset: Partial<AdminAsset>) => saveAdminAsset(token, asset),
|
||||
onSuccess: async (_, asset) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] });
|
||||
message.success(asset.id ? "素材已保存" : "素材已新增");
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : "保存失败");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteAdminAsset(token, id),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] });
|
||||
message.success("素材已删除");
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : "删除失败");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (query.isError) {
|
||||
const errorMessage = query.error instanceof Error ? query.error.message : "读取素材失败";
|
||||
message.error(errorMessage);
|
||||
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) {
|
||||
clearSession();
|
||||
}
|
||||
}
|
||||
}, [clearSession, message, query.error, query.isError]);
|
||||
|
||||
const updateFilters = (next: Partial<{ keyword: string; type: string; tag: string[]; page: number; pageSize: number }>) => {
|
||||
const queryState = { keyword, type, tag, page, pageSize, ...next };
|
||||
if (next.keyword !== undefined || next.type !== undefined || next.tag !== undefined || next.pageSize !== undefined) {
|
||||
queryState.page = 1;
|
||||
}
|
||||
setKeyword(queryState.keyword);
|
||||
setType(queryState.type);
|
||||
setTag(queryState.tag);
|
||||
setPage(queryState.page);
|
||||
setPageSize(queryState.pageSize);
|
||||
};
|
||||
|
||||
const refreshAssets = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
const saveAsset = async (asset: Partial<AdminAsset>) => {
|
||||
await saveMutation.mutateAsync(asset);
|
||||
};
|
||||
|
||||
const deleteAsset = async (id: string) => {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
};
|
||||
|
||||
const data = query.data;
|
||||
const isLoading = query.isFetching || saveMutation.isPending || deleteMutation.isPending;
|
||||
|
||||
return {
|
||||
assets: data?.items || [],
|
||||
tags: data?.tags || [],
|
||||
keyword,
|
||||
kind: type,
|
||||
tag,
|
||||
page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
isLoading,
|
||||
searchAssets: (value = keyword) => updateFilters({ keyword: value }),
|
||||
changeKind: (value: string) => updateFilters({ type: value, tag: [] }),
|
||||
changeTag: (value: string[]) => updateFilters({ tag: value }),
|
||||
changePage: (value: number) => updateFilters({ page: value }),
|
||||
changePageSize: (value: number) => updateFilters({ pageSize: value }),
|
||||
resetFilters: () => updateFilters({ keyword: "", type: "", tag: [], page: 1, pageSize: defaultPageSize }),
|
||||
refreshAssets,
|
||||
saveAsset,
|
||||
deleteAsset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { App } from "antd";
|
||||
|
||||
import { deleteAdminPrompt, fetchAdminPrompts, fetchAdminPromptCategories, saveAdminPrompt, syncAdminPromptCategory, type AdminPromptCategory } from "@/services/api/admin";
|
||||
import type { Prompt } from "@/services/api/prompts";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
const defaultPageSize = 10;
|
||||
|
||||
export function useAdminPrompts() {
|
||||
const { message } = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const token = useUserStore((state) => state.token);
|
||||
const clearSession = useUserStore((state) => state.clearSession);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [tag, setTag] = useState<string[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(defaultPageSize);
|
||||
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: ["admin", "prompt-categories", token],
|
||||
queryFn: () => fetchAdminPromptCategories(token),
|
||||
enabled: Boolean(token),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const promptsQuery = useQuery({
|
||||
queryKey: ["admin", "prompts", token, keyword, category, tag, page, pageSize],
|
||||
queryFn: () => fetchAdminPrompts(token, { keyword, category, tag, page, pageSize }),
|
||||
enabled: Boolean(token),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: (category: string) => syncAdminPromptCategory(token, category),
|
||||
onSuccess: async (categories) => {
|
||||
queryClient.setQueryData<AdminPromptCategory[]>(["admin", "prompt-categories", token], categories);
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
|
||||
message.success("远程提示词源已同步");
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : "同步失败");
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (prompt: Partial<Prompt>) => saveAdminPrompt(token, prompt),
|
||||
onSuccess: async (_, prompt) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
|
||||
message.success(prompt.id ? "提示词已保存" : "提示词已新增");
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : "保存失败");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteAdminPrompt(token, id),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
|
||||
message.success("提示词已删除");
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : "删除失败");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (categoriesQuery.isError) {
|
||||
const errorMessage = categoriesQuery.error instanceof Error ? categoriesQuery.error.message : "读取提示词分类失败";
|
||||
message.error(errorMessage);
|
||||
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) {
|
||||
clearSession();
|
||||
}
|
||||
}
|
||||
}, [categoriesQuery.error, categoriesQuery.isError, clearSession, message]);
|
||||
|
||||
useEffect(() => {
|
||||
if (promptsQuery.isError) {
|
||||
const errorMessage = promptsQuery.error instanceof Error ? promptsQuery.error.message : "读取提示词失败";
|
||||
message.error(errorMessage);
|
||||
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) {
|
||||
clearSession();
|
||||
}
|
||||
}
|
||||
}, [clearSession, message, promptsQuery.error, promptsQuery.isError]);
|
||||
|
||||
const updateFilters = (next: Partial<{ keyword: string; category: string; tag: string[]; page: number; pageSize: number }>) => {
|
||||
const queryState = { keyword, category, tag, page, pageSize, ...next };
|
||||
if (next.keyword !== undefined || next.category !== undefined || next.tag !== undefined || next.pageSize !== undefined) {
|
||||
queryState.page = 1;
|
||||
}
|
||||
setKeyword(queryState.keyword);
|
||||
setCategory(queryState.category);
|
||||
setTag(queryState.tag);
|
||||
setPage(queryState.page);
|
||||
setPageSize(queryState.pageSize);
|
||||
};
|
||||
|
||||
const refreshPrompts = async () => {
|
||||
await categoriesQuery.refetch();
|
||||
await promptsQuery.refetch();
|
||||
};
|
||||
|
||||
const data = promptsQuery.data;
|
||||
const isLoading = categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending;
|
||||
|
||||
return {
|
||||
categories: categoriesQuery.data || [],
|
||||
prompts: data?.items || [],
|
||||
tags: data?.tags || [],
|
||||
keyword,
|
||||
category,
|
||||
tag,
|
||||
page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
isLoading,
|
||||
isSyncing: syncMutation.isPending,
|
||||
syncCategory: (category: string) => syncMutation.mutateAsync(category),
|
||||
searchPrompts: (value = keyword) => updateFilters({ keyword: value }),
|
||||
changeCategory: (value: string) => updateFilters({ category: value, tag: [] }),
|
||||
changeTag: (value: string[]) => updateFilters({ tag: value }),
|
||||
changePage: (value: number) => updateFilters({ page: value }),
|
||||
changePageSize: (value: number) => updateFilters({ pageSize: value }),
|
||||
resetFilters: () => updateFilters({ keyword: "", category: "", tag: [], page: 1, pageSize: defaultPageSize }),
|
||||
refreshPrompts,
|
||||
savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt),
|
||||
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined } from "@ant-design/icons";
|
||||
import { Button, Flex, Layout, Menu, Typography } from "antd";
|
||||
import { LogOut } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { UserStatusActions } from "@/components/user-status-actions";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const token = useUserStore((state) => state.token);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const isReady = useUserStore((state) => state.isReady);
|
||||
const logout = useUserStore((state) => state.clearSession);
|
||||
const colorTheme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const activeKey = pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : "";
|
||||
const pageTitle = pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理";
|
||||
const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
if (!token) {
|
||||
router.replace("/login?redirect=/admin");
|
||||
return;
|
||||
}
|
||||
if (user?.role !== "admin") {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [isReady, router, token, user?.role]);
|
||||
|
||||
if (!isReady || !token || user?.role !== "admin") {
|
||||
return (
|
||||
<div style={{ display: "flex", minHeight: "100vh", alignItems: "center", justifyContent: "center", background: "var(--background)" }}>
|
||||
<span />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout className="admin-layout" hasSider style={{ height: "100vh", overflow: "hidden" }}>
|
||||
<Layout.Sider width={232} style={{ height: "100vh", overflow: "hidden" }}>
|
||||
<Flex className="admin-brand" align="center" gap={12} style={{ height: 56, padding: "0 20px" }}>
|
||||
<span className="admin-logo" aria-hidden />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>无限画布</Typography.Text>
|
||||
</Flex>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[activeKey]}
|
||||
style={{ borderInlineEnd: 0, padding: "12px 8px" }}
|
||||
items={[
|
||||
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: <Link href="/admin/prompts" style={{ color: "inherit" }}>提示词管理</Link> },
|
||||
{ key: "/admin/assets", icon: <PictureOutlined />, label: <Link href="/admin/assets" style={{ color: "inherit" }}>素材库</Link> },
|
||||
]}
|
||||
/>
|
||||
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12 }}>
|
||||
<Button block icon={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer">前往画布</Button>
|
||||
<Button block icon={<LogoutOutlined />} onClick={logout}>退出登录</Button>
|
||||
</Flex>
|
||||
</Layout.Sider>
|
||||
<Layout>
|
||||
<Layout.Header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", height: 56, padding: "0 24px" }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>{pageTitle}</Typography.Title>
|
||||
<Flex align="center" gap={4}>
|
||||
<UserStatusActions
|
||||
version={appVersion}
|
||||
theme={colorTheme}
|
||||
onThemeChange={setTheme}
|
||||
showConfig={false}
|
||||
userName={user.username}
|
||||
menuItems={[{ key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", onClick: logout }]}
|
||||
/>
|
||||
</Flex>
|
||||
</Layout.Header>
|
||||
<Layout.Content style={{ minHeight: 0, overflow: "auto" }}>{children}</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect("/admin/assets");
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { CopyOutlined, DeleteOutlined, EditOutlined, ExportOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SyncOutlined } from "@ant-design/icons";
|
||||
import { ProTable, type ProColumns } from "@ant-design/pro-components";
|
||||
import { App, Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Table, Tag, Tooltip, Typography } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { Prompt } from "@/services/api/prompts";
|
||||
import { useAdminPrompts } from "../hooks/use-admin-prompts";
|
||||
|
||||
export default function AdminPromptsPage() {
|
||||
const { categories, prompts, tags, keyword, category, tag, page, pageSize, total, isLoading, isSyncing, searchPrompts, changeCategory, changeTag, changePage, changePageSize, resetFilters, refreshPrompts, syncCategory, savePrompt: saveAdminPrompt, deletePrompt } = useAdminPrompts();
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm<Partial<Prompt> & { tagText?: string }>();
|
||||
const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null);
|
||||
const [detailPrompt, setDetailPrompt] = useState<Prompt | null>(null);
|
||||
const [deletingPrompt, setDeletingPrompt] = useState<Prompt | null>(null);
|
||||
const [isSyncOpen, setIsSyncOpen] = useState(false);
|
||||
const defaultCategory = categories[0]?.category || "";
|
||||
const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category;
|
||||
const categoryOptions = [{ label: "全部分类", value: "" }, ...categories.map((item) => ({ label: item.name, value: item.category }))];
|
||||
const tagOptions = tags.map((item) => ({ label: item, value: item }));
|
||||
|
||||
useEffect(() => {
|
||||
if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" });
|
||||
}, [editingPrompt, form]);
|
||||
|
||||
const copyPrompt = async (value: string) => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
message.success("已复制");
|
||||
};
|
||||
|
||||
const savePrompt = async () => {
|
||||
const value = await form.validateFields();
|
||||
await saveAdminPrompt({ ...editingPrompt, ...value, category: value.category || defaultCategory, tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean) });
|
||||
setEditingPrompt(null);
|
||||
};
|
||||
|
||||
const columns: ProColumns<Prompt>[] = [
|
||||
{
|
||||
title: "封面",
|
||||
dataIndex: "coverUrl",
|
||||
width: 88,
|
||||
render: (_, item) => <Image src={item.coverUrl || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />,
|
||||
},
|
||||
{
|
||||
title: "标题",
|
||||
dataIndex: "title",
|
||||
width: 260,
|
||||
render: (_, item) => <Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailPrompt(item)}>{item.title}</Typography.Link>,
|
||||
},
|
||||
{
|
||||
title: "分类",
|
||||
dataIndex: "category",
|
||||
width: 150,
|
||||
render: (_, item) => <Typography.Text type="secondary">{categoryName(item.category)}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "标签",
|
||||
dataIndex: "tags",
|
||||
width: 180,
|
||||
render: (_, item) => <Space size={[4, 4]} wrap>{(item.tags || []).slice(0, 3).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 112,
|
||||
align: "right",
|
||||
render: (_, item) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title="详情"><Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailPrompt(item)} /></Tooltip>
|
||||
<Tooltip title="编辑"><Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingPrompt(item)} /></Tooltip>
|
||||
<Tooltip title="删除"><Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingPrompt(item)} /></Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<main style={{ padding: 24 }}>
|
||||
<Flex vertical gap={16}>
|
||||
<Card variant="borderless">
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16} align="bottom">
|
||||
<Col flex="360px"><Form.Item label="关键词"><Input.Search value={keyword} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={searchPrompts} onChange={(event) => searchPrompts(event.target.value)} /></Form.Item></Col>
|
||||
<Col flex="220px"><Form.Item label="分组"><Select value={category} onChange={changeCategory} options={categoryOptions} /></Form.Item></Col>
|
||||
<Col flex="220px"><Form.Item label="标签"><Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" /></Form.Item></Col>
|
||||
<Col flex="none"><Form.Item><Space><Button onClick={resetFilters}>重置</Button><Button type="primary" icon={<ReloadOutlined />} onClick={refreshPrompts}>查询</Button></Space></Form.Item></Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
<ProTable<Prompt>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={prompts}
|
||||
loading={isLoading}
|
||||
search={false}
|
||||
defaultSize="middle"
|
||||
tableLayout="fixed"
|
||||
cardProps={{ variant: "borderless" }}
|
||||
headerTitle={<Space><Typography.Text strong>提示词列表</Typography.Text><Tag>{total} 条</Tag></Space>}
|
||||
options={{ density: true, setting: true, reload: () => void refreshPrompts() }}
|
||||
toolBarRender={() => [<Button key="sync" icon={<SyncOutlined />} onClick={() => setIsSyncOpen(true)}>同步</Button>, <Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingPrompt({ category: defaultCategory, tags: [] })}>新增</Button>]}
|
||||
pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], showTotal: (value) => `共 ${value} 条`, onChange: (nextPage, nextPageSize) => nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage) }}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Modal title={editingPrompt?.id ? "编辑提示词" : "新增提示词"} open={Boolean(editingPrompt)} width={720} onCancel={() => setEditingPrompt(null)} onOk={() => void savePrompt()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}><Input /></Form.Item>
|
||||
<Form.Item name="category" label="分类"><Select options={categories.map((item) => ({ label: item.name, value: item.category }))} /></Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL"><Input /></Form.Item>
|
||||
<Form.Item name="tagText" label="标签,用逗号分隔"><Input /></Form.Item>
|
||||
<Form.Item name="prompt" label="提示词" rules={[{ required: true, message: "请输入提示词" }]}><Input.TextArea rows={6} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="提示词详情" open={Boolean(detailPrompt)} width={760} onCancel={() => setDetailPrompt(null)} footer={<Button onClick={() => setDetailPrompt(null)}>关闭</Button>}>
|
||||
{detailPrompt ? (
|
||||
<Flex vertical gap={14}>
|
||||
<Flex gap={14} align="start">
|
||||
<Image src={detailPrompt.coverUrl || "/logo.svg"} alt={detailPrompt.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
|
||||
<Flex vertical gap={8} style={{ minWidth: 0 }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>{detailPrompt.title}</Typography.Title>
|
||||
<Space wrap><Tag>{categoryName(detailPrompt.category)}</Tag>{(detailPrompt.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}</Space>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{detailPrompt.preview ? <Typography.Paragraph type="secondary" style={{ margin: 0 }}>{detailPrompt.preview}</Typography.Paragraph> : null}
|
||||
<Input.TextArea value={detailPrompt.prompt} rows={8} readOnly />
|
||||
<Space>
|
||||
<Button icon={<CopyOutlined />} onClick={() => void copyPrompt(detailPrompt.prompt)}>复制提示词</Button>
|
||||
{detailPrompt.githubUrl ? <Button icon={<ExportOutlined />} href={detailPrompt.githubUrl} target="_blank">远程源</Button> : null}
|
||||
</Space>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal title="同步远程提示词源" open={isSyncOpen} width={640} onCancel={() => !isSyncing && setIsSyncOpen(false)} maskClosable={!isSyncing} footer={<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}>取消</Button>}>
|
||||
<Table
|
||||
rowKey="category"
|
||||
dataSource={categories.filter((item) => item.remote)}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: "远程源", dataIndex: "name", render: (_, item) => <Flex align="center" gap={8}>{item.name}{item.githubUrl ? <Typography.Link href={item.githubUrl} target="_blank"><ExportOutlined /></Typography.Link> : null}</Flex> },
|
||||
{ title: "", key: "sync", width: 96, align: "right", render: (_, item) => <Button type="primary" loading={isSyncing} onClick={async () => { try { await syncCategory(item.category); setIsSyncOpen(false); } catch {} }}>同步</Button> },
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal title="删除提示词" open={Boolean(deletingPrompt)} onCancel={() => setDeletingPrompt(null)} onOk={async () => { if (!deletingPrompt) return; await deletePrompt(deletingPrompt.id); setDeletingPrompt(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除「{deletingPrompt?.title}」吗?删除后会从当前分类中删除。
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { Copy, FolderPlus, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { App, Button, Card, Drawer, Empty, Image, Input, Pagination, Spin, Tag, Typography } from "antd";
|
||||
import axios from "axios";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
export default function AssetLibraryPage() {
|
||||
const { message } = App.useApp();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [selectedType, setSelectedType] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedAsset, setSelectedAsset] = useState<AssetLibraryItem | null>(null);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["asset-library", keyword, selectedType, selectedTags, page],
|
||||
queryFn: () => fetchAssetLibrary({ keyword, type: selectedType, tag: selectedTags, page, pageSize: PAGE_SIZE }),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (query.isError) {
|
||||
message.error(query.error instanceof Error ? query.error.message : "获取素材库失败");
|
||||
}
|
||||
}, [message, query.error, query.isError]);
|
||||
|
||||
const isReady = query.isFetched || query.isError;
|
||||
const items = query.data?.items || [];
|
||||
const availableTags = query.data?.tags || [];
|
||||
const total = query.data?.total || 0;
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]);
|
||||
};
|
||||
|
||||
const saveToMyAssets = async (asset: AssetLibraryItem) => {
|
||||
try {
|
||||
if (asset.type === "image") {
|
||||
const dataUrl = await remoteImageToDataUrl(asset.url);
|
||||
const image = await uploadImage(dataUrl);
|
||||
addAsset({
|
||||
kind: "image",
|
||||
title: asset.title,
|
||||
coverUrl: asset.coverUrl,
|
||||
tags: asset.tags,
|
||||
source: asset.category,
|
||||
note: asset.description,
|
||||
data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType },
|
||||
metadata: { source: "asset-library", assetId: asset.id },
|
||||
});
|
||||
} else {
|
||||
addAsset({
|
||||
kind: "text",
|
||||
title: asset.title,
|
||||
coverUrl: asset.coverUrl,
|
||||
tags: asset.tags,
|
||||
source: asset.category,
|
||||
note: asset.description,
|
||||
data: { content: asset.content },
|
||||
metadata: { source: "asset-library", assetId: asset.id },
|
||||
});
|
||||
}
|
||||
message.success("已加入我的素材");
|
||||
} catch {
|
||||
message.error("加入失败");
|
||||
}
|
||||
};
|
||||
|
||||
const copyText = async (value: string) => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
message.success("已复制");
|
||||
};
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
|
||||
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]">
|
||||
<div className="pb-8">
|
||||
<div className="mx-auto max-w-5xl text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">素材库</h1>
|
||||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">挑选团队素材,加入我的素材后继续编辑和使用。</p>
|
||||
</div>
|
||||
<div className="mx-auto mt-8 w-full max-w-2xl">
|
||||
<Input size="large" className="w-full" prefix={<Search className="size-4 text-stone-400" />} value={keyword} placeholder="按标题查询" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} />
|
||||
</div>
|
||||
<div className="mx-auto mt-6 max-w-6xl space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
||||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">类型</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
].map((item) => (
|
||||
<Tag.CheckableTag key={item.value || "all"} checked={selectedType === item.value} className={cn("prompt-filter-tag", selectedType === item.value && "is-active")} onChange={() => { setPage(1); setSelectedType(item.value); }}>
|
||||
{item.label}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
||||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">标签</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tag.CheckableTag checked={selectedTags.length === 0} className={cn("prompt-filter-tag", selectedTags.length === 0 && "is-active")} onChange={() => { setPage(1); setSelectedTags([]); }}>
|
||||
全部
|
||||
</Tag.CheckableTag>
|
||||
{availableTags.map((tag) => (
|
||||
<Tag.CheckableTag key={tag} checked={selectedTags.includes(tag)} className={cn("prompt-filter-tag", selectedTags.includes(tag) && "is-active")} onChange={() => { setPage(1); toggleTag(tag); }}>
|
||||
{tag}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-5">
|
||||
{items.map((asset) => (
|
||||
<LibraryCard key={asset.id} asset={asset} onOpen={() => setSelectedAsset(asset)} onAdd={() => void saveToMyAssets(asset)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!items.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Pagination current={page} pageSize={PAGE_SIZE} total={total} showSizeChanger={false} onChange={(nextPage) => setPage(nextPage)} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Drawer title="素材详情" open={Boolean(selectedAsset)} size="large" onClose={() => setSelectedAsset(null)}>
|
||||
{selectedAsset ? (
|
||||
<div className="space-y-5">
|
||||
{selectedAsset.coverUrl ? <Image src={selectedAsset.coverUrl} alt={selectedAsset.title} className="rounded-lg" /> : <div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{selectedAsset.content || "暂无封面"}</div>}
|
||||
<div>
|
||||
<Typography.Title level={4} className="!mb-2">{selectedAsset.title}</Typography.Title>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Tag>{selectedAsset.type === "image" ? "图片" : "文本"}</Tag>
|
||||
{selectedAsset.tags.map((tag) => <Tag key={tag}>{tag}</Tag>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
|
||||
<Typography.Text type="secondary" className="block text-xs">内容</Typography.Text>
|
||||
{selectedAsset.type === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{selectedAsset.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{selectedAsset.url}</Typography.Text>}
|
||||
</div>
|
||||
{selectedAsset.description ? <Typography.Paragraph type="secondary">{selectedAsset.description}</Typography.Paragraph> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedAsset.type === "text" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => void copyText(selectedAsset.content)}>复制文本</Button> : null}
|
||||
{selectedAsset.type === "image" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => void copyText(selectedAsset.url)}>复制链接</Button> : null}
|
||||
<Button icon={<FolderPlus className="size-4" />} onClick={() => void saveToMyAssets(selectedAsset)}>加入我的素材</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen: () => void; onAdd: () => void }) {
|
||||
const cover = asset.coverUrl;
|
||||
return (
|
||||
<Card hoverable className="overflow-hidden" styles={{ body: { padding: 0 } }} cover={<button type="button" className="block w-full text-left" onClick={onOpen}>{cover ? <img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.content || "暂无封面"}</div>}</button>}>
|
||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
|
||||
<Tag className="m-0 shrink-0 text-[11px]">{asset.type === "image" ? "图片" : "文本"}</Tag>
|
||||
</div>
|
||||
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
|
||||
{asset.type === "text" ? asset.content : asset.url}
|
||||
</Typography.Paragraph>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{asset.tags.slice(0, 3).map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)}
|
||||
{!asset.tags.length ? <Tag className="m-0 text-[11px]">无标签</Tag> : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-4 pb-4">
|
||||
<Button size="small" onClick={onOpen}>查看</Button>
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={onAdd}>加入我的素材</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
async function remoteImageToDataUrl(url: string) {
|
||||
const response = await axios.get(url, { responseType: "blob" });
|
||||
const blob = response.data as Blob;
|
||||
return await blobToDataUrl(blob);
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
"use client";
|
||||
|
||||
import { Copy, Download, PencilLine, Search, Trash2, Upload } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { App, Button, Card, Drawer, Empty, Form, Image, Input, Modal, Pagination, Select, Space, Tag, Typography } from "antd";
|
||||
|
||||
import { formatBytes, readFileAsDataUrl } from "@/lib/image-utils";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAssetStore, type Asset, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
|
||||
|
||||
type AssetFormValues = {
|
||||
kind: AssetKind;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
tags: string[];
|
||||
source?: string;
|
||||
note?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type ImageDraft = ImageAsset["data"] | null;
|
||||
|
||||
const kindOptions = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
];
|
||||
|
||||
export default function AssetsPage() {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm<AssetFormValues>();
|
||||
const coverInputRef = useRef<HTMLInputElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
const updateAsset = useAssetStore((state) => state.updateAsset);
|
||||
const removeAsset = useAssetStore((state) => state.removeAsset);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState<AssetKind | "all">("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [editingAsset, setEditingAsset] = useState<Asset | null>(null);
|
||||
const [isAssetOpen, setIsAssetOpen] = useState(false);
|
||||
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
|
||||
const [deletingAsset, setDeletingAsset] = useState<Asset | null>(null);
|
||||
const [formKind, setFormKind] = useState<AssetKind>("text");
|
||||
const [imageDraft, setImageDraft] = useState<ImageDraft>(null);
|
||||
const coverUrl = Form.useWatch("coverUrl", form) || "";
|
||||
const title = Form.useWatch("title", form) || "";
|
||||
const tags = Form.useWatch("tags", form) || [];
|
||||
const content = Form.useWatch("content", form) || "";
|
||||
const validAssets = useMemo(() => assets.filter((asset) => asset.kind === "text" || asset.kind === "image"), [assets]);
|
||||
|
||||
const filteredAssets = useMemo(() => {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return validAssets.filter((asset) => {
|
||||
if (kindFilter !== "all" && asset.kind !== kindFilter) return false;
|
||||
if (!query) return true;
|
||||
return assetSearchText(asset).includes(query);
|
||||
});
|
||||
}, [validAssets, keyword, kindFilter]);
|
||||
|
||||
const visibleAssets = useMemo(() => {
|
||||
const start = (page - 1) * pageSize;
|
||||
return filteredAssets.slice(start, start + pageSize);
|
||||
}, [filteredAssets, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const maxPage = Math.max(1, Math.ceil(filteredAssets.length / pageSize));
|
||||
setPage((value) => Math.min(value, maxPage));
|
||||
}, [filteredAssets.length, pageSize]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingAsset(null);
|
||||
setImageDraft(null);
|
||||
setFormKind("text");
|
||||
form.setFieldsValue({ kind: "text", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" });
|
||||
setIsAssetOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (asset: Asset) => {
|
||||
setEditingAsset(asset);
|
||||
setFormKind(asset.kind);
|
||||
setImageDraft(asset.kind === "image" ? asset.data : null);
|
||||
form.setFieldsValue({
|
||||
kind: asset.kind,
|
||||
title: asset.title,
|
||||
coverUrl: asset.coverUrl,
|
||||
tags: asset.tags || [],
|
||||
source: asset.source,
|
||||
note: asset.note,
|
||||
content: asset.kind === "text" ? asset.data.content : "",
|
||||
});
|
||||
setIsAssetOpen(true);
|
||||
};
|
||||
|
||||
const saveAsset = async () => {
|
||||
const values = await form.validateFields();
|
||||
const base = {
|
||||
title: values.title.trim(),
|
||||
coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageDraft ? imageDraft.dataUrl : ""),
|
||||
tags: values.tags || [],
|
||||
source: values.source?.trim(),
|
||||
note: values.note?.trim(),
|
||||
metadata: editingAsset?.metadata || { source: "manual" },
|
||||
};
|
||||
|
||||
if (values.kind === "text") {
|
||||
const asset = { ...base, kind: "text" as const, data: { content: (values.content || "").trim() } };
|
||||
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
|
||||
} else {
|
||||
if (!imageDraft) {
|
||||
message.error("请选择图片文件");
|
||||
return;
|
||||
}
|
||||
const asset = { ...base, kind: "image" as const, data: imageDraft };
|
||||
editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset);
|
||||
}
|
||||
|
||||
message.success(editingAsset ? "素材已更新" : "素材已保存");
|
||||
setIsAssetOpen(false);
|
||||
};
|
||||
|
||||
const readCoverFile = async (file?: File) => {
|
||||
if (!file) return;
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
form.setFieldValue("coverUrl", dataUrl);
|
||||
};
|
||||
|
||||
const readImageFile = async (file?: File) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
const image = await uploadImage(file);
|
||||
const draft = { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType };
|
||||
setImageDraft(draft);
|
||||
if (!form.getFieldValue("coverUrl")) form.setFieldValue("coverUrl", draft.dataUrl);
|
||||
if (!form.getFieldValue("title")) form.setFieldValue("title", file.name);
|
||||
};
|
||||
|
||||
const copyText = async (asset: Asset) => {
|
||||
if (asset.kind !== "text") return;
|
||||
await navigator.clipboard.writeText(asset.data.content);
|
||||
message.success("文本已复制");
|
||||
};
|
||||
|
||||
const downloadImage = (asset: Asset) => {
|
||||
if (asset.kind !== "image") return;
|
||||
const link = document.createElement("a");
|
||||
link.href = asset.data.dataUrl;
|
||||
link.download = `${asset.title || "asset"}.${asset.data.mimeType.split("/")[1] || "png"}`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deletingAsset) return;
|
||||
removeAsset(deletingAsset.id);
|
||||
message.success("素材已删除");
|
||||
setDeletingAsset(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-900 dark:text-stone-100">
|
||||
<main className="min-h-0 flex-1 overflow-y-auto bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.14)_1px,transparent_1px)]">
|
||||
<div className="pb-8">
|
||||
<div className="mx-auto max-w-5xl text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">我的素材</h1>
|
||||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">收藏常用文本和图片,按类型、标题和标签快速查找。</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-8 w-full max-w-2xl">
|
||||
<Input.Search className="w-full" size="large" allowClear prefix={<Search className="size-4 text-stone-400" />} value={keyword} placeholder="搜索标题、内容、标签或来源" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} onSearch={(value) => { setPage(1); setKeyword(value); }} />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-center">
|
||||
<div className="text-xs font-medium text-stone-500 dark:text-stone-400">类型</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{kindOptions.map((option) => (
|
||||
<Tag.CheckableTag key={option.value} checked={kindFilter === option.value} className={cn("prompt-filter-tag", kindFilter === option.value && "is-active")} onChange={() => { setPage(1); setKindFilter(option.value as AssetKind | "all"); }}>
|
||||
{option.label}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="cursor-pointer self-start text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline sm:self-center dark:text-stone-300" onClick={openCreate}>
|
||||
新增素材
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{visibleAssets.map((asset) => (
|
||||
<AssetCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
onOpen={() => setPreviewAsset(asset)}
|
||||
onEdit={() => openEdit(asset)}
|
||||
onCopy={copyText}
|
||||
onDownload={downloadImage}
|
||||
onDelete={() => setDeletingAsset(asset)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!visibleAssets.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredAssets.length}
|
||||
showSizeChanger
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
onChange={(nextPage, nextPageSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextPageSize);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Modal title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<div className="grid gap-6 pt-1 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", tags: [] }}>
|
||||
<Form.Item name="kind" label="类型">
|
||||
<Select options={[{ label: "文本", value: "text" }, { label: "图片", value: "image" }]} onChange={(value) => setFormKind(value)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||
<Input size="large" placeholder="给素材起一个容易检索的名字" />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL">
|
||||
<Space.Compact className="w-full">
|
||||
<Input placeholder="可粘贴图片 URL,也可以上传本地封面" />
|
||||
<Button icon={<Upload className="size-3.5" />} onClick={() => coverInputRef.current?.click()}>上传</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="tags" label="标签">
|
||||
<Select mode="tags" tokenSeparators={[",", ","]} placeholder="输入标签后回车" />
|
||||
</Form.Item>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Form.Item name="source" label="来源">
|
||||
<Input placeholder="手动添加 / 画布 / 提示词库" />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input placeholder="可选" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{formKind === "text" ? (
|
||||
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
|
||||
<Input.TextArea rows={8} placeholder="保存提示词、说明文案、参考描述等文本素材" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item label="图片内容" required>
|
||||
<div className="rounded-lg border border-dashed border-stone-300 p-4 dark:border-stone-700">
|
||||
<Button icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}>选择图片文件</Button>
|
||||
{imageDraft ? <Typography.Text type="secondary" className="ml-3 text-xs">{imageDraft.width}x{imageDraft.height} · {formatBytes(imageDraft.bytes)}</Typography.Text> : <Typography.Text type="secondary" className="ml-3 text-xs">未选择图片</Typography.Text>}
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4 dark:border-stone-800 dark:bg-stone-950">
|
||||
<Typography.Text strong>预览</Typography.Text>
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
|
||||
{coverUrl || imageDraft?.dataUrl ? <img src={coverUrl || imageDraft?.dataUrl} alt="" className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm text-stone-500 dark:bg-stone-900">{content || "暂无封面"}</div>}
|
||||
<div className="p-4">
|
||||
<Typography.Text strong ellipsis className="block">{title || "未命名素材"}</Typography.Text>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{tags.length ? tags.map((tag) => <Tag key={tag} className="m-0">{tag}</Tag>) : <Tag className="m-0">未打标签</Tag>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input ref={coverInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
|
||||
void readCoverFile(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}} />
|
||||
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
|
||||
void readImageFile(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}} />
|
||||
</Modal>
|
||||
|
||||
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyText} onDownload={downloadImage} />
|
||||
|
||||
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetCard({ asset, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: Asset; onOpen: () => void; onEdit: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void; onDelete: () => void }) {
|
||||
const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
|
||||
const summary = assetSummary(asset);
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
className="overflow-hidden"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
cover={
|
||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||
{cover ? <img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
|
||||
<Typography.Text type="secondary" className="mt-1 block text-xs">{asset.source || "未标注来源"}</Typography.Text>
|
||||
</div>
|
||||
<Tag className="m-0 shrink-0 text-[11px]">{asset.kind === "image" ? "图片" : "文本"}</Tag>
|
||||
</div>
|
||||
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
|
||||
{summary}
|
||||
</Typography.Paragraph>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{(asset.tags || []).slice(0, 3).map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)}
|
||||
{!asset.tags?.length ? <Tag className="m-0 text-[11px]">无标签</Tag> : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-4 pb-4">
|
||||
<Button size="small" onClick={onOpen}>查看</Button>
|
||||
<Button size="small" icon={<PencilLine className="size-3.5" />} onClick={onEdit}>编辑</Button>
|
||||
{asset.kind === "text" ? <Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void onCopy(asset)}>复制</Button> : null}
|
||||
{asset.kind === "image" ? <Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(asset)}>下载</Button> : null}
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}>删除</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: Asset | null; onClose: () => void; onCopy: (asset: Asset) => void; onDownload: (asset: Asset) => void }) {
|
||||
const cover = asset ? asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "") : "";
|
||||
return (
|
||||
<Drawer title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}>
|
||||
{asset ? (
|
||||
<div className="space-y-5">
|
||||
{cover ? <Image src={cover} alt={asset.title} className="rounded-lg" /> : <div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>}
|
||||
<div>
|
||||
<Typography.Title level={4} className="!mb-2">{asset.title}</Typography.Title>
|
||||
<Space size={[4, 4]} wrap>
|
||||
<Tag>{asset.kind === "image" ? "图片" : "文本"}</Tag>
|
||||
{(asset.tags || []).map((tag) => <Tag key={tag}>{tag}</Tag>)}
|
||||
</Space>
|
||||
</div>
|
||||
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
|
||||
<Typography.Text type="secondary" className="block text-xs">内容</Typography.Text>
|
||||
{asset.kind === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{asset.data.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{asset.data.width}x{asset.data.height} · {formatBytes(asset.data.bytes)} · {asset.data.mimeType}</Typography.Text>}
|
||||
</div>
|
||||
{asset.note ? <div><Typography.Text type="secondary">备注</Typography.Text><Typography.Paragraph className="mt-1">{asset.note}</Typography.Paragraph></div> : null}
|
||||
<Space>
|
||||
{asset.kind === "text" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(asset)}>复制文本</Button> : null}
|
||||
{asset.kind === "image" ? <Button type="primary" icon={<Download className="size-4" />} onClick={() => onDownload(asset)}>下载图片</Button> : null}
|
||||
</Space>
|
||||
</div>
|
||||
) : null}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function assetSummary(asset: Asset) {
|
||||
if (asset.kind === "text") return asset.data.content;
|
||||
return `${asset.data.width}x${asset.data.height} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`;
|
||||
}
|
||||
|
||||
function assetSearchText(asset: Asset) {
|
||||
return [
|
||||
asset.title,
|
||||
asset.source || "",
|
||||
asset.note || "",
|
||||
(asset.tags || []).join(" "),
|
||||
asset.kind === "text" ? asset.data.content : asset.data.mimeType,
|
||||
].join(" ").toLowerCase();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
import CanvasClientPage from "./canvas-client-page";
|
||||
|
||||
export default function CanvasPage() {
|
||||
return <CanvasClientPage />;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { App, Empty, Input, Modal, Pagination, Spin, Tabs, Tag } from "antd";
|
||||
import { Search } from "lucide-react";
|
||||
import axios from "axios";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets";
|
||||
|
||||
export type AssetPickerTab = "my-assets" | "library";
|
||||
|
||||
export type InsertAssetPayload =
|
||||
| { kind: "text"; content: string; title: string }
|
||||
| { kind: "image"; dataUrl: string; title: string; storageKey?: string };
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
defaultTab?: AssetPickerTab;
|
||||
onInsert: (payload: InsertAssetPayload) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function AssetPickerModal({ open, defaultTab = "my-assets", onInsert, onClose }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<AssetPickerTab>(defaultTab);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setActiveTab(defaultTab);
|
||||
}, [open, defaultTab]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="选择素材"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={860}
|
||||
destroyOnHidden
|
||||
styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as AssetPickerTab)}
|
||||
items={[
|
||||
{ key: "my-assets", label: "我的素材", children: <MyAssetsTab onInsert={onInsert} /> },
|
||||
{ key: "library", label: "素材库", children: <LibraryTab onInsert={onInsert} /> },
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
const kindOptions = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
];
|
||||
|
||||
function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [inserting, setInserting] = useState<string | null>(null);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["asset-picker-library", keyword, kindFilter, page],
|
||||
queryFn: () => fetchAssetLibrary({ keyword, type: kindFilter, page, pageSize: PAGE_SIZE }),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const items = query.data?.items || [];
|
||||
const total = query.data?.total || 0;
|
||||
|
||||
const handleInsert = async (asset: AssetLibraryItem) => {
|
||||
try {
|
||||
setInserting(asset.id);
|
||||
if (asset.type === "text") {
|
||||
onInsert({ kind: "text", content: asset.content, title: asset.title });
|
||||
} else {
|
||||
const dataUrl = await remoteImageToDataUrl(asset.url);
|
||||
onInsert({ kind: "image", dataUrl, title: asset.title });
|
||||
}
|
||||
} catch {
|
||||
message.error("插入失败");
|
||||
} finally {
|
||||
setInserting(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
className="w-56"
|
||||
size="small"
|
||||
prefix={<Search className="size-3.5 text-stone-400" />}
|
||||
placeholder="搜索素材"
|
||||
value={keyword}
|
||||
allowClear
|
||||
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
{[{ label: "全部", value: "" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }].map((opt) => (
|
||||
<Tag.CheckableTag
|
||||
key={opt.value || "all"}
|
||||
checked={kindFilter === opt.value}
|
||||
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
|
||||
onChange={() => { setPage(1); setKindFilter(opt.value); }}
|
||||
>
|
||||
{opt.label}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="flex justify-center py-16"><Spin /></div>
|
||||
) : items.length ? (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{items.map((asset) => (
|
||||
<PickerCard
|
||||
key={asset.id}
|
||||
title={asset.title}
|
||||
kind={asset.type}
|
||||
cover={asset.coverUrl}
|
||||
loading={inserting === asset.id}
|
||||
onClick={() => void handleInsert(asset)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
|
||||
)}
|
||||
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex justify-center">
|
||||
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={total} onChange={setPage} showSizeChanger={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerCard({ title, kind, cover, loading, onClick }: { title: string; kind: string; cover: string; loading?: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="group relative cursor-pointer overflow-hidden rounded-lg border border-stone-200 bg-white text-left transition hover:border-stone-400 hover:shadow-md dark:border-stone-700 dark:bg-stone-900 dark:hover:border-stone-500"
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
>
|
||||
{cover ? (
|
||||
<img src={cover} alt={title} className="aspect-[4/3] w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-3 text-center text-xs leading-5 text-stone-500 dark:bg-stone-800 dark:text-stone-400">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="line-clamp-1 text-xs font-medium text-stone-800 dark:text-stone-200">{title}</span>
|
||||
<Tag className="m-0 shrink-0 text-[10px]">{kind === "image" ? "图片" : "文本"}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/60 dark:bg-stone-900/60">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100">
|
||||
插入
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
async function remoteImageToDataUrl(url: string) {
|
||||
const response = await axios.get(url, { responseType: "blob" });
|
||||
const blob = response.data as Blob;
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return assets
|
||||
.filter((a) => a.kind === "text" || a.kind === "image")
|
||||
.filter((a) => kindFilter === "all" || a.kind === kindFilter)
|
||||
.filter((a) => !query || [a.title, ...(a.tags || [])].join(" ").toLowerCase().includes(query));
|
||||
}, [assets, keyword, kindFilter]);
|
||||
|
||||
const visible = useMemo(() => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [filtered, page]);
|
||||
|
||||
useEffect(() => {
|
||||
const maxPage = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
setPage((v) => Math.min(v, maxPage));
|
||||
}, [filtered.length]);
|
||||
|
||||
const handleInsert = (asset: Asset) => {
|
||||
if (asset.kind === "text") {
|
||||
onInsert({ kind: "text", content: asset.data.content, title: asset.title });
|
||||
} else {
|
||||
onInsert({ kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
className="w-56"
|
||||
size="small"
|
||||
prefix={<Search className="size-3.5 text-stone-400" />}
|
||||
placeholder="搜索素材"
|
||||
value={keyword}
|
||||
allowClear
|
||||
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
{kindOptions.map((opt) => (
|
||||
<Tag.CheckableTag
|
||||
key={opt.value}
|
||||
checked={kindFilter === opt.value}
|
||||
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
|
||||
onChange={() => { setPage(1); setKindFilter(opt.value); }}
|
||||
>
|
||||
{opt.label}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visible.length ? (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{visible.map((asset) => (
|
||||
<PickerCard key={asset.id} title={asset.title} kind={asset.kind} cover={asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "")} onClick={() => handleInsert(asset)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
|
||||
)}
|
||||
|
||||
{filtered.length > PAGE_SIZE && (
|
||||
<div className="flex justify-center">
|
||||
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={filtered.length} onChange={setPage} showSizeChanger={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { ArrowUp, History, ImageIcon, LoaderCircle, MessageSquare, PanelRightClose, Plus, RotateCcw, Settings2, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { Button, ConfigProvider, Input, InputNumber, Modal, Popover, Segmented, Tooltip } from "antd";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { ImageGenerationPending } from "@/components/image-generation-pending";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import type { AiConfig } from "@/lib/ai-config";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { createId } from "@/lib/id";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { requestEdit, requestGeneration, requestImageQuestion, type ChatCompletionMessage } from "@/services/api/image";
|
||||
import { imageToDataUrl, uploadImage } from "@/services/image-storage";
|
||||
import { useAiConfigStore } from "@/stores/use-ai-config-store";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import { DiaTextReveal } from "@/components/ui/dia-text-reveal";
|
||||
import { CanvasPromptLibrary } from "./canvas-prompt-library";
|
||||
import { CanvasNodeType, type CanvasAssistantImage, type CanvasAssistantMessage, type CanvasAssistantReference, type CanvasAssistantSession, type CanvasNodeData } from "../types";
|
||||
|
||||
type AssistantMode = "ask" | "image";
|
||||
const PANEL_MOTION_MS = 500;
|
||||
const PANEL_MOTION_SECONDS = PANEL_MOTION_MS / 1000;
|
||||
|
||||
type CanvasAssistantPanelProps = {
|
||||
nodes: CanvasNodeData[];
|
||||
selectedNodeIds: Set<string>;
|
||||
sessions: CanvasAssistantSession[];
|
||||
activeSessionId: string | null;
|
||||
onSelectNodeIds: (ids: Set<string>) => void;
|
||||
onSessionsChange: (sessions: CanvasAssistantSession[], activeSessionId: string | null) => void;
|
||||
onInsertImage: (image: CanvasAssistantImage) => void;
|
||||
onInsertText: (text: string) => void;
|
||||
onPasteImage: (file: File) => void;
|
||||
onCollapseStart: () => void;
|
||||
onCollapse: () => void;
|
||||
};
|
||||
|
||||
export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onInsertImage, onInsertText, onPasteImage, onCollapseStart, onCollapse }: CanvasAssistantPanelProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const config = useAiConfigStore((state) => state.config);
|
||||
const cleanupImages = useAssetStore((state) => state.cleanupImages);
|
||||
const updateConfig = useAiConfigStore((state) => state.updateConfig);
|
||||
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
|
||||
const [width, setWidth] = useState(390);
|
||||
const [view, setView] = useState<"chat" | "history">("chat");
|
||||
const [mode, setMode] = useState<AssistantMode>("image");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [checkedChatIds, setCheckedChatIds] = useState<string[]>([]);
|
||||
const [deleteChatIds, setDeleteChatIds] = useState<string[]>([]);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set());
|
||||
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => sessions.length ? sessions : [createSession()]);
|
||||
const [localActiveSessionId, setLocalActiveSessionId] = useState<string | null>(activeSessionId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessions.length) return;
|
||||
setLocalSessions(sessions);
|
||||
setLocalActiveSessionId(activeSessionId);
|
||||
}, [activeSessionId, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
onSessionsChange(localSessions, localActiveSessionId);
|
||||
}, [localActiveSessionId, localSessions, onSessionsChange]);
|
||||
|
||||
const safeSessions = localSessions.length ? localSessions : [createSession()];
|
||||
const activeSession = useMemo(() => safeSessions.find((session) => session.id === localActiveSessionId) || safeSessions[0] || null, [localActiveSessionId, safeSessions]);
|
||||
const historySessions = safeSessions.filter((session) => session.messages.length > 0);
|
||||
const messages = activeSession?.messages || [];
|
||||
const hasMessages = messages.length > 0;
|
||||
const selectedNodeKey = useMemo(() => Array.from(selectedNodeIds).sort().join(","), [selectedNodeIds]);
|
||||
const allSelectedReferences = useMemo(() => buildAssistantReferences(nodes, selectedNodeIds), [nodes, selectedNodeIds]);
|
||||
const selectedReferences = useMemo(() => allSelectedReferences.filter((item) => !removedReferenceIds.has(item.id)), [allSelectedReferences, removedReferenceIds]);
|
||||
const iconButtonStyle = { color: theme.node.muted };
|
||||
|
||||
useEffect(() => {
|
||||
setRemovedReferenceIds(new Set());
|
||||
}, [selectedNodeKey]);
|
||||
|
||||
const updateSession = (sessionId: string, updater: (session: CanvasAssistantSession) => CanvasAssistantSession) => {
|
||||
setLocalSessions((prev) => prev.map((session) => session.id === sessionId ? updater(session) : session));
|
||||
};
|
||||
|
||||
const appendMessage = (sessionId: string, message: CanvasAssistantMessage) => {
|
||||
updateSession(sessionId, (session) => ({
|
||||
...session,
|
||||
title: session.messages.length ? session.title : message.text.slice(0, 18) || "新对话",
|
||||
messages: [...session.messages, message],
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateMessage = (sessionId: string, messageId: string, patch: Partial<CanvasAssistantMessage>) => {
|
||||
updateSession(sessionId, (session) => ({
|
||||
...session,
|
||||
messages: session.messages.map((message) => message.id === messageId ? { ...message, ...patch } : message),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
};
|
||||
|
||||
const startChatSession = () => {
|
||||
if (activeSession && activeSession.messages.length === 0) {
|
||||
setLocalActiveSessionId(activeSession.id);
|
||||
return;
|
||||
}
|
||||
const session = createSession();
|
||||
setLocalSessions((prev) => [session, ...prev]);
|
||||
setLocalActiveSessionId(session.id);
|
||||
};
|
||||
|
||||
const removeSessions = (ids: string[]) => {
|
||||
const next = safeSessions.filter((session) => !ids.includes(session.id));
|
||||
if (!next.length) {
|
||||
const session = createSession();
|
||||
setLocalSessions([session]);
|
||||
setLocalActiveSessionId(session.id);
|
||||
} else {
|
||||
setLocalSessions(next);
|
||||
setLocalActiveSessionId(localActiveSessionId && ids.includes(localActiveSessionId) ? next[0].id : localActiveSessionId);
|
||||
}
|
||||
cleanupImages({ sessions: next });
|
||||
setCheckedChatIds((prev) => prev.filter((id) => !ids.includes(id)));
|
||||
};
|
||||
|
||||
const clearSessions = () => {
|
||||
const session = createSession();
|
||||
setLocalSessions([session]);
|
||||
setLocalActiveSessionId(session.id);
|
||||
setCheckedChatIds([]);
|
||||
cleanupImages({ sessions: [session] });
|
||||
};
|
||||
|
||||
const sendMessage = async (text: string, nextMode: AssistantMode, history: CanvasAssistantMessage[], savedReferences?: CanvasAssistantReference[]) => {
|
||||
const requestConfig = { ...config, model: nextMode === "image" ? config.imageModel || config.model : config.textModel || config.model };
|
||||
if (!requestConfig.baseUrl.trim() || !requestConfig.model.trim() || !requestConfig.apiKey.trim()) {
|
||||
openConfigDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = activeSession || createSession();
|
||||
if (!activeSession) {
|
||||
setLocalSessions([session]);
|
||||
setLocalActiveSessionId(session.id);
|
||||
}
|
||||
|
||||
const refs = savedReferences || selectedReferences;
|
||||
const userMessage: CanvasAssistantMessage = { id: createId(), role: "user", mode: nextMode, text, references: refs };
|
||||
const assistantId = createId();
|
||||
appendMessage(session.id, userMessage);
|
||||
appendMessage(session.id, { id: assistantId, role: "assistant", mode: nextMode, text: nextMode === "image" ? "正在生成图片" : "正在回答", isLoading: true });
|
||||
setPrompt("");
|
||||
setIsRunning(true);
|
||||
|
||||
try {
|
||||
if (nextMode === "image") {
|
||||
const referenceImages: ReferenceImage[] = await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ id: item.id, name: `${item.title}.png`, type: "image/png", dataUrl: await imageToDataUrl(item), storageKey: item.storageKey })));
|
||||
const images = referenceImages.length ? await requestEdit(requestConfig, text, referenceImages) : await requestGeneration(requestConfig, text);
|
||||
const storedImages = await Promise.all(images.map((image) => uploadImage(image.dataUrl)));
|
||||
updateMessage(session.id, assistantId, {
|
||||
text: `生成了 ${storedImages.length} 张图片`,
|
||||
images: storedImages.map((image, index) => ({ id: images[index].id, dataUrl: image.url, storageKey: image.storageKey, prompt: text })),
|
||||
isLoading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const answer = await requestImageQuestion(requestConfig, await buildChatMessages([...history, userMessage]), (streamed) => {
|
||||
updateMessage(session.id, assistantId, { text: streamed, isLoading: false });
|
||||
});
|
||||
updateMessage(session.id, assistantId, { text: answer, isLoading: false });
|
||||
} catch (error) {
|
||||
updateMessage(session.id, assistantId, { text: error instanceof Error ? error.message : "操作失败", isLoading: false });
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const text = prompt.trim();
|
||||
if (!text || isRunning) return;
|
||||
await sendMessage(text, mode, messages);
|
||||
};
|
||||
|
||||
const retryMessage = (message: CanvasAssistantMessage) => {
|
||||
const index = messages.findIndex((item) => item.id === message.id);
|
||||
const userIndex = messages.slice(0, index).findLastIndex((item) => item.role === "user");
|
||||
const user = messages[userIndex];
|
||||
if (user) void sendMessage(user.text, user.mode, messages.slice(0, userIndex), user.references);
|
||||
};
|
||||
|
||||
const startResize = () => {
|
||||
const move = (event: MouseEvent) => setWidth(Math.min(760, Math.max(320, window.innerWidth - event.clientX)));
|
||||
const stop = () => {
|
||||
setResizing(false);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
document.removeEventListener("mousemove", move);
|
||||
document.removeEventListener("mouseup", stop);
|
||||
};
|
||||
setResizing(true);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", move);
|
||||
document.addEventListener("mouseup", stop);
|
||||
};
|
||||
|
||||
const collapse = () => {
|
||||
setClosing(true);
|
||||
onCollapseStart();
|
||||
window.setTimeout(onCollapse, PANEL_MOTION_MS);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex shrink-0"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: closing ? 0 : width + 1, opacity: closing ? 0 : 1 }}
|
||||
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ overflow: "clip", pointerEvents: closing ? "none" : undefined }}
|
||||
>
|
||||
<motion.aside className="relative flex shrink-0 flex-col border-l" initial={{ x: 48 }} animate={{ x: closing ? 28 : 0 }} transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }} style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||
<button type="button" className="absolute inset-y-0 left-0 z-40 w-4 -translate-x-1/2 cursor-col-resize" onMouseDown={startResize} aria-label="调整右侧面板宽度" />
|
||||
<div className="flex items-center justify-between border-b px-4 py-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex items-center gap-2 text-sm font-medium"><Sparkles className="size-4" />{view === "history" ? "历史记录" : "画布助手(未开发)"}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{view === "history" ? (
|
||||
<>
|
||||
<Tooltip title="删除选中">
|
||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Trash2 className="size-4" />} disabled={!checkedChatIds.length} onClick={() => setDeleteChatIds(checkedChatIds)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="删除全部">
|
||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<X className="size-4" />} disabled={!historySessions.length} onClick={() => setDeleteChatIds(historySessions.map((session) => session.id))} />
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
<Tooltip title={view === "history" ? "返回对话" : "历史记录"}>
|
||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<History className="size-4" />} onClick={() => setView(view === "history" ? "chat" : "history")} />
|
||||
</Tooltip>
|
||||
<Tooltip title="新对话">
|
||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Plus className="size-4" />} disabled={!hasMessages} onClick={() => { startChatSession(); setView("chat"); }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="配置">
|
||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<Settings2 className="size-4" />} onClick={() => openConfigDialog(false)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="收起对话">
|
||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={iconButtonStyle} icon={<PanelRightClose className="size-4" />} onClick={collapse} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4">
|
||||
{view === "history" ? (
|
||||
<AssistantHistory
|
||||
sessions={historySessions}
|
||||
activeSession={activeSession}
|
||||
checkedIds={checkedChatIds.filter((id) => historySessions.some((session) => session.id === id))}
|
||||
onToggleChecked={(id, checked) => setCheckedChatIds((prev) => checked ? [...new Set([...prev, id])] : prev.filter((item) => item !== id))}
|
||||
onOpen={(id) => { setLocalActiveSessionId(id); setView("chat"); }}
|
||||
onDelete={(id) => setDeleteChatIds([id])}
|
||||
/>
|
||||
) : messages.length ? (
|
||||
<AssistantMessages messages={messages} onRetry={retryMessage} onInsertImage={onInsertImage} onInsertText={onInsertText} />
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center px-1 text-center">
|
||||
<div className="relative font-serif text-4xl font-bold italic tracking-normal" style={{ color: theme.node.text }}>
|
||||
<span>Infinite Canvas</span>
|
||||
<DiaTextReveal
|
||||
className="absolute inset-0"
|
||||
colors={["#A97CF8", "#F38CB8", "#FDCC92"]}
|
||||
textColor="transparent"
|
||||
duration={1.8}
|
||||
startOnView={false}
|
||||
text="Infinite Canvas"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 font-serif text-base italic tracking-wide opacity-60">One canvas, infinite ideas</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view === "chat" ? (
|
||||
<AssistantComposer
|
||||
mode={mode}
|
||||
prompt={prompt}
|
||||
isRunning={isRunning}
|
||||
references={selectedReferences}
|
||||
config={config}
|
||||
onModeChange={setMode}
|
||||
onPromptChange={setPrompt}
|
||||
onSubmit={submit}
|
||||
onConfigChange={updateConfig}
|
||||
onMissingConfig={() => openConfigDialog(true)}
|
||||
onRemoveReference={(id) => {
|
||||
setRemovedReferenceIds((prev) => new Set(prev).add(id));
|
||||
if (selectedNodeIds.has(id)) onSelectNodeIds(new Set(Array.from(selectedNodeIds).filter((nodeId) => nodeId !== id)));
|
||||
}}
|
||||
onPasteImage={onPasteImage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
title="删除对话记录?"
|
||||
open={deleteChatIds.length > 0}
|
||||
centered
|
||||
onCancel={() => setDeleteChatIds([])}
|
||||
footer={<>
|
||||
<Button onClick={() => setDeleteChatIds([])}>取消</Button>
|
||||
<Button
|
||||
danger
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
deleteChatIds.length === historySessions.length ? clearSessions() : removeSessions(deleteChatIds);
|
||||
setDeleteChatIds([]);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<p className="text-sm opacity-60">将删除 {deleteChatIds.length} 条对话记录,此操作不可撤销。</p>
|
||||
</Modal>
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantComposer({
|
||||
mode,
|
||||
prompt,
|
||||
isRunning,
|
||||
references,
|
||||
config,
|
||||
onModeChange,
|
||||
onPromptChange,
|
||||
onSubmit,
|
||||
onConfigChange,
|
||||
onMissingConfig,
|
||||
onRemoveReference,
|
||||
onPasteImage,
|
||||
}: {
|
||||
mode: AssistantMode;
|
||||
prompt: string;
|
||||
isRunning: boolean;
|
||||
references: CanvasAssistantReference[];
|
||||
config: AiConfig;
|
||||
onModeChange: (mode: AssistantMode) => void;
|
||||
onPromptChange: (prompt: string) => void;
|
||||
onSubmit: () => void;
|
||||
onConfigChange: (key: keyof AiConfig, value: string) => void;
|
||||
onMissingConfig: () => void;
|
||||
onRemoveReference: (id: string) => void;
|
||||
onPasteImage: (file: File) => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
return (
|
||||
<div className="px-2 pb-2" onWheelCapture={(event) => event.stopPropagation()}>
|
||||
{references.length ? (
|
||||
<div className="thin-scrollbar mb-1.5 flex max-w-full gap-1.5 overflow-x-auto px-1 pb-1">
|
||||
{references.map((item) => <AssistantReferenceChip key={item.id} item={item} onRemove={() => onRemoveReference(item.id)} />)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-[28px] border px-3 pb-3 pt-3 shadow-lg" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
onPaste={(event) => {
|
||||
const file = Array.from(event.clipboardData.files).find((item) => item.type.startsWith("image/"));
|
||||
if (!file) return;
|
||||
event.preventDefault();
|
||||
onPasteImage(file);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
}}
|
||||
className="thin-scrollbar h-20 w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:text-stone-400"
|
||||
style={{ color: theme.node.text }}
|
||||
placeholder={mode === "image" ? "描述你想生成或修改的图片" : "输入你想问的问题"}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<CanvasPromptLibrary onSelect={onPromptChange} />
|
||||
<CanvasThemeProvider theme={theme}>
|
||||
<Segmented
|
||||
size="small"
|
||||
className="canvas-composer-mode !rounded-full !p-0.5"
|
||||
value={mode}
|
||||
onChange={(value) => onModeChange(value as AssistantMode)}
|
||||
options={[
|
||||
{ value: "ask", label: <Tooltip title="文本"><MessageSquare className="size-4" /></Tooltip> },
|
||||
{ value: "image", label: <Tooltip title="生图"><ImageIcon className="size-4" /></Tooltip> },
|
||||
]}
|
||||
/>
|
||||
</CanvasThemeProvider>
|
||||
{mode === "image" ? (
|
||||
<ImageSettingsPopover config={config} onConfigChange={onConfigChange} onMissingConfig={onMissingConfig} />
|
||||
) : (
|
||||
<ModelPicker config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
|
||||
)}
|
||||
</div>
|
||||
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10 shrink-0" icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} disabled={isRunning || !prompt.trim()} onClick={() => void onSubmit()} aria-label="发送" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageSettingsPopover({ config, onConfigChange, onMissingConfig }: { config: AiConfig; onConfigChange: (key: keyof AiConfig, value: string) => void; onMissingConfig: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const quality = config.quality || "auto";
|
||||
const count = String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))));
|
||||
const sizes = ["1:1", "3:2", "2:3", "16:9", "9:16", "auto"];
|
||||
const customSize = sizes.includes(config.size || "auto") ? "" : config.size;
|
||||
return (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="topLeft"
|
||||
arrow={false}
|
||||
overlayClassName="canvas-image-settings-popover"
|
||||
color={theme.toolbar.panel}
|
||||
content={(
|
||||
<CanvasThemeProvider theme={theme}>
|
||||
<div className="w-[330px] space-y-4" style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="text-base font-semibold">图像设置</div>
|
||||
<div className="space-y-1.5">
|
||||
<SettingTitle color={theme.node.muted}>模型</SettingTitle>
|
||||
<ModelPicker className="!h-9 !w-full !max-w-none" config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} fullWidth />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<SettingTitle color={theme.node.muted}>质量</SettingTitle>
|
||||
<Segmented block value={quality} onChange={(value) => onConfigChange("quality", String(value))} options={[{ value: "auto", label: "自动" }, { value: "high", label: "高" }, { value: "medium", label: "中" }, { value: "low", label: "低" }]} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<SettingTitle color={theme.node.muted}>宽高比</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{sizes.map((size) => (
|
||||
<Button key={size} size="small" type={(config.size || "auto") === size ? "primary" : "default"} className="!h-7 !rounded-md !px-1.5" onClick={() => onConfigChange("size", size)}>
|
||||
{size}
|
||||
</Button>
|
||||
))}
|
||||
<Input size="small" className="col-span-2 !h-7" placeholder="自定义比例" value={customSize} onChange={(event) => onConfigChange("size", event.target.value.trim() || "auto")} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<SettingTitle color={theme.node.muted}>生成数量</SettingTitle>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_92px] gap-2">
|
||||
<Segmented block value={["1", "2", "3", "4"].includes(count) ? count : undefined} onChange={(value) => onConfigChange("count", String(value))} options={["1", "2", "3", "4"].map((value) => ({ value, label: `${value} 张` }))} />
|
||||
<InputNumber min={1} max={15} size="small" className="canvas-control-number !h-8 !w-full" value={Number(count)} onChange={(value) => onConfigChange("count", String(value || 1))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CanvasThemeProvider>
|
||||
)}
|
||||
>
|
||||
<Button size="small" type="text" className="!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5" style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />}>
|
||||
<span className="truncate">{qualityLabel(quality)} · {config.size || "auto"} · {count} 张</span>
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasThemeProvider({ theme, children }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode }) {
|
||||
return (
|
||||
<ConfigProvider theme={{ token: { colorBgContainer: theme.toolbar.panel, colorBgElevated: theme.toolbar.panel, colorBorder: theme.node.stroke, colorPrimary: theme.node.activeStroke, colorText: theme.node.text, colorTextLightSolid: theme.node.panel }, components: { Button: { defaultBg: theme.toolbar.panel, defaultBorderColor: theme.node.stroke, defaultColor: theme.node.text }, Input: { activeBorderColor: theme.node.activeStroke, hoverBorderColor: theme.node.activeStroke }, InputNumber: { activeBorderColor: theme.node.activeStroke, hoverBorderColor: theme.node.activeStroke }, Segmented: { itemColor: theme.node.text, itemHoverBg: theme.node.fill, itemSelectedBg: theme.node.activeStroke, itemSelectedColor: theme.node.panel, trackBg: theme.toolbar.panel, trackPadding: 2 } } }}>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingTitle({ children, color }: { children: string; color: string }) {
|
||||
return <div className="text-xs font-medium" style={{ color }}>{children}</div>;
|
||||
}
|
||||
|
||||
function qualityLabel(value: string) {
|
||||
return ({ auto: "自动", high: "高", medium: "中", low: "低" } as Record<string, string>)[value] || value;
|
||||
}
|
||||
|
||||
function AssistantMessages({
|
||||
messages,
|
||||
onRetry,
|
||||
onInsertImage,
|
||||
onInsertText,
|
||||
}: {
|
||||
messages: CanvasAssistantMessage[];
|
||||
onRetry: (message: CanvasAssistantMessage) => void;
|
||||
onInsertImage: (image: CanvasAssistantImage) => void;
|
||||
onInsertText: (text: string) => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
return (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className={cn("flex flex-col gap-2", message.role === "user" ? "items-end" : "items-start")}>
|
||||
<div className="max-w-[88%] whitespace-pre-wrap rounded-2xl px-3 py-2 text-sm leading-6" style={message.role === "user" ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { background: theme.node.fill, color: theme.node.text }}>
|
||||
{message.role === "assistant" ? <div className="mb-1 flex items-center gap-1.5 text-xs opacity-60"><MessageSquare className="size-3.5" />回答</div> : null}
|
||||
{message.text}
|
||||
</div>
|
||||
{message.references?.length ? <MessageReferences message={message} /> : null}
|
||||
{message.isLoading ? <ImageGenerationPending compact label={message.mode === "image" ? "正在生成图片" : "正在回答"} className="w-[250px] rounded-2xl border" /> : null}
|
||||
{message.role === "assistant" && !message.isLoading ? (
|
||||
<div className="flex gap-1">
|
||||
<Button shape="circle" size="small" style={{ borderColor: theme.node.stroke }} icon={<RotateCcw className="size-3.5" />} onClick={() => onRetry(message)} title="重试" />
|
||||
{!message.images?.length ? <Button shape="circle" size="small" style={{ borderColor: theme.node.stroke }} icon={<Plus className="size-3.5" />} onClick={() => onInsertText(message.text)} title="插入画布" /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{message.images?.map((image) => (
|
||||
<div key={image.id} className="w-[250px] overflow-hidden rounded-2xl border" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}>
|
||||
<img src={image.dataUrl} alt="" className="aspect-square w-full object-cover" />
|
||||
<Button type="text" className="!h-8 !w-full !rounded-none" style={{ borderTop: `1px solid ${theme.node.stroke}`, color: theme.node.text }} icon={<Plus className="size-3.5" />} onClick={() => onInsertImage(image)} title="插入画布" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantHistory({
|
||||
sessions,
|
||||
activeSession,
|
||||
checkedIds,
|
||||
onToggleChecked,
|
||||
onOpen,
|
||||
onDelete,
|
||||
}: {
|
||||
sessions: CanvasAssistantSession[];
|
||||
activeSession: CanvasAssistantSession | null;
|
||||
checkedIds: string[];
|
||||
onToggleChecked: (id: string, checked: boolean) => void;
|
||||
onOpen: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{sessions.map((session) => (
|
||||
<div key={session.id} className="group flex items-center gap-2 rounded-lg px-2 py-1.5 transition hover:bg-black/5 dark:hover:bg-white/10" style={session.id === activeSession?.id ? { background: theme.node.fill } : undefined}>
|
||||
<input type="checkbox" className="size-4 accent-stone-950" checked={checkedIds.includes(session.id)} onChange={(event) => onToggleChecked(session.id, event.target.checked)} />
|
||||
<button type="button" className="min-w-0 flex-1 text-left text-sm" onClick={() => onOpen(session.id)}>
|
||||
<span className="block truncate">{session.title}</span>
|
||||
<span className="text-xs opacity-50">{session.messages.length} 条消息</span>
|
||||
</button>
|
||||
<Button type="text" shape="circle" size="small" className="opacity-0 transition group-hover:opacity-100" icon={<Trash2 className="size-3.5" />} onClick={() => onDelete(session.id)} title="删除" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageReferences({ message }: { message: CanvasAssistantMessage }) {
|
||||
return (
|
||||
<div className={cn("flex max-w-[88%] flex-wrap gap-2", message.role === "user" ? "justify-end" : "justify-start")}>
|
||||
{message.references?.map((item) => <AssistantReferenceChip key={item.id} item={item} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantReferenceChip({ item, onRemove }: { item: CanvasAssistantReference; onRemove?: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const text = (item.text || item.title).replace(/\s+/g, " ").trim().slice(0, 1) || "文";
|
||||
return (
|
||||
<div className="group/chip relative inline-flex h-8 max-w-[150px] shrink-0 items-center gap-1.5 rounded-lg text-sm" style={{ color: theme.node.text }}>
|
||||
{item.dataUrl ? <img src={item.dataUrl} alt="" className="size-8 rounded-lg object-cover" /> : <span className="grid size-8 place-items-center rounded-lg border text-sm font-medium" style={{ background: theme.node.panel, borderColor: theme.node.activeStroke }}>{text}</span>}
|
||||
{onRemove ? (
|
||||
<button type="button" className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full border opacity-0 shadow-sm transition group-hover/chip:opacity-100" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }} onClick={onRemove} aria-label="移除引用">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function nodeToReference(node: CanvasNodeData): CanvasAssistantReference | null {
|
||||
if (node.type === CanvasNodeType.Image && node.metadata?.content) {
|
||||
return { id: node.id, type: node.type, title: node.title, dataUrl: node.metadata.content, storageKey: node.metadata.storageKey };
|
||||
}
|
||||
if (node.type === CanvasNodeType.Text && node.metadata?.content) {
|
||||
return { id: node.id, type: node.type, title: node.title, text: node.metadata.content };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildAssistantReferences(nodes: CanvasNodeData[], selectedNodeIds: Set<string>) {
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
return Array.from(selectedNodeIds)
|
||||
.map((id) => nodeById.get(id))
|
||||
.filter((node): node is CanvasNodeData => Boolean(node))
|
||||
.map(nodeToReference)
|
||||
.filter((item): item is CanvasAssistantReference => Boolean(item));
|
||||
}
|
||||
|
||||
async function buildChatMessages(messages: CanvasAssistantMessage[]): Promise<ChatCompletionMessage[]> {
|
||||
return Promise.all(messages.map(async (message, index) => {
|
||||
if (message.role === "assistant") return { role: "assistant", content: message.text };
|
||||
if (index !== messages.length - 1) return { role: "user", content: message.text };
|
||||
const refs = message.references || [];
|
||||
return {
|
||||
role: "user",
|
||||
content: [
|
||||
...refs.flatMap((item) => item.text ? [{ type: "text" as const, text: item.text }] : []),
|
||||
{ type: "text", text: message.text },
|
||||
...(await Promise.all(refs.filter((item) => item.dataUrl).map(async (item) => ({ type: "image_url" as const, image_url: { url: await imageToDataUrl(item) } })))),
|
||||
],
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
function createSession(): CanvasAssistantSession {
|
||||
const now = new Date().toISOString();
|
||||
return { id: createId(), title: "新对话", messages: [], createdAt: now, updatedAt: now };
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useState } from "react";
|
||||
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Edit3, Eye, Image as ImageIcon, LoaderCircle, MessageSquare, Play } from "lucide-react";
|
||||
import { App, Button, Empty, Input, InputNumber, Modal, Segmented } from "antd";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { defaultConfig, type AiConfig } from "@/lib/ai-config";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useAiConfigStore } from "@/stores/use-ai-config-store";
|
||||
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasSizePicker } from "./canvas-size-picker";
|
||||
import type { NodeGenerationInput } from "./canvas-node-generation";
|
||||
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types";
|
||||
|
||||
type CanvasConfigNodePanelProps = {
|
||||
node: CanvasNodeData;
|
||||
isRunning: boolean;
|
||||
inputSummary: { textCount: number; imageCount: number };
|
||||
inputs: NodeGenerationInput[];
|
||||
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
|
||||
onTextInputChange: (nodeId: string, content: string) => void;
|
||||
onGenerate: (nodeId: string) => void;
|
||||
};
|
||||
|
||||
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, onConfigChange, onTextInputChange, onGenerate }: CanvasConfigNodePanelProps) {
|
||||
const { message } = App.useApp();
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [editingTextId, setEditingTextId] = useState<string | null>(null);
|
||||
const [editingText, setEditingText] = useState("");
|
||||
const globalConfig = useAiConfigStore((state) => state.config);
|
||||
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const mode = node.metadata?.generationMode || "image";
|
||||
const config = buildNodeConfig(globalConfig, node, mode);
|
||||
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.count || 3)) || 1)));
|
||||
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
|
||||
const textInputs = inputs.filter((input) => input.type === "text");
|
||||
const imageInputs = inputs.filter((input) => input.type === "image");
|
||||
|
||||
const moveInput = (input: NodeGenerationInput, offset: number) => {
|
||||
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
|
||||
const sameTypeIndex = sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId);
|
||||
const targetInput = sameTypeInputs[sameTypeIndex + offset];
|
||||
if (!targetInput) return;
|
||||
const index = inputs.findIndex((item) => item.nodeId === input.nodeId);
|
||||
const targetIndex = inputs.findIndex((item) => item.nodeId === targetInput.nodeId);
|
||||
const next = [...inputs];
|
||||
[next[index], next[targetIndex]] = [next[targetIndex], next[index]];
|
||||
onConfigChange(node.id, { inputOrder: next.map((input) => input.nodeId) });
|
||||
message.success("已调整输入顺序");
|
||||
};
|
||||
const startTextEdit = (input: NodeGenerationInput) => {
|
||||
setEditingTextId(input.nodeId);
|
||||
setEditingText(input.text || "");
|
||||
};
|
||||
const saveTextEdit = () => {
|
||||
if (!editingTextId) return;
|
||||
onTextInputChange(editingTextId, editingText);
|
||||
setEditingText("");
|
||||
setEditingTextId(null);
|
||||
message.success("已保存文本提示词");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="shrink-0 text-sm font-semibold">生成配置</div>
|
||||
<Segmented
|
||||
size="small"
|
||||
className="canvas-config-mode !rounded-md !p-0.5"
|
||||
value={mode}
|
||||
onChange={(value) => onConfigChange(node.id, { generationMode: value as CanvasGenerationMode })}
|
||||
options={[
|
||||
{ value: "image", label: <span className="inline-flex items-center gap-1"><ImageIcon className="size-3.5" />生图</span> },
|
||||
{ value: "text", label: <span className="inline-flex items-center gap-1"><MessageSquare className="size-3.5" />文本</span> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
<InputChip label="提示词" value={`${inputSummary.textCount} 个`} style={chipStyle} />
|
||||
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[11px]"
|
||||
style={chipStyle}
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
预览
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 grid min-w-0 grid-cols-[minmax(0,1fr)_92px_64px] items-center gap-2">
|
||||
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} fullWidth />
|
||||
{mode === "image" ? <CanvasSizePicker className="h-10 min-w-0" value={node.metadata?.size || globalConfig.size || defaultConfig.size} onChange={(value) => onConfigChange(node.id, { size: value })} /> : null}
|
||||
<InputNumber min={1} max={15} className="canvas-compact-control canvas-control-number h-10 !w-full" value={count} onChange={(value) => onConfigChange(node.id, { count: Number(value) || 1 })} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="mt-auto !h-9 !w-full !rounded-lg"
|
||||
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount)}
|
||||
onClick={() => onGenerate(node.id)}
|
||||
icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <Play className="size-4" />}
|
||||
>
|
||||
开始生成
|
||||
</Button>
|
||||
<Modal
|
||||
title="输入预览"
|
||||
open={previewOpen}
|
||||
onCancel={() => setPreviewOpen(false)}
|
||||
footer={null}
|
||||
centered
|
||||
width={860}
|
||||
mask={{ closable: true }}
|
||||
keyboard
|
||||
destroyOnHidden
|
||||
modalRender={(modal) => (
|
||||
<div onClick={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
{modal}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheelCapture={(event) => event.stopPropagation()}>
|
||||
{inputs.length ? (
|
||||
<div className="flex h-[min(66vh,580px)] flex-col gap-3 overflow-hidden">
|
||||
<div className="shrink-0">
|
||||
<PreviewSection title="图片提示词" count={imageInputs.length} empty="暂无图片提示词">
|
||||
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
|
||||
{imageInputs.map((input, index) => <ImageSortCard key={input.nodeId} input={input} imageIndex={index} imageTotal={imageInputs.length} inputs={inputs} theme={theme} onMove={moveInput} />)}
|
||||
</div>
|
||||
</PreviewSection>
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-hidden">
|
||||
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5">
|
||||
<PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词">
|
||||
<div className="space-y-1.5">
|
||||
{textInputs.map((input, index) => <TextSortCard key={input.nodeId} input={input} textIndex={index} textTotal={textInputs.length} inputs={inputs} theme={theme} onMove={moveInput} onEdit={startTextEdit} />)}
|
||||
</div>
|
||||
</PreviewSection>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col rounded-xl border p-2.5" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
{editingTextId ? (
|
||||
<>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-sm font-semibold">编辑文本提示词</div>
|
||||
<Button size="small" type="text" onClick={() => setEditingTextId(null)}>收起</Button>
|
||||
</div>
|
||||
<Input.TextArea className="thin-scrollbar !flex-1 !resize-none !text-xs !leading-5" value={editingText} onChange={(event) => setEditingText(event.target.value)} />
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<Button size="small" onClick={() => setEditingTextId(null)}>取消</Button>
|
||||
<Button size="small" type="primary" onClick={saveTextEdit}>保存</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-col justify-center rounded-xl border border-dashed px-4 text-center text-xs leading-5 opacity-45" style={{ borderColor: theme.node.stroke }}>
|
||||
<Edit3 className="mx-auto mb-2 size-5" />
|
||||
选择一条文本后在这里编辑
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词或参考图" className="py-8" />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewSection({ title, count, empty, children }: { title: string; count: number; empty: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<div className="sticky top-0 z-10 mb-1 flex items-center justify-between px-0.5 py-0.5 backdrop-blur-sm">
|
||||
<div className="text-xs font-semibold">{title}</div>
|
||||
<div className="text-[11px] opacity-50">{count} 个</div>
|
||||
</div>
|
||||
{count ? children : <div className="rounded-xl border border-dashed px-3 py-5 text-center text-xs opacity-45">{empty}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TextSortCard({ input, textIndex, textTotal, inputs, theme, onMove, onEdit }: { input: NodeGenerationInput; textIndex: number; textTotal: number; inputs: NodeGenerationInput[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onMove: (input: NodeGenerationInput, offset: number) => void; onEdit: (input: NodeGenerationInput) => void }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_72px] items-center gap-1.5 rounded-md border px-2 py-1" style={{ background: `${theme.node.fill}99`, borderColor: theme.node.stroke }}>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[10px] font-medium opacity-50">文本 {textIndex + 1}</div>
|
||||
<div className="line-clamp-1 whitespace-pre-wrap break-words text-[11px] leading-4 opacity-80">{input.text}</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<Edit3 className="size-3" />} onClick={() => onEdit(input)} />
|
||||
<VerticalOrderButtons index={textIndex} total={textTotal} onMove={(offset) => onMove(input, offset)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageSortCard({ input, imageIndex, imageTotal, inputs, theme, onMove }: { input: NodeGenerationInput; imageIndex: number; imageTotal: number; inputs: NodeGenerationInput[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onMove: (input: NodeGenerationInput, offset: number) => void }) {
|
||||
if (!input.image) return null;
|
||||
return (
|
||||
<div className="w-24 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
<div className="relative">
|
||||
<img src={input.image.dataUrl} alt={input.title} className="aspect-square w-full object-cover" />
|
||||
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{imageIndex + 1}</span>
|
||||
<HorizontalOrderButtons index={imageIndex} total={imageTotal} onMove={(offset) => onMove(input, offset)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerticalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
|
||||
return (
|
||||
<>
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowUp className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowDown className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HorizontalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
|
||||
return (
|
||||
<div className="absolute inset-x-1 bottom-1 flex justify-between">
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowLeft className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowRight className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputChip({ label, value, style }: { label: string; value: string; style: CSSProperties }) {
|
||||
return (
|
||||
<div className="inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[11px]" style={style}>
|
||||
<span>{label}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode): AiConfig {
|
||||
const defaultModel = mode === "image" ? globalConfig.imageModel : globalConfig.textModel;
|
||||
return {
|
||||
...globalConfig,
|
||||
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
|
||||
quality: globalConfig.quality || defaultConfig.quality,
|
||||
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "../types";
|
||||
|
||||
export function ConnectionPath({
|
||||
connection,
|
||||
from,
|
||||
to,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
connection: CanvasConnection;
|
||||
from: CanvasNodeData;
|
||||
to: CanvasNodeData;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const startX = from.position.x + from.width;
|
||||
const startY = from.position.y + from.height / 2;
|
||||
const endX = to.position.x;
|
||||
const endY = to.position.y + to.height / 2;
|
||||
const dx = Math.abs(endX - startX);
|
||||
const curvature = Math.max(dx * 0.5, 50);
|
||||
const pathD = `M ${startX} ${startY} C ${startX + curvature} ${startY}, ${endX - curvature} ${endY}, ${endX} ${endY}`;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
data-connection-id={connection.id}
|
||||
d={pathD}
|
||||
stroke="transparent"
|
||||
strokeWidth="16"
|
||||
fill="none"
|
||||
style={{ cursor: "pointer", pointerEvents: "stroke" }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d={pathD}
|
||||
stroke={active ? theme.node.activeStroke : theme.node.muted}
|
||||
strokeWidth={active ? 3 : 2}
|
||||
strokeOpacity={active ? 1 : 0.82}
|
||||
fill="none"
|
||||
style={{ filter: active ? `drop-shadow(0 0 8px ${theme.node.activeStroke}66)` : undefined, pointerEvents: "none" }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActiveConnectionPath({
|
||||
node,
|
||||
handle,
|
||||
mouseWorld,
|
||||
}: {
|
||||
node?: CanvasNodeData;
|
||||
handle: ConnectionHandle;
|
||||
mouseWorld: Position;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
if (!node) return null;
|
||||
|
||||
const startX = handle.handleType === "source" ? node.position.x + node.width : mouseWorld.x;
|
||||
const startY = handle.handleType === "source" ? node.position.y + node.height / 2 : mouseWorld.y;
|
||||
const endX = handle.handleType === "source" ? mouseWorld.x : node.position.x;
|
||||
const endY = handle.handleType === "source" ? mouseWorld.y : node.position.y + node.height / 2;
|
||||
const distance = Math.abs(endX - startX);
|
||||
const pathD = `M ${startX} ${startY} C ${startX + distance * 0.5} ${startY}, ${endX - distance * 0.5} ${endY}, ${endX} ${endY}`;
|
||||
|
||||
return <path d={pathD} stroke={theme.node.activeStroke} strokeWidth="2" fill="none" strokeDasharray="5,5" />;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { ContextMenuState } from "../types";
|
||||
|
||||
export function CanvasNodeContextMenu({
|
||||
menu,
|
||||
onClose,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: {
|
||||
menu: ContextMenuState;
|
||||
onClose: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
useEffect(() => {
|
||||
const close = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest(".ant-popover")) return;
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener("pointerdown", close);
|
||||
return () => window.removeEventListener("pointerdown", close);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[80] min-w-44 overflow-hidden rounded-xl border py-1 shadow-2xl"
|
||||
style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MenuButton icon={<Plus className="size-4" />} label="Duplicate" onClick={onDuplicate} />
|
||||
<MenuButton icon={<Trash2 className="size-4" />} label="Delete" onClick={onDelete} danger />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
danger = false,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:opacity-80"
|
||||
style={{ color: danger ? "#f87171" : theme.node.text }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Modal } from "antd";
|
||||
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { useCanvasStore } from "../stores/use-canvas-store";
|
||||
import { useCanvasUiStore } from "../stores/use-canvas-ui-store";
|
||||
|
||||
export function CanvasDeleteProjectsDialog() {
|
||||
const ids = useCanvasUiStore((state) => state.deleteProjectIds);
|
||||
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
|
||||
const removeSelectedIds = useCanvasUiStore((state) => state.removeSelectedProjectIds);
|
||||
const deleteProjects = useCanvasStore((state) => state.deleteProjects);
|
||||
const cleanupImages = useAssetStore((state) => state.cleanupImages);
|
||||
const confirm = () => {
|
||||
deleteProjects(ids);
|
||||
cleanupImages();
|
||||
removeSelectedIds(ids);
|
||||
setDeleteIds([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="删除画布?"
|
||||
open={ids.length > 0}
|
||||
centered
|
||||
onCancel={() => setDeleteIds([])}
|
||||
footer={<><Button onClick={() => setDeleteIds([])}>取消</Button><Button danger type="primary" onClick={confirm}>删除</Button></>}
|
||||
>
|
||||
<p className="text-sm text-stone-500">将删除 {ids.length} 个画布,里面的节点和连线也会一起移除。</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
|
||||
|
||||
export function Minimap({
|
||||
nodes,
|
||||
viewport,
|
||||
viewportSize,
|
||||
onViewportChange,
|
||||
}: {
|
||||
nodes: CanvasNodeData[];
|
||||
viewport: ViewportTransform;
|
||||
viewportSize: { width: number; height: number };
|
||||
onViewportChange: (viewport: ViewportTransform) => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const width = 240;
|
||||
const height = 160;
|
||||
|
||||
const { worldBounds, scale, offset } = useMemo(() => {
|
||||
if (!nodes.length) {
|
||||
return { worldBounds: { x: -500, y: -500, w: 1000, h: 1000 }, scale: 0.16, offset: { x: 40, y: 0 } };
|
||||
}
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
minX = Math.min(minX, node.position.x);
|
||||
minY = Math.min(minY, node.position.y);
|
||||
maxX = Math.max(maxX, node.position.x + node.width);
|
||||
maxY = Math.max(maxY, node.position.y + node.height);
|
||||
});
|
||||
|
||||
minX -= 500;
|
||||
minY -= 500;
|
||||
maxX += 500;
|
||||
maxY += 500;
|
||||
|
||||
const boundsWidth = maxX - minX;
|
||||
const boundsHeight = maxY - minY;
|
||||
const nextScale = Math.min(width / boundsWidth, height / boundsHeight);
|
||||
const mapContentW = boundsWidth * nextScale;
|
||||
const mapContentH = boundsHeight * nextScale;
|
||||
|
||||
return {
|
||||
worldBounds: { x: minX, y: minY, w: boundsWidth, h: boundsHeight },
|
||||
scale: nextScale,
|
||||
offset: { x: (width - mapContentW) / 2, y: (height - mapContentH) / 2 },
|
||||
};
|
||||
}, [nodes]);
|
||||
|
||||
const toMinimap = useCallback(
|
||||
(worldX: number, worldY: number) => {
|
||||
return {
|
||||
x: (worldX - worldBounds.x) * scale + offset.x,
|
||||
y: (worldY - worldBounds.y) * scale + offset.y,
|
||||
};
|
||||
},
|
||||
[offset.x, offset.y, scale, worldBounds.x, worldBounds.y],
|
||||
);
|
||||
|
||||
const toWorld = useCallback(
|
||||
(minimapX: number, minimapY: number) => {
|
||||
return {
|
||||
x: (minimapX - offset.x) / scale + worldBounds.x,
|
||||
y: (minimapY - offset.y) / scale + worldBounds.y,
|
||||
};
|
||||
},
|
||||
[offset.x, offset.y, scale, worldBounds.x, worldBounds.y],
|
||||
);
|
||||
|
||||
const viewportRect = useMemo(() => {
|
||||
const vx = -viewport.x / viewport.k;
|
||||
const vy = -viewport.y / viewport.k;
|
||||
const vw = viewportSize.width / viewport.k;
|
||||
const vh = viewportSize.height / viewport.k;
|
||||
const p1 = toMinimap(vx, vy);
|
||||
const p2 = toMinimap(vx + vw, vy + vh);
|
||||
|
||||
return {
|
||||
x: p1.x,
|
||||
y: p1.y,
|
||||
w: Math.max(p2.x - p1.x, 4),
|
||||
h: Math.max(p2.y - p1.y, 4),
|
||||
};
|
||||
}, [toMinimap, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
|
||||
|
||||
const updateViewportFromEvent = (event: React.PointerEvent) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const world = toWorld(event.clientX - rect.left, event.clientY - rect.top);
|
||||
onViewportChange({
|
||||
x: viewportSize.width / 2 - world.x * viewport.k,
|
||||
y: viewportSize.height / 2 - world.y * viewport.k,
|
||||
k: viewport.k,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-24 left-6 z-50 overflow-hidden rounded-lg border shadow-2xl backdrop-blur-sm"
|
||||
style={{ width, height, background: theme.toolbar.panel, borderColor: theme.toolbar.border }}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative h-full w-full cursor-crosshair"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setIsDragging(true);
|
||||
updateViewportFromEvent(event);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (isDragging) updateViewportFromEvent(event);
|
||||
}}
|
||||
onPointerUp={() => setIsDragging(false)}
|
||||
onPointerLeave={() => setIsDragging(false)}
|
||||
>
|
||||
{nodes.map((node) => {
|
||||
const pos = toMinimap(node.position.x, node.position.y);
|
||||
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Config ? "#60a5fa" : theme.node.muted;
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className="absolute rounded-[1px]"
|
||||
style={{
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
width: Math.max(node.width * scale, 2),
|
||||
height: Math.max(node.height * scale, 2),
|
||||
backgroundColor: color,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className="pointer-events-none absolute border"
|
||||
style={{ left: viewportRect.x, top: viewportRect.y, width: viewportRect.w, height: viewportRect.h, borderColor: theme.node.activeStroke, background: `${theme.node.activeStroke}18` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Modal, Segmented, Slider } from "antd";
|
||||
import { RotateCcw, WandSparkles } from "lucide-react";
|
||||
|
||||
export type CanvasImageAngleParams = {
|
||||
horizontalAngle: number;
|
||||
pitchAngle: number;
|
||||
cameraDistance: number;
|
||||
wideAngle: boolean;
|
||||
};
|
||||
|
||||
const defaultParams: CanvasImageAngleParams = {
|
||||
horizontalAngle: 0,
|
||||
pitchAngle: 9,
|
||||
cameraDistance: 4.8,
|
||||
wideAngle: false,
|
||||
};
|
||||
|
||||
export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: {
|
||||
dataUrl: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (params: CanvasImageAngleParams) => void;
|
||||
}) {
|
||||
const [params, setParams] = useState(defaultParams);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setParams(defaultParams);
|
||||
}, [dataUrl, open]);
|
||||
|
||||
const update = <Key extends keyof CanvasImageAngleParams>(key: Key, value: CanvasImageAngleParams[Key]) => setParams((current) => ({ ...current, [key]: value }));
|
||||
|
||||
return (
|
||||
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={860} centered destroyOnHidden>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">AI 多角度</h2>
|
||||
<p className="mt-1 text-sm opacity-60">左侧只预览方向,结果会基于原图重新生成</p>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]">
|
||||
<div className="flex min-h-[300px] flex-col justify-between rounded-xl border p-4">
|
||||
<div className="grid flex-1 place-items-center">
|
||||
<div className="relative">
|
||||
<img src={dataUrl} alt="" className="size-48 rounded-2xl object-cover shadow-2xl" draggable={false} style={{ transform: previewTransform(params) }} />
|
||||
<div className="absolute -bottom-6 left-1/2 h-10 w-24 -translate-x-1/2 rounded-full border bg-black/20 backdrop-blur" />
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-fit" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}>重置</Button>
|
||||
</div>
|
||||
<div className="space-y-6 py-2">
|
||||
<AngleSlider label="左右角度" value={params.horizontalAngle} min={-60} max={60} step={1} suffix="deg" onChange={(value) => update("horizontalAngle", value)} />
|
||||
<AngleSlider label="俯仰角度" value={params.pitchAngle} min={-45} max={45} step={1} suffix="deg" onChange={(value) => update("pitchAngle", value)} />
|
||||
<AngleSlider label="镜头距离" value={params.cameraDistance} min={1} max={10} step={0.1} onChange={(value) => update("cameraDistance", value)} />
|
||||
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
|
||||
<span className="font-medium opacity-75">广角镜头</span>
|
||||
<Segmented className="w-fit" value={params.wideAngle ? "wide" : "standard"} options={[{ label: "标准", value: "standard" }, { label: "广角", value: "wide" }]} onChange={(value) => update("wideAngle", value === "wide")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="primary" size="large" icon={<WandSparkles className="size-4" />} onClick={() => onConfirm(params)}>AI 生成</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function AngleSlider({ label, value, min, max, step, suffix = "", onChange }: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
suffix?: string;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
|
||||
<span className="font-medium opacity-75">{label}</span>
|
||||
<Slider min={min} max={max} step={step} value={value} onChange={onChange} />
|
||||
<span className="whitespace-nowrap text-right font-semibold">{Number.isInteger(value) ? value : value.toFixed(1)}{suffix}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function previewTransform(params: CanvasImageAngleParams) {
|
||||
const scale = 1.08 - params.cameraDistance * 0.035 + (params.wideAngle ? -0.08 : 0);
|
||||
return `perspective(520px) rotateY(${params.horizontalAngle * -0.45}deg) rotateX(${params.pitchAngle * 0.35}deg) scale(${Math.max(0.72, Math.min(1.08, scale))})`;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { Button, Modal } from "antd";
|
||||
import { Check, Lock, LockOpen, X } from "lucide-react";
|
||||
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
|
||||
export type CanvasImageCropRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type DragMode = "move" | "resize";
|
||||
type ResizeHandle = "n" | "e" | "s" | "w" | "ne" | "nw" | "se" | "sw";
|
||||
|
||||
const handles: ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
|
||||
const minSize = 0.06;
|
||||
const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 };
|
||||
|
||||
export function CanvasNodeCropDialog({
|
||||
dataUrl,
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
dataUrl: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (crop: CanvasImageCropRect) => void;
|
||||
}) {
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
|
||||
const [locked, setLocked] = useState(false);
|
||||
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
|
||||
const cropSize = image ? { width: Math.max(1, Math.round(crop.width * image.width)), height: Math.max(1, Math.round(crop.height * image.height)) } : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setCrop(defaultCrop);
|
||||
}, [dataUrl, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void readImageMeta(dataUrl).then(setImage);
|
||||
}, [dataUrl, open]);
|
||||
|
||||
const startDrag = (mode: DragMode, event: ReactPointerEvent, handle?: ResizeHandle) => {
|
||||
const box = boxRef.current?.getBoundingClientRect();
|
||||
if (!box) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const start = { x: event.clientX, y: event.clientY, crop };
|
||||
const move = (event: PointerEvent) => {
|
||||
const dx = (event.clientX - start.x) / box.width;
|
||||
const dy = (event.clientY - start.y) / box.height;
|
||||
setCrop(mode === "move" ? moveCrop(start.crop, dx, dy) : resizeCrop(start.crop, dx, dy, handle || "se", locked, box));
|
||||
};
|
||||
const up = () => {
|
||||
document.removeEventListener("pointermove", move);
|
||||
document.removeEventListener("pointerup", up);
|
||||
};
|
||||
document.addEventListener("pointermove", move);
|
||||
document.addEventListener("pointerup", up);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title="裁剪图片" open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div ref={boxRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black select-none">
|
||||
<img src={dataUrl} alt="" className="block max-h-[62vh] max-w-full opacity-90" draggable={false} />
|
||||
<CropMask crop={crop} />
|
||||
<div className="absolute cursor-move border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,.3),0_0_28px_rgba(0,0,0,.28)]" style={cropStyle(crop)} onPointerDown={(event) => startDrag("move", event)}>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-1/3 border-t border-white/50" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-2/3 border-t border-white/50" />
|
||||
<div className="pointer-events-none absolute inset-y-0 left-1/3 border-l border-white/50" />
|
||||
<div className="pointer-events-none absolute inset-y-0 left-2/3 border-l border-white/50" />
|
||||
{handles.map((handle) => (
|
||||
<button key={handle} type="button" className="absolute size-3 rounded-full border border-black bg-white" style={handleStyle(handle)} onPointerDown={(event) => startDrag("resize", event, handle)} aria-label="调整裁剪框" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm opacity-80">
|
||||
<span>裁剪尺寸 {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
|
||||
<span>比例 {cropSize ? formatRatio(cropSize.width, cropSize.height) : "未知"}</span>
|
||||
{image ? <span>原图 {image.width} x {image.height}</span> : null}
|
||||
</div>
|
||||
<Button icon={locked ? <Lock className="size-4" /> : <LockOpen className="size-4" />} onClick={() => setLocked((value) => !value)}>
|
||||
{locked ? "锁定比例" : "自由比例"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button onClick={() => setCrop(defaultCrop)}>重置</Button>
|
||||
<Button icon={<X className="size-4" />} onClick={onClose}>取消</Button>
|
||||
<Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}>确认裁剪</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function CropMask({ crop }: { crop: CanvasImageCropRect }) {
|
||||
return (
|
||||
<>
|
||||
<div className="absolute inset-x-0 top-0 bg-black/55" style={{ height: `${crop.y * 100}%` }} />
|
||||
<div className="absolute inset-x-0 bottom-0 bg-black/55" style={{ height: `${(1 - crop.y - crop.height) * 100}%` }} />
|
||||
<div className="absolute bg-black/55" style={{ left: 0, top: `${crop.y * 100}%`, width: `${crop.x * 100}%`, height: `${crop.height * 100}%` }} />
|
||||
<div className="absolute bg-black/55" style={{ right: 0, top: `${crop.y * 100}%`, width: `${(1 - crop.x - crop.width) * 100}%`, height: `${crop.height * 100}%` }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function moveCrop(crop: CanvasImageCropRect, dx: number, dy: number): CanvasImageCropRect {
|
||||
return { ...crop, x: clamp(crop.x + dx, 0, 1 - crop.width), y: clamp(crop.y + dy, 0, 1 - crop.height) };
|
||||
}
|
||||
|
||||
function resizeCrop(crop: CanvasImageCropRect, dx: number, dy: number, handle: ResizeHandle, locked: boolean, box: DOMRect): CanvasImageCropRect {
|
||||
let next = { ...crop };
|
||||
if (handle.includes("e")) next.width = crop.width + dx;
|
||||
if (handle.includes("s")) next.height = crop.height + dy;
|
||||
if (handle.includes("w")) {
|
||||
next.x = crop.x + dx;
|
||||
next.width = crop.width - dx;
|
||||
}
|
||||
if (handle.includes("n")) {
|
||||
next.y = crop.y + dy;
|
||||
next.height = crop.height - dy;
|
||||
}
|
||||
if (locked) {
|
||||
const size = Math.max(next.width * box.width, next.height * box.height);
|
||||
next.width = size / box.width;
|
||||
next.height = size / box.height;
|
||||
if (handle.includes("w")) next.x = crop.x + crop.width - next.width;
|
||||
if (handle.includes("n")) next.y = crop.y + crop.height - next.height;
|
||||
}
|
||||
next.width = clamp(next.width, minSize, 1);
|
||||
next.height = clamp(next.height, minSize, 1);
|
||||
next.x = clamp(next.x, 0, 1 - next.width);
|
||||
next.y = clamp(next.y, 0, 1 - next.height);
|
||||
return next;
|
||||
}
|
||||
|
||||
function cropStyle(crop: CanvasImageCropRect) {
|
||||
return { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${crop.width * 100}%`, height: `${crop.height * 100}%` };
|
||||
}
|
||||
|
||||
function handleStyle(handle: ResizeHandle) {
|
||||
const top = handle.includes("n") ? "-6px" : handle.includes("s") ? "calc(100% - 6px)" : "calc(50% - 6px)";
|
||||
const left = handle.includes("w") ? "-6px" : handle.includes("e") ? "calc(100% - 6px)" : "calc(50% - 6px)";
|
||||
return { top, left, cursor: `${handle}-resize` };
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function formatRatio(width: number, height: number) {
|
||||
const divisor = gcd(width, height);
|
||||
return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`;
|
||||
}
|
||||
|
||||
function gcd(a: number, b: number): number {
|
||||
return b ? gcd(b, a % b) : Math.max(1, a);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ChatCompletionMessage } from "@/services/api/image";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types";
|
||||
|
||||
export type NodeGenerationContext = {
|
||||
prompt: string;
|
||||
referenceImages: ReferenceImage[];
|
||||
textCount: number;
|
||||
imageCount: number;
|
||||
};
|
||||
|
||||
export type NodeGenerationInput = {
|
||||
nodeId: string;
|
||||
type: "text" | "image";
|
||||
title: string;
|
||||
text?: string;
|
||||
image?: ReferenceImage;
|
||||
};
|
||||
|
||||
export function buildNodeGenerationContext(
|
||||
nodeId: string,
|
||||
nodes: CanvasNodeData[],
|
||||
connections: CanvasConnection[],
|
||||
prompt: string,
|
||||
): NodeGenerationContext {
|
||||
const inputs = buildNodeGenerationInputs(nodeId, nodes, connections);
|
||||
const upstreamText = inputs.map((input) => input.text).filter(Boolean).join("\n\n");
|
||||
const referenceImages = inputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
|
||||
|
||||
return {
|
||||
prompt: upstreamText ? `${prompt}\n\n${upstreamText}` : prompt,
|
||||
referenceImages,
|
||||
textCount: inputs.filter((input) => input.type === "text").length,
|
||||
imageCount: referenceImages.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNodeGenerationInputs(
|
||||
nodeId: string,
|
||||
nodes: CanvasNodeData[],
|
||||
connections: CanvasConnection[],
|
||||
): NodeGenerationInput[] {
|
||||
return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
|
||||
const image = readReferenceImage(node);
|
||||
if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }];
|
||||
const text = readNodeTextInput(node);
|
||||
if (text) return [{ nodeId: node.id, type: "text" as const, title: node.title, text }];
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function buildNodeChatMessages(context: NodeGenerationContext): ChatCompletionMessage[] {
|
||||
if (!context.referenceImages.length) {
|
||||
return [{ role: "user", content: context.prompt }];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text" as const, text: context.prompt },
|
||||
...context.referenceImages.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl } })),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function hydrateNodeGenerationContext(context: NodeGenerationContext) {
|
||||
const { imageToDataUrl } = await import("@/services/image-storage");
|
||||
return { ...context, referenceImages: await Promise.all(context.referenceImages.map(async (image) => ({ ...image, dataUrl: await imageToDataUrl(image) }))) };
|
||||
}
|
||||
|
||||
function readNodeTextInput(node: CanvasNodeData) {
|
||||
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || "";
|
||||
return node.metadata?.prompt || "";
|
||||
}
|
||||
|
||||
function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
|
||||
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) return null;
|
||||
return {
|
||||
id: node.id,
|
||||
name: `${node.title || node.id}.png`,
|
||||
type: node.metadata.mimeType || "image/png",
|
||||
dataUrl: node.metadata.content,
|
||||
storageKey: node.metadata.storageKey,
|
||||
};
|
||||
}
|
||||
|
||||
function getOrderedUpstreamNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
const target = nodes.find((node) => node.id === nodeId);
|
||||
const upstreamNodes = connections
|
||||
.filter((connection) => connection.toNodeId === nodeId)
|
||||
.map((connection) => nodes.find((node) => node.id === connection.fromNodeId))
|
||||
.filter((node): node is CanvasNodeData => Boolean(node));
|
||||
const order = target?.metadata?.inputOrder || [];
|
||||
return [
|
||||
...order.map((id) => upstreamNodes.find((node) => node.id === id)).filter((node): node is CanvasNodeData => Boolean(node)),
|
||||
...upstreamNodes.filter((node) => !order.includes(node.id)),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Modal, Segmented, Tooltip } from "antd";
|
||||
import { Camera, Download, FolderPlus, Image as ImageIcon, Info, Lock, LockOpen, MessageSquare, Minus, Pencil, Plus, RefreshCw, Scissors, Settings2, Trash2, Upload } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
|
||||
|
||||
type CanvasNodeHoverToolbarProps = {
|
||||
node: CanvasNodeData | null;
|
||||
viewport: ViewportTransform;
|
||||
onKeep: (nodeId: string) => void;
|
||||
onLeave: () => void;
|
||||
onInfo: (node: CanvasNodeData) => void;
|
||||
onEditText: (node: CanvasNodeData) => void;
|
||||
onDecreaseFont: (node: CanvasNodeData) => void;
|
||||
onIncreaseFont: (node: CanvasNodeData) => void;
|
||||
onToggleDialog: (node: CanvasNodeData) => void;
|
||||
onGenerateImage: (node: CanvasNodeData) => void;
|
||||
onUpload: (node: CanvasNodeData) => void;
|
||||
onDownload: (node: CanvasNodeData) => void;
|
||||
onSaveAsset: (node: CanvasNodeData) => void;
|
||||
onCrop: (node: CanvasNodeData) => void;
|
||||
onAngle: (node: CanvasNodeData) => void;
|
||||
onRetry: (node: CanvasNodeData) => void;
|
||||
onToggleFreeResize: (node: CanvasNodeData) => void;
|
||||
onDelete: (node: CanvasNodeData) => void;
|
||||
};
|
||||
|
||||
export function CanvasNodeHoverToolbar({
|
||||
node,
|
||||
viewport,
|
||||
onKeep,
|
||||
onLeave,
|
||||
onInfo,
|
||||
onEditText,
|
||||
onDecreaseFont,
|
||||
onIncreaseFont,
|
||||
onToggleDialog,
|
||||
onGenerateImage,
|
||||
onUpload,
|
||||
onDownload,
|
||||
onSaveAsset,
|
||||
onCrop,
|
||||
onAngle,
|
||||
onRetry,
|
||||
onToggleFreeResize,
|
||||
onDelete,
|
||||
}: CanvasNodeHoverToolbarProps) {
|
||||
if (!node) return null;
|
||||
|
||||
const left = viewport.x + (node.position.x + node.width / 2) * viewport.k;
|
||||
const top = viewport.y + node.position.y * viewport.k - 14;
|
||||
const isImage = node.type === CanvasNodeType.Image;
|
||||
const hasImage = isImage && Boolean(node.metadata?.content);
|
||||
const isText = node.type === CanvasNodeType.Text;
|
||||
const isConfig = node.type === CanvasNodeType.Config;
|
||||
const canOpenDialog = isText || hasImage;
|
||||
const canRetry = node.metadata?.status === "error";
|
||||
const hasSpecificTools = canRetry || isText || isImage || isConfig;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[70] flex h-12 -translate-x-1/2 -translate-y-full items-center overflow-visible rounded-[18px] border border-black/10 bg-white text-[15px] text-[#242529] shadow-[0_8px_28px_rgba(15,23,42,.12)]"
|
||||
style={{ left, top }}
|
||||
onMouseEnter={() => onKeep(node.id)}
|
||||
onMouseLeave={onLeave}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ToolbarAction title="查看节点信息" label="信息" icon={<Info className="size-4" />} onClick={() => onInfo(node)} />
|
||||
<ToolbarAction title="移除节点" label="删除" icon={<Trash2 className="size-4" />} onClick={() => onDelete(node)} danger />
|
||||
{hasSpecificTools ? <ToolbarDivider /> : null}
|
||||
{canRetry ? <ToolbarAction title="重新生成" label="重试" icon={<RefreshCw className="size-4" />} onClick={() => onRetry(node)} /> : null}
|
||||
{hasImage || isText ? <ToolbarAction title="加入我的素材" label="存素材" icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(node)} /> : null}
|
||||
{hasImage ? <IconAction title="下载图片" icon={<Download className="size-5" />} onClick={() => onDownload(node)} /> : null}
|
||||
{canOpenDialog ? <ToolbarAction title="编辑" label="编辑" icon={<MessageSquare className="size-4" />} onClick={() => onToggleDialog(node)} /> : null}
|
||||
{isText ? <ToolbarAction title="编辑文本" label="编辑文字" icon={<Pencil className="size-4" />} onClick={() => onEditText(node)} /> : null}
|
||||
{isText ? <ToolbarAction title="用文本生图" label="生图" icon={<ImageIcon className="size-4" />} onClick={() => onGenerateImage(node)} /> : null}
|
||||
{isConfig ? <ToolbarAction title="生成配置" label="生成配置" icon={<Settings2 className="size-4" />} onClick={() => onInfo(node)} /> : null}
|
||||
{isText ? <ToolbarAction title="减小字号" label="缩小" icon={<Minus className="size-4" />} onClick={() => onDecreaseFont(node)} /> : null}
|
||||
{isText ? <ToolbarAction title="增大字号" label="放大" icon={<Plus className="size-4" />} onClick={() => onIncreaseFont(node)} /> : null}
|
||||
{isImage ? <ToolbarAction title={hasImage ? "替换图片" : "上传图片"} label={hasImage ? "替换图片" : "上传图片"} icon={<Upload className="size-4" />} onClick={() => onUpload(node)} /> : null}
|
||||
{hasImage ? <ToolbarAction title={node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"} label={node.metadata?.freeResize ? "自由比例" : "锁比例"} icon={node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />} onClick={() => onToggleFreeResize(node)} active={node.metadata?.freeResize} /> : null}
|
||||
{hasImage ? <ToolbarAction title="裁剪并生成新节点" label="裁剪" icon={<Scissors className="size-4" />} onClick={() => onCrop(node)} /> : null}
|
||||
{hasImage ? <ToolbarAction title="生成角度" label="多角度" icon={<Camera className="size-4" />} onClick={() => onAngle(node)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeData | null; open: boolean; onClose: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const [view, setView] = useState<"info" | "json">("info");
|
||||
const imageBytes = node?.type === CanvasNodeType.Image && node.metadata?.content ? getDataUrlByteSize(node.metadata.content) : 0;
|
||||
const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0;
|
||||
const json = useMemo(() => {
|
||||
if (!node) return "";
|
||||
return JSON.stringify(node, (key, value) => {
|
||||
if (key === "title") return undefined;
|
||||
if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) {
|
||||
return "[base64 image]";
|
||||
}
|
||||
return value;
|
||||
}, 2);
|
||||
}, [node]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setView("info");
|
||||
}, [node?.id, open]);
|
||||
|
||||
const title = (
|
||||
<div className="flex items-center justify-between gap-4 pr-12">
|
||||
<span>节点信息</span>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={view}
|
||||
onChange={(value) => setView(value as "info" | "json")}
|
||||
options={[
|
||||
{ label: "信息", value: "info" },
|
||||
{ label: "JSON", value: "json" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal className="canvas-node-info-modal" title={title} open={open && Boolean(node)} centered footer={null} onCancel={onClose}>
|
||||
{node ? (
|
||||
<div className="h-[56vh] min-h-[360px] text-sm">
|
||||
{view === "info" ? (
|
||||
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
|
||||
<InfoRow label="ID" value={node.id} />
|
||||
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : "生成配置"} />
|
||||
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
|
||||
<InfoRow label="位置" value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
|
||||
<InfoRow label="状态" value={node.metadata?.status || "idle"} />
|
||||
{batchCount > 1 ? <InfoRow label="图片组" value={`${batchCount} 张`} /> : null}
|
||||
{node.metadata?.prompt ? <InfoRow label="提示词" value={node.metadata.prompt} /> : null}
|
||||
{imageBytes ? <InfoRow label="图片大小" value={formatBytes(imageBytes)} /> : null}
|
||||
{node.metadata?.errorDetails ? <div className="rounded-lg border p-3 text-red-400" style={{ borderColor: theme.node.stroke }}>{node.metadata.errorDetails}</div> : null}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="thin-scrollbar h-full overflow-auto rounded-lg border p-3 text-xs leading-5" style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||
{json}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarAction({ title, label, icon, onClick, hint, active = false, danger = false }: { title: string; label: string; icon: ReactNode; onClick?: () => void; hint?: string; active?: boolean; danger?: boolean }) {
|
||||
return (
|
||||
<Tooltip title={title} placement="top" mouseEnterDelay={0.2}>
|
||||
<button type="button" className={`group relative flex h-12 items-center whitespace-nowrap px-1.5 ${danger ? "text-[#ef4444]" : ""}`} onClick={onClick} aria-label={title}>
|
||||
<span className={`flex h-9 items-center gap-2 rounded-lg px-2.5 transition group-hover:bg-[#f0f0f1] ${active ? "bg-[#eeeeef]" : ""}`}>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
{hint ? <span className="text-[#a3a3a3]">{hint}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function IconAction({ title, icon, onClick }: { title: string; icon: ReactNode; onClick: () => void }) {
|
||||
return (
|
||||
<Tooltip title={title} placement="top" mouseEnterDelay={0.2}>
|
||||
<button type="button" className="group relative grid h-12 w-12 place-items-center px-1.5" onClick={onClick} aria-label={title}>
|
||||
<span className="grid size-9 place-items-center rounded-lg transition group-hover:bg-[#f0f0f1]">{icon}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarDivider() {
|
||||
return <span className="mx-1 h-7 w-px scale-x-50 bg-[#dedee2]" />;
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-3">
|
||||
<span className="opacity-50">{label}</span>
|
||||
<span className="min-w-0 whitespace-pre-wrap break-words">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowUp, LoaderCircle } from "lucide-react";
|
||||
import { Button, InputNumber } from "antd";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { defaultConfig, type AiConfig } from "@/lib/ai-config";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useAiConfigStore } from "@/stores/use-ai-config-store";
|
||||
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasPromptLibrary } from "./canvas-prompt-library";
|
||||
import { CanvasSizePicker } from "./canvas-size-picker";
|
||||
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "../types";
|
||||
|
||||
export type CanvasNodeGenerationMode = CanvasGenerationMode;
|
||||
|
||||
type CanvasNodePromptPanelProps = {
|
||||
node: CanvasNodeData;
|
||||
isRunning: boolean;
|
||||
onPromptChange: (nodeId: string, prompt: string) => void;
|
||||
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => void;
|
||||
onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
|
||||
};
|
||||
|
||||
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate }: CanvasNodePromptPanelProps) {
|
||||
const globalConfig = useAiConfigStore((state) => state.config);
|
||||
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const mode = defaultMode(node.type);
|
||||
const config = buildNodeConfig(globalConfig, node, mode);
|
||||
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
|
||||
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
|
||||
const isEditingExistingContent = hasTextContent || hasImageContent;
|
||||
const [prompt, setPrompt] = useState(isEditingExistingContent ? "" : node.metadata?.prompt || "");
|
||||
|
||||
useEffect(() => {
|
||||
setPrompt(isEditingExistingContent ? "" : node.metadata?.prompt || "");
|
||||
}, [isEditingExistingContent, node.id]);
|
||||
|
||||
const updatePrompt = (value: string) => {
|
||||
setPrompt(value);
|
||||
if (!isEditingExistingContent) onPromptChange(node.id, value);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const text = prompt.trim();
|
||||
if (!text || isRunning) return;
|
||||
onGenerate(node.id, mode, text);
|
||||
setPrompt("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-3 shadow-2xl backdrop-blur"
|
||||
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => updatePrompt(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none"
|
||||
style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
placeholder={mode === "image" ? hasImageContent ? "请输入你想要把这张图修改成什么" : "描述要生成的图片内容" : hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容"}
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<CanvasPromptLibrary onSelect={updatePrompt} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||
{mode === "image" ? (
|
||||
<CanvasSizePicker className="h-10 w-[92px] shrink-0" value={config.size} onChange={(value) => onConfigChange(node.id, { size: value })} />
|
||||
) : null}
|
||||
{mode === "image" ? (
|
||||
<InputNumber min={1} max={15} className="canvas-compact-control canvas-control-number h-10 shrink-0 !w-[58px]" value={Math.floor(Math.abs(Number(config.count)) || 1)} onChange={(value) => onConfigChange(node.id, { count: Number(value) || 1 })} />
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
className="!h-10 !w-10 !min-w-10 shrink-0"
|
||||
disabled={isRunning || !prompt.trim()}
|
||||
onClick={submit}
|
||||
icon={isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
|
||||
aria-label="生成"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
|
||||
return type === CanvasNodeType.Text ? "text" : "image";
|
||||
}
|
||||
|
||||
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig {
|
||||
const defaultModel = mode === "image" ? globalConfig.imageModel : globalConfig.textModel;
|
||||
return {
|
||||
...globalConfig,
|
||||
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
|
||||
quality: globalConfig.quality || defaultConfig.quality,
|
||||
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronRight, Image as ImageIcon, RefreshCw, Star } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type Position } from "../types";
|
||||
|
||||
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
const selectionBlue = "#2f80ff";
|
||||
|
||||
type CanvasNodeProps = {
|
||||
data: CanvasNodeData;
|
||||
scale: number;
|
||||
isSelected: boolean;
|
||||
isRelated: boolean;
|
||||
isFocusRelated: boolean;
|
||||
isConnectionTarget: boolean;
|
||||
isConnecting: boolean;
|
||||
editRequestNonce?: number;
|
||||
showPanel: boolean;
|
||||
renderPanel?: (node: CanvasNodeData) => ReactNode;
|
||||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||||
batchCount?: number;
|
||||
batchExpanded?: boolean;
|
||||
batchClosing?: boolean;
|
||||
batchOpening?: boolean;
|
||||
batchRecovering?: boolean;
|
||||
batchMotion?: { x: number; y: number; index: number };
|
||||
onMouseDown: (event: React.MouseEvent, nodeId: string) => void;
|
||||
onHoverStart: (nodeId: string) => void;
|
||||
onHoverEnd: (nodeId: string) => void;
|
||||
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
|
||||
onResize: (nodeId: string, width: number, height: number, position?: Position) => void;
|
||||
onContentChange: (nodeId: string, content: string) => void;
|
||||
onToggleBatch?: (nodeId: string) => void;
|
||||
onSetBatchPrimary?: (node: CanvasNodeData) => void;
|
||||
onRetry?: (node: CanvasNodeData) => void;
|
||||
onGenerateImage?: (node: CanvasNodeData) => void;
|
||||
onContextMenu: (event: React.MouseEvent, nodeId: string) => void;
|
||||
};
|
||||
|
||||
type NodeContentRendererProps = {
|
||||
node: CanvasNodeData;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
isEditingContent: boolean;
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
isBatchRoot: boolean;
|
||||
batchCount: number;
|
||||
batchExpanded: boolean;
|
||||
batchOpening: boolean;
|
||||
batchRecovering: boolean;
|
||||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||||
onContentChange: (nodeId: string, content: string) => void;
|
||||
onStopEditing: () => void;
|
||||
onRetry?: (node: CanvasNodeData) => void;
|
||||
onGenerateImage?: (node: CanvasNodeData) => void;
|
||||
onToggleBatch?: () => void;
|
||||
onSetBatchPrimary?: () => void;
|
||||
};
|
||||
|
||||
export const CanvasNode = React.memo(function CanvasNode({
|
||||
data,
|
||||
scale,
|
||||
isSelected,
|
||||
isRelated,
|
||||
isFocusRelated,
|
||||
isConnectionTarget,
|
||||
isConnecting,
|
||||
editRequestNonce = 0,
|
||||
showPanel,
|
||||
renderPanel,
|
||||
renderNodeContent,
|
||||
batchCount = 0,
|
||||
batchExpanded = false,
|
||||
batchClosing = false,
|
||||
batchOpening = false,
|
||||
batchRecovering = false,
|
||||
batchMotion,
|
||||
onMouseDown,
|
||||
onHoverStart,
|
||||
onHoverEnd,
|
||||
onConnectStart,
|
||||
onResize,
|
||||
onContentChange,
|
||||
onToggleBatch,
|
||||
onSetBatchPrimary,
|
||||
onRetry,
|
||||
onGenerateImage,
|
||||
onContextMenu,
|
||||
}: CanvasNodeProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [isEditingContent, setIsEditingContent] = useState(false);
|
||||
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
|
||||
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
|
||||
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
|
||||
const isActive = isConnectionTarget || isSelected || isFocusRelated;
|
||||
const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent";
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const resizeRef = useRef({
|
||||
isResizing: false,
|
||||
corner: "bottom-right" as ResizeCorner,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
startLeft: 0,
|
||||
startTop: 0,
|
||||
startWidth: 0,
|
||||
startHeight: 0,
|
||||
keepRatio: false,
|
||||
ratio: 1,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const handleWheel = (event: WheelEvent) => event.stopPropagation();
|
||||
textarea.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => textarea.removeEventListener("wheel", handleWheel);
|
||||
}, [data.type, isEditingContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditingContent) return;
|
||||
const textarea = textareaRef.current;
|
||||
textarea?.focus();
|
||||
textarea?.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||
}, [isEditingContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editRequestNonce || data.type !== CanvasNodeType.Text) return;
|
||||
setIsEditingContent(true);
|
||||
}, [data.type, editRequestNonce]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditingContent) return;
|
||||
|
||||
const handleOutsidePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (isEditingContent && textareaRef.current?.contains(target)) return;
|
||||
|
||||
setIsEditingContent(false);
|
||||
};
|
||||
|
||||
window.addEventListener("pointerdown", handleOutsidePointerDown, true);
|
||||
return () => window.removeEventListener("pointerdown", handleOutsidePointerDown, true);
|
||||
}, [isEditingContent]);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!resizeRef.current.isResizing) return;
|
||||
|
||||
const dx = (event.clientX - resizeRef.current.startX) / scale;
|
||||
const dy = (event.clientY - resizeRef.current.startY) / scale;
|
||||
const minWidth = 220;
|
||||
const minHeight = 160;
|
||||
const startRight = resizeRef.current.startLeft + resizeRef.current.startWidth;
|
||||
const startBottom = resizeRef.current.startTop + resizeRef.current.startHeight;
|
||||
const fromLeft = resizeRef.current.corner.includes("left");
|
||||
const fromTop = resizeRef.current.corner.includes("top");
|
||||
const rawWidth = Math.max(minWidth, resizeRef.current.startWidth + (fromLeft ? -dx : dx));
|
||||
const rawHeight = Math.max(minHeight, resizeRef.current.startHeight + (fromTop ? -dy : dy));
|
||||
let width = rawWidth;
|
||||
let height = rawHeight;
|
||||
if (resizeRef.current.keepRatio) {
|
||||
const ratio = resizeRef.current.ratio;
|
||||
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||
height = width / ratio;
|
||||
} else {
|
||||
width = height * ratio;
|
||||
}
|
||||
if (height < minHeight) {
|
||||
height = minHeight;
|
||||
width = height * ratio;
|
||||
}
|
||||
if (width < minWidth) {
|
||||
width = minWidth;
|
||||
height = width / ratio;
|
||||
}
|
||||
}
|
||||
|
||||
onResize(data.id, width, height, {
|
||||
x: fromLeft ? startRight - width : resizeRef.current.startLeft,
|
||||
y: fromTop ? startBottom - height : resizeRef.current.startTop,
|
||||
});
|
||||
},
|
||||
[data.id, onResize, scale],
|
||||
);
|
||||
|
||||
const handleResizeUp = useCallback(() => {
|
||||
resizeRef.current.isResizing = false;
|
||||
window.removeEventListener("mousemove", handleResizeMove);
|
||||
window.removeEventListener("mouseup", handleResizeUp);
|
||||
}, [handleResizeMove]);
|
||||
|
||||
const handleResizeMouseDown = (event: React.MouseEvent, corner: ResizeCorner) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
resizeRef.current = {
|
||||
isResizing: true,
|
||||
corner,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startLeft: data.position.x,
|
||||
startTop: data.position.y,
|
||||
startWidth: data.width,
|
||||
startHeight: data.height,
|
||||
keepRatio: data.type === CanvasNodeType.Image && !data.metadata?.freeResize,
|
||||
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
|
||||
};
|
||||
window.addEventListener("mousemove", handleResizeMove);
|
||||
window.addEventListener("mouseup", handleResizeUp);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleResizeMove);
|
||||
window.removeEventListener("mouseup", handleResizeUp);
|
||||
};
|
||||
}, [handleResizeMove, handleResizeUp]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-node-id={data.id}
|
||||
className={`node-element absolute flex select-none flex-col transition-shadow duration-200 ${isSelected ? "z-50" : "z-10"}`}
|
||||
style={{
|
||||
transform: `translate(${data.position.x}px, ${data.position.y}px)`,
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
transition: "box-shadow 200ms ease",
|
||||
contain: "layout style",
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
onHoverStart(data.id);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHovered(false);
|
||||
onHoverEnd(data.id);
|
||||
}}
|
||||
onContextMenu={(event) => onContextMenu(event, data.id)}
|
||||
>
|
||||
<div
|
||||
className="relative h-full w-full overflow-visible rounded-3xl border-2"
|
||||
style={{
|
||||
background: hasImageContent ? "transparent" : theme.node.fill,
|
||||
borderColor: hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : theme.node.stroke,
|
||||
boxShadow: isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated && !isBatchChild ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined,
|
||||
}}
|
||||
onMouseDown={(event) => onMouseDown(event, data.id)}
|
||||
onDoubleClick={(event) => {
|
||||
if (isBatchRoot) {
|
||||
event.stopPropagation();
|
||||
onToggleBatch?.(data.id);
|
||||
return;
|
||||
}
|
||||
if (data.type !== CanvasNodeType.Text) return;
|
||||
event.stopPropagation();
|
||||
setIsEditingContent(true);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`}
|
||||
style={{
|
||||
background: hasImageContent ? "transparent" : theme.node.fill,
|
||||
"--batch-from-x": `${batchMotion?.x || 0}px`,
|
||||
"--batch-from-y": `${batchMotion?.y || 0}px`,
|
||||
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
|
||||
animation: data.metadata?.batchRootId
|
||||
? batchClosing
|
||||
? "canvas-batch-child-out 260ms cubic-bezier(.4,0,.2,1) both"
|
||||
: "canvas-batch-child-in 340ms cubic-bezier(.2,.85,.18,1) both"
|
||||
: undefined,
|
||||
animationDelay: data.metadata?.batchRootId ? `${batchClosing ? 0 : 45 + (batchMotion?.index || 0) * 24}ms` : undefined,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<NodeContent
|
||||
node={data}
|
||||
theme={theme}
|
||||
isEditingContent={isEditingContent}
|
||||
textareaRef={textareaRef}
|
||||
isBatchRoot={isBatchRoot}
|
||||
batchCount={batchCount}
|
||||
batchExpanded={batchExpanded}
|
||||
batchOpening={batchOpening}
|
||||
batchRecovering={batchRecovering}
|
||||
renderNodeContent={renderNodeContent}
|
||||
onContentChange={onContentChange}
|
||||
onStopEditing={() => setIsEditingContent(false)}
|
||||
onRetry={onRetry}
|
||||
onGenerateImage={onGenerateImage}
|
||||
onToggleBatch={() => onToggleBatch?.(data.id)}
|
||||
onSetBatchPrimary={() => onSetBatchPrimary?.(data)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!hasImageContent ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-12"
|
||||
style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
|
||||
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
|
||||
<ResizeHandle corner="bottom-left" onMouseDown={handleResizeMouseDown} />
|
||||
<ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} />
|
||||
</div>
|
||||
|
||||
<ConnectionHandleDot
|
||||
side="left"
|
||||
visible={hovered || isSelected || isConnecting}
|
||||
onMouseDown={(event) => onConnectStart(event, data.id, "target")}
|
||||
/>
|
||||
<ConnectionHandleDot
|
||||
side="right"
|
||||
visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)}
|
||||
onMouseDown={(event) => onConnectStart(event, data.id, "source")}
|
||||
/>
|
||||
|
||||
{showPanel && renderPanel && data.type !== CanvasNodeType.Config ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function NodeContent(props: NodeContentRendererProps) {
|
||||
if (props.node.type === CanvasNodeType.Config && props.renderNodeContent) return props.renderNodeContent(props.node);
|
||||
if (props.isBatchRoot) return <ImageNodeContent {...props} />;
|
||||
if (props.node.metadata?.status === "loading") return <LoadingContent theme={props.theme} />;
|
||||
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
|
||||
|
||||
const Renderer = nodeContentRenderers[props.node.type];
|
||||
return <Renderer {...props} />;
|
||||
}
|
||||
|
||||
const nodeContentRenderers = {
|
||||
[CanvasNodeType.Text]: TextContent,
|
||||
[CanvasNodeType.Image]: ImageNodeContent,
|
||||
[CanvasNodeType.Config]: EmptyImageContent,
|
||||
} satisfies Record<CanvasNodeType, (props: NodeContentRendererProps) => ReactNode>;
|
||||
|
||||
function LoadingContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.activeStroke }}>
|
||||
<div className="size-10 animate-spin rounded-full border-2" style={{ borderColor: theme.node.stroke, borderTopColor: theme.node.activeStroke }} />
|
||||
<span className="text-[10px] tracking-[0.2em]">生成中</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorContent({
|
||||
node,
|
||||
theme,
|
||||
onRetry,
|
||||
}: Pick<NodeContentRendererProps, "node" | "theme" | "onRetry">) {
|
||||
return (
|
||||
<div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center">
|
||||
<div className="text-xs leading-5 text-red-300">{node.metadata?.errorDetails || "生成失败"}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition hover:scale-[1.02]"
|
||||
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRetry?.(node);
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextContent({
|
||||
node,
|
||||
theme,
|
||||
isEditingContent,
|
||||
textareaRef,
|
||||
onContentChange,
|
||||
onStopEditing,
|
||||
onGenerateImage,
|
||||
}: NodeContentRendererProps) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden pt-8">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-3 z-20 inline-flex h-8 items-center gap-1 rounded-full border px-2.5 text-xs font-medium opacity-85 backdrop-blur-md transition hover:scale-[1.02] hover:opacity-100"
|
||||
style={{ background: `${theme.toolbar.panel}dd`, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onGenerateImage?.(node);
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
title="用文本生图"
|
||||
aria-label="用文本生图"
|
||||
>
|
||||
<ImageIcon className="size-3.5" />
|
||||
生图
|
||||
</button>
|
||||
{isEditingContent ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="thin-scrollbar block h-full w-full resize-none overflow-y-auto whitespace-pre-wrap break-words border-none bg-transparent pl-4 pr-14 pt-0 pb-4 m-0 font-mono leading-relaxed outline-none select-text appearance-none"
|
||||
style={{ fontSize: `${node.metadata?.fontSize || 14}px`, color: theme.node.text }}
|
||||
value={node.metadata?.content || ""}
|
||||
onChange={(event) => onContentChange(node.id, event.target.value)}
|
||||
onBlur={onStopEditing}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") onStopEditing();
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="thin-scrollbar block h-full w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent pl-4 pr-14 pt-0 pb-4 font-mono leading-relaxed"
|
||||
style={{ fontSize: `${node.metadata?.fontSize || 14}px`, color: theme.node.text }}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
>
|
||||
{node.metadata?.content || <span style={{ color: theme.node.placeholder }}>双击编辑文字</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageNodeContent(props: NodeContentRendererProps) {
|
||||
if (!props.node.metadata?.content && props.isBatchRoot) {
|
||||
const content = props.node.metadata?.status === "loading"
|
||||
? <LoadingContent theme={props.theme} />
|
||||
: props.node.metadata?.status === "error"
|
||||
? <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />
|
||||
: <EmptyImageContent {...props} isBatchRoot={false} />;
|
||||
return <BatchFrame batchCount={props.batchCount} batchExpanded={props.batchExpanded} batchOpening={props.batchOpening} batchRecovering={props.batchRecovering} onToggleBatch={props.onToggleBatch}>{content}</BatchFrame>;
|
||||
}
|
||||
if (!props.node.metadata?.content) return <EmptyImageContent {...props} />;
|
||||
|
||||
return (
|
||||
<ImageContent
|
||||
node={props.node}
|
||||
isBatchRoot={props.isBatchRoot}
|
||||
batchCount={props.batchCount}
|
||||
batchExpanded={props.batchExpanded}
|
||||
batchOpening={props.batchOpening}
|
||||
batchRecovering={props.batchRecovering}
|
||||
onToggleBatch={props.onToggleBatch}
|
||||
onSetBatchPrimary={props.onSetBatchPrimary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: NodeContentRendererProps) {
|
||||
const content = (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl" style={{ background: theme.toolbar.activeBg }}>
|
||||
<ImageIcon className="size-6 opacity-30" />
|
||||
</div>
|
||||
<span className="text-[10px] tracking-[0.18em] opacity-50">空图片节点</span>
|
||||
</div>
|
||||
);
|
||||
if (isBatchRoot) return <BatchFrame batchCount={batchCount} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} onToggleBatch={onToggleBatch}>{content}</BatchFrame>;
|
||||
return (
|
||||
content
|
||||
);
|
||||
}
|
||||
|
||||
function ImageContent({
|
||||
node,
|
||||
isBatchRoot,
|
||||
batchCount,
|
||||
batchExpanded,
|
||||
batchOpening,
|
||||
batchRecovering,
|
||||
onToggleBatch,
|
||||
onSetBatchPrimary,
|
||||
}: {
|
||||
node: CanvasNodeData;
|
||||
isBatchRoot: boolean;
|
||||
batchCount: number;
|
||||
batchExpanded: boolean;
|
||||
batchOpening: boolean;
|
||||
batchRecovering: boolean;
|
||||
onToggleBatch?: () => void;
|
||||
onSetBatchPrimary?: () => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const isBatchChild = Boolean(node.metadata?.batchRootId);
|
||||
|
||||
return (
|
||||
<BatchFrame batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} onToggleBatch={onToggleBatch}>
|
||||
<img
|
||||
src={node.metadata!.content!}
|
||||
alt={node.title}
|
||||
draggable={false}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
className={`pointer-events-none block h-full w-full select-none rounded-[inherit] ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`}
|
||||
/>
|
||||
{isBatchRoot ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-2.5 z-30 flex h-8 items-center justify-center gap-1 rounded-full border px-2.5 text-xs font-semibold shadow-[0_6px_18px_rgba(15,23,42,.10)] backdrop-blur-md transition hover:scale-[1.02]"
|
||||
style={{ background: `${theme.toolbar.panel}d9`, borderColor: `${theme.toolbar.border}cc`, color: theme.node.text }}
|
||||
aria-label={batchExpanded ? "图片组已展开" : "图片组已收起"}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleBatch?.();
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span className="leading-none text-[#2f80ff]">{batchCount}</span>
|
||||
<ChevronRight className={`size-3.5 opacity-55 transition-transform ${batchExpanded ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
) : null}
|
||||
{isBatchChild ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-3 z-30 flex h-9 items-center gap-1.5 rounded-xl border px-2.5 text-xs font-medium opacity-0 shadow-[0_8px_20px_rgba(68,64,60,.13)] backdrop-blur-md transition group-hover/batch:opacity-100 hover:scale-[1.02]"
|
||||
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSetBatchPrimary?.();
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Star className="size-3.5 text-[#2f80ff]" />
|
||||
设为主图
|
||||
</button>
|
||||
) : null}
|
||||
</BatchFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; onToggleBatch?: () => void; children: ReactNode }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const isBatchRoot = batchCount > 1;
|
||||
return (
|
||||
<div className="group/batch relative h-full w-full overflow-visible" onDoubleClick={isBatchRoot ? (event) => { event.stopPropagation(); onToggleBatch?.(); } : undefined}>
|
||||
{isBatchRoot ? (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-visible">
|
||||
{Array.from({ length: Math.min(batchCount - 1, 5) }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute rounded-[inherit] border shadow-[0_14px_34px_rgba(68,64,60,.16)] transition-all duration-300 group-hover/batch:translate-x-2"
|
||||
style={{
|
||||
inset: 0,
|
||||
background: `linear-gradient(135deg, ${theme.node.panel}, ${theme.node.fill})`,
|
||||
borderColor: theme.node.stroke,
|
||||
opacity: batchExpanded && !batchOpening ? 0.34 : 1,
|
||||
transform: batchOpening || batchRecovering ? `translate(${54 + index * 22}px, ${20 + index * 12}px) rotate(${8 + index * 5}deg) scale(.98)` : `translate(${34 + index * 18}px, ${14 + index * 10}px) rotate(${6 + index * 4}deg)`,
|
||||
zIndex: -index - 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function ResizeHandle({
|
||||
corner,
|
||||
onMouseDown,
|
||||
}: {
|
||||
corner: ResizeCorner;
|
||||
onMouseDown: (event: React.MouseEvent, corner: ResizeCorner) => void;
|
||||
}) {
|
||||
const positionClass = {
|
||||
"top-left": "-left-[14px] -top-[14px] cursor-nwse-resize",
|
||||
"top-right": "-right-[14px] -top-[14px] cursor-nesw-resize",
|
||||
"bottom-left": "-bottom-[14px] -left-[14px] cursor-nesw-resize",
|
||||
"bottom-right": "-bottom-[14px] -right-[14px] cursor-nwse-resize",
|
||||
}[corner];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute z-50 size-7 ${positionClass}`}
|
||||
onMouseDown={(event) => onMouseDown(event, corner)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionHandleDot({
|
||||
side,
|
||||
visible,
|
||||
onMouseDown,
|
||||
}: {
|
||||
side: "left" | "right";
|
||||
visible: boolean;
|
||||
onMouseDown: (event: React.MouseEvent) => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute top-1/2 z-30 flex size-12 -translate-y-1/2 cursor-crosshair items-center justify-center transition-opacity duration-150 ${
|
||||
side === "left" ? "-left-6" : "-right-6"
|
||||
} ${visible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<div
|
||||
className="size-3 rounded-full border-2 transition-all hover:scale-125"
|
||||
style={{ background: theme.node.panel, borderColor: theme.node.muted }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Download, Pencil, Trash2, X } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, Input } from "antd";
|
||||
|
||||
import { useCanvasStore, type CanvasProject } from "../stores/use-canvas-store";
|
||||
import { useCanvasUiStore } from "../stores/use-canvas-ui-store";
|
||||
|
||||
export function CanvasProjectCard({ project }: { project: CanvasProject }) {
|
||||
const router = useRouter();
|
||||
const renameProject = useCanvasStore((state) => state.renameProject);
|
||||
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
|
||||
const editingId = useCanvasUiStore((state) => state.editingProjectId);
|
||||
const editingTitle = useCanvasUiStore((state) => state.editingProjectTitle);
|
||||
const startEditing = useCanvasUiStore((state) => state.startEditingProject);
|
||||
const setEditingTitle = useCanvasUiStore((state) => state.setEditingProjectTitle);
|
||||
const stopEditing = useCanvasUiStore((state) => state.stopEditingProject);
|
||||
const toggleSelected = useCanvasUiStore((state) => state.toggleSelectedProjectId);
|
||||
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
|
||||
const editing = editingId === project.id;
|
||||
const selected = selectedIds.includes(project.id);
|
||||
const open = () => router.push(`/canvas/${project.id}`);
|
||||
const saveTitle = () => {
|
||||
renameProject(project.id, editingTitle);
|
||||
stopEditing();
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="group flex min-h-44 cursor-pointer flex-col justify-between rounded-2xl bg-[#f1eee8] p-5 transition hover:bg-[#ebe6dc] dark:bg-white/5 dark:hover:bg-white/10" onClick={() => !editing && open()}>
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => toggleSelected(project.id, event.target.checked)}
|
||||
className="mt-1 size-4 accent-stone-950 dark:accent-stone-100"
|
||||
aria-label={`选择 ${project.title}`}
|
||||
/>
|
||||
{editing ? (
|
||||
<Input className="min-w-0" value={editingTitle} onClick={(event) => event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus />
|
||||
) : (
|
||||
<button type="button" className="min-w-0 cursor-pointer text-left" onClick={(event) => { event.stopPropagation(); open(); }}>
|
||||
<h2 className="truncate text-xl font-semibold">{project.title}</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-stone-600 dark:text-stone-400">{project.nodes.length} 个节点 · {project.connections.length} 条连线</p>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8 flex items-end justify-between gap-3">
|
||||
<p className="text-xs text-stone-500">更新于 {new Date(project.updatedAt).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</p>
|
||||
<div className="flex items-center gap-1" onClick={(event) => event.stopPropagation()}>
|
||||
{editing ? (
|
||||
<>
|
||||
<Button type="text" size="small" shape="circle" icon={<Check className="size-4" />} onClick={saveTitle} aria-label="保存名称" />
|
||||
<Button type="text" size="small" shape="circle" icon={<X className="size-4" />} onClick={stopEditing} aria-label="取消重命名" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button type="text" size="small" shape="circle" icon={<Download className="size-4" />} onClick={() => exportProject(project)} aria-label="导出" />
|
||||
<Button type="text" size="small" shape="circle" icon={<Pencil className="size-4" />} onClick={() => startEditing(project.id, project.title)} aria-label="重命名" />
|
||||
<Button type="text" size="small" shape="circle" icon={<Trash2 className="size-4" />} onClick={() => setDeleteIds([project.id])} aria-label="删除" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function exportProject(project: CanvasProject) {
|
||||
const data = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), project };
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${(project.title || "无限画布").replace(/[\\/:*?"<>|]/g, "_")}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { BookOpen } from "lucide-react";
|
||||
|
||||
import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
|
||||
export function CanvasPromptLibrary({ onSelect }: { onSelect: (prompt: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="提示词库">
|
||||
<Button shape="circle" className="!h-8 !w-8 !min-w-8 shrink-0 !bg-transparent" style={{ borderColor: theme.node.stroke, color: theme.node.text }} icon={<BookOpen className="size-3.5" />} onClick={() => setOpen(true)} aria-label="提示词库" />
|
||||
</Tooltip>
|
||||
<PromptSelectDialog open={open} onOpenChange={setOpen} onSelect={onSelect} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const sizeOptions = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"];
|
||||
|
||||
type CanvasSizePickerProps = {
|
||||
value: string;
|
||||
className?: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function CanvasSizePicker({ value, className, onChange }: CanvasSizePickerProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const extraOptions = [value, search.trim()].filter((item) => item && !sizeOptions.includes(item));
|
||||
const options = [...sizeOptions, ...Array.from(new Set(extraOptions))].map((size) => ({ value: size, label: size }));
|
||||
const selectSize = (next: string) => {
|
||||
onChange(next.trim());
|
||||
setSearch("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (event: PointerEvent) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (target && (rootRef.current?.contains(target) || target.closest(".ant-select-dropdown"))) return;
|
||||
setOpen(false);
|
||||
};
|
||||
window.addEventListener("pointerdown", close, true);
|
||||
return () => window.removeEventListener("pointerdown", close, true);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={className}>
|
||||
<Select
|
||||
showSearch
|
||||
open={open}
|
||||
className={cn("canvas-compact-control canvas-control-select h-full w-full")}
|
||||
value={value || undefined}
|
||||
searchValue={search}
|
||||
placeholder="比例"
|
||||
options={options}
|
||||
popupMatchSelectWidth={false}
|
||||
onOpenChange={setOpen}
|
||||
onSearch={setSearch}
|
||||
onChange={selectSize}
|
||||
onBlur={() => {
|
||||
if (search.trim()) selectSize(search);
|
||||
}}
|
||||
onInputKeyDown={(event) => {
|
||||
if (event.key === "Enter" && search.trim()) selectSize(search);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Button, Segmented } from "antd";
|
||||
import { CircleDot, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Library, Moon, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload } from "lucide-react";
|
||||
|
||||
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||
|
||||
export function CanvasToolbar({
|
||||
selectedCount,
|
||||
canUndo,
|
||||
canRedo,
|
||||
backgroundMode,
|
||||
onAddImage,
|
||||
onAddText,
|
||||
onAddConfig,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onUpload,
|
||||
onDelete,
|
||||
onClear,
|
||||
onDeselect,
|
||||
onBackgroundModeChange,
|
||||
onOpenAssetLibrary,
|
||||
onOpenMyAssets,
|
||||
}: {
|
||||
selectedCount: number;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
backgroundMode: CanvasBackgroundMode;
|
||||
onAddImage: () => void;
|
||||
onAddText: () => void;
|
||||
onAddConfig: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onUpload: () => void;
|
||||
onDelete: () => void;
|
||||
onClear: () => void;
|
||||
onDeselect: () => void;
|
||||
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
|
||||
onOpenAssetLibrary: () => void;
|
||||
onOpenMyAssets: () => void;
|
||||
}) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const colorTheme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const theme = canvasThemes[colorTheme];
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
const [tipX, setTipX] = useState(0);
|
||||
const [appearanceOpen, setAppearanceOpen] = useState(false);
|
||||
const [panelX, setPanelX] = useState(0);
|
||||
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
|
||||
const hoverStyle = { background: theme.toolbar.itemHover, color: theme.toolbar.activeText };
|
||||
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
|
||||
const tip = hovered ? toolLabel(hovered) : "";
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
|
||||
{tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0"
|
||||
style={dockStyle}
|
||||
>
|
||||
<ToolbarButton id="tool-hand" label="移动/选择" active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
|
||||
<Hand className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton id="tool-undo" label="撤销" disabled={!canUndo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUndo}>
|
||||
<Undo2 className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton id="tool-redo" label="重做" disabled={!canRedo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onRedo}>
|
||||
<Redo2 className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
<Divider theme={theme} />
|
||||
<ToolbarButton id="tool-text" label="文本" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddText}>
|
||||
<Type className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton id="tool-image" label="图片" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddImage}>
|
||||
<ImageIcon className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton id="tool-config" label="生成配置" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddConfig}>
|
||||
<Settings2 className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton id="tool-upload" label="上传图片" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
|
||||
<Upload className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
<Divider theme={theme} />
|
||||
<ToolbarButton id="tool-library" label="素材库" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenAssetLibrary}>
|
||||
<Library className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton id="tool-assets" label="我的素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenMyAssets}>
|
||||
<FolderOpen className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
id="tool-style"
|
||||
label="画布外观"
|
||||
active={appearanceOpen}
|
||||
hovered={hovered}
|
||||
activeStyle={activeStyle}
|
||||
hoverStyle={hoverStyle}
|
||||
wrapRef={wrapRef}
|
||||
onTipX={setTipX}
|
||||
onHover={setHovered}
|
||||
onClick={(event) => {
|
||||
setPanelX(getTipX(wrapRef.current, event.currentTarget));
|
||||
setAppearanceOpen((value) => !value);
|
||||
}}
|
||||
>
|
||||
<Palette className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
{selectedCount ? (
|
||||
<>
|
||||
<Divider theme={theme} />
|
||||
<ToolbarButton id="tool-delete" label="删除选中" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDelete} danger>
|
||||
<Trash2 className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
</>
|
||||
) : null}
|
||||
<Divider theme={theme} />
|
||||
<ToolbarButton id="tool-clear" label="清空画布" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onClear} danger>
|
||||
<Eraser className="size-4.5" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
|
||||
{appearanceOpen ? (
|
||||
<div
|
||||
className="pointer-events-auto absolute bottom-[72px] z-30 w-[248px] -translate-x-1/2 rounded-xl border p-2.5 shadow-xl backdrop-blur"
|
||||
style={{ left: panelX || "50%", background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item }}
|
||||
>
|
||||
<div className="px-1 pb-2 text-sm font-medium opacity-65">画布外观</div>
|
||||
<div className="px-1 pb-1.5 text-[11px] font-medium opacity-50">主题模式</div>
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg p-1" style={{ background: theme.toolbar.itemHover }}>
|
||||
<CanvasThemeButton colorTheme={colorTheme} targetTheme="light" onThemeChange={setTheme}>
|
||||
<Sun className="size-4" />
|
||||
浅色
|
||||
</CanvasThemeButton>
|
||||
<CanvasThemeButton colorTheme={colorTheme} targetTheme="dark" onThemeChange={setTheme}>
|
||||
<Moon className="size-4" />
|
||||
深色
|
||||
</CanvasThemeButton>
|
||||
</div>
|
||||
<div className="mt-3 px-1 pb-1.5 text-[11px] font-medium opacity-50">网格样式</div>
|
||||
<Segmented
|
||||
className="w-full !p-1 [&_.ant-segmented-group]:!flex [&_.ant-segmented-item]:!min-h-8 [&_.ant-segmented-item]:!flex-1 [&_.ant-segmented-item-label]:!min-h-8 [&_.ant-segmented-item-label]:!leading-8"
|
||||
value={backgroundMode}
|
||||
onChange={(value) => onBackgroundModeChange(value as CanvasBackgroundMode)}
|
||||
options={[
|
||||
{ value: "dots", label: <span className="inline-flex items-center gap-1.5"><CircleDot className="size-4" />点</span> },
|
||||
{ value: "lines", label: <span className="inline-flex items-center gap-1.5"><Grid2x2 className="size-4" />线</span> },
|
||||
{ value: "blank", label: <span className="inline-flex items-center gap-1.5"><Square className="size-4" />空白</span> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
id,
|
||||
label,
|
||||
active,
|
||||
hovered,
|
||||
activeStyle,
|
||||
hoverStyle,
|
||||
wrapRef,
|
||||
onTipX,
|
||||
onHover,
|
||||
onClick,
|
||||
disabled = false,
|
||||
danger = false,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
hovered: string | null;
|
||||
activeStyle?: CSSProperties;
|
||||
hoverStyle: CSSProperties;
|
||||
wrapRef: RefObject<HTMLDivElement | null>;
|
||||
onTipX: (x: number) => void;
|
||||
onHover: (id: string | null) => void;
|
||||
onClick?: (event: ReactMouseEvent<HTMLElement>) => void;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="text"
|
||||
aria-label={label}
|
||||
className="!h-8 !w-8 !min-w-8 !p-0"
|
||||
disabled={disabled}
|
||||
style={active ? activeStyle : hovered === id && !disabled ? hoverStyle : { color: danger ? "#f87171" : theme.toolbar.item, opacity: disabled ? 0.35 : 1 }}
|
||||
icon={children}
|
||||
onMouseEnter={(event) => { onHover(id); onTipX(getTipX(wrapRef.current, event.currentTarget)); }}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider({ theme }: { theme: CanvasTheme }) {
|
||||
return <div className="mx-1 h-6 w-px" style={{ background: theme.toolbar.border }} />;
|
||||
}
|
||||
|
||||
function CanvasThemeButton({
|
||||
colorTheme,
|
||||
targetTheme,
|
||||
onThemeChange,
|
||||
children,
|
||||
}: {
|
||||
colorTheme: CanvasColorTheme;
|
||||
targetTheme: CanvasColorTheme;
|
||||
onThemeChange: (theme: CanvasColorTheme) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const theme = canvasThemes[colorTheme];
|
||||
const active = colorTheme === targetTheme;
|
||||
const activeStyle = colorTheme === "light" ? { background: "#111111", color: "#ffffff" } : { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
|
||||
|
||||
return (
|
||||
<AnimatedThemeToggler
|
||||
theme={colorTheme}
|
||||
targetTheme={targetTheme}
|
||||
onThemeChange={onThemeChange}
|
||||
className="inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md px-2 text-sm transition"
|
||||
style={active ? activeStyle : { color: theme.toolbar.item }}
|
||||
aria-label={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
|
||||
title={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
|
||||
>
|
||||
{children}
|
||||
</AnimatedThemeToggler>
|
||||
);
|
||||
}
|
||||
|
||||
function DockTip({ label, x, theme }: { label: string; x: number; theme: CanvasTheme }) {
|
||||
return <span className="absolute bottom-[calc(100%+8px)] -translate-x-1/2 rounded-md px-2 py-1 text-xs shadow-lg" style={{ left: x, background: theme.node.text, color: theme.node.panel }}>{label}</span>;
|
||||
}
|
||||
|
||||
function toolLabel(id: string) {
|
||||
if (id === "tool-hand") return "移动/选择";
|
||||
if (id === "tool-undo") return "撤销";
|
||||
if (id === "tool-redo") return "重做";
|
||||
if (id === "tool-text") return "文本";
|
||||
if (id === "tool-image") return "图片";
|
||||
if (id === "tool-config") return "生成配置";
|
||||
if (id === "tool-upload") return "上传图片";
|
||||
if (id === "tool-library") return "素材库";
|
||||
if (id === "tool-assets") return "我的素材";
|
||||
if (id === "tool-style") return "画布外观";
|
||||
if (id === "tool-delete") return "删除选中";
|
||||
if (id === "tool-clear") return "清空画布";
|
||||
return "";
|
||||
}
|
||||
|
||||
function getTipX(wrap: HTMLDivElement | null, target: HTMLElement) {
|
||||
if (!wrap) return 0;
|
||||
const wrapBox = wrap.parentElement?.getBoundingClientRect() || wrap.getBoundingClientRect();
|
||||
const box = target.getBoundingClientRect();
|
||||
return box.left - wrapBox.left + box.width / 2;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Compass, Focus, HelpCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button, Modal, Tooltip } from "antd";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
|
||||
type CanvasZoomControlsProps = {
|
||||
scale: number;
|
||||
onScaleChange: (scale: number) => void;
|
||||
onReset: () => void;
|
||||
isMiniMapOpen: boolean;
|
||||
onToggleMiniMap: () => void;
|
||||
};
|
||||
|
||||
export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpen, onToggleMiniMap }: CanvasZoomControlsProps) {
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const colorTheme = useThemeStore((state) => state.theme);
|
||||
const theme = canvasThemes[colorTheme];
|
||||
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
|
||||
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-5 left-5 z-50" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<div className="flex h-14 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={dockStyle}>
|
||||
<Tooltip title={isMiniMapOpen ? "关闭小地图" : "打开小地图"}>
|
||||
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={isMiniMapOpen ? activeStyle : { color: theme.toolbar.item }} icon={<Compass className="size-4" />} onClick={onToggleMiniMap} aria-label={isMiniMapOpen ? "关闭小地图" : "打开小地图"} />
|
||||
</Tooltip>
|
||||
<Tooltip title="重置视图">
|
||||
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={{ color: theme.toolbar.item }} icon={<Focus className="size-4" />} onClick={onReset} aria-label="重置视图" />
|
||||
</Tooltip>
|
||||
<Tooltip title="放大/缩小画布">
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="500"
|
||||
step="1"
|
||||
value={Math.round(scale * 100)}
|
||||
className="w-24"
|
||||
style={{ accentColor: theme.node.activeStroke }}
|
||||
onChange={(event) => onScaleChange(Number(event.target.value) / 100)}
|
||||
aria-label="放大/缩小画布"
|
||||
/>
|
||||
</Tooltip>
|
||||
<span className="w-10 text-right text-xs tabular-nums" style={{ color: theme.node.muted }}>
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<Tooltip title="快捷键">
|
||||
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={shortcutsOpen ? activeStyle : { color: theme.toolbar.item }} icon={<HelpCircle className="size-4" />} onClick={() => setShortcutsOpen(true)} aria-label="快捷键" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
|
||||
<div className="space-y-3 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
|
||||
<Shortcut label="拖动画布" value="平移视图" />
|
||||
<Shortcut label="滚轮" value="缩放画布" />
|
||||
<Shortcut label="Ctrl / Cmd + 拖动" value="框选多个节点" />
|
||||
<Shortcut label="Shift / Ctrl / Cmd + 点击" value="追加选择节点" />
|
||||
<Shortcut label="Ctrl / Cmd + C / V" value="复制 / 粘贴节点" />
|
||||
<Shortcut label="Delete / Backspace" value="删除选中" />
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Shortcut({ label, value }: { label: ReactNode; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-base font-medium">{label}</span>
|
||||
<span className="opacity-60">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { ViewportTransform } from "../types";
|
||||
|
||||
type InfiniteCanvasProps = {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
viewport: ViewportTransform;
|
||||
backgroundMode?: CanvasBackgroundMode;
|
||||
onViewportChange: (viewport: ViewportTransform) => void;
|
||||
onCanvasMouseDown?: (event: React.PointerEvent<HTMLDivElement>) => void;
|
||||
onCanvasDeselect?: () => void;
|
||||
onContextMenu?: (event: React.MouseEvent) => void;
|
||||
onDrop?: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function InfiniteCanvas({
|
||||
containerRef,
|
||||
viewport,
|
||||
backgroundMode = "lines",
|
||||
onViewportChange,
|
||||
onCanvasMouseDown,
|
||||
onCanvasDeselect,
|
||||
onContextMenu,
|
||||
onDrop,
|
||||
children,
|
||||
}: InfiniteCanvasProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const panState = useRef({
|
||||
isPanning: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
initialX: 0,
|
||||
initialY: 0,
|
||||
hasMoved: false,
|
||||
});
|
||||
const scaleRef = useRef(viewport.k);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const nextViewportRef = useRef<ViewportTransform | null>(null);
|
||||
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
scaleRef.current = viewport.k;
|
||||
}, [viewport.k]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code !== "Space") return;
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
|
||||
setIsSpacePressed(true);
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.code === "Space") setIsSpacePressed(false);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (target?.closest(".ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown")) return;
|
||||
|
||||
const delta = -event.deltaY;
|
||||
const factor = Math.pow(1.1, delta / 100);
|
||||
const newScale = Math.min(Math.max(viewport.k * factor, 0.05), 5);
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const mouseY = event.clientY - rect.top;
|
||||
const worldX = (mouseX - viewport.x) / viewport.k;
|
||||
const worldY = (mouseY - viewport.y) / viewport.k;
|
||||
|
||||
onViewportChange({
|
||||
x: mouseX - worldX * newScale,
|
||||
y: mouseY - worldY * newScale,
|
||||
k: newScale,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (target?.closest("[data-connection-create-menu]")) return;
|
||||
const isBackgroundClick = !target?.closest("[data-node-id],[data-connection-id]");
|
||||
|
||||
if (event.button === 0 && (event.ctrlKey || event.metaKey) && isBackgroundClick) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
onCanvasMouseDown?.(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.button === 1 || (event.button === 0 && !isSpacePressed && isBackgroundClick)) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
panState.current = {
|
||||
isPanning: true,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
initialX: viewport.x,
|
||||
initialY: viewport.y,
|
||||
hasMoved: false,
|
||||
};
|
||||
document.body.style.cursor = "grabbing";
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.button === 0 && isSpacePressed && isBackgroundClick) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (!panState.current.isPanning) return;
|
||||
|
||||
const dx = event.clientX - panState.current.startX;
|
||||
const dy = event.clientY - panState.current.startY;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
|
||||
panState.current.hasMoved = true;
|
||||
}
|
||||
|
||||
nextViewportRef.current = {
|
||||
x: panState.current.initialX + dx,
|
||||
y: panState.current.initialY + dy,
|
||||
k: scaleRef.current,
|
||||
};
|
||||
if (frameRef.current) return;
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
if (nextViewportRef.current) onViewportChange(nextViewportRef.current);
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (!panState.current.isPanning) return;
|
||||
|
||||
if (!panState.current.hasMoved) {
|
||||
onCanvasDeselect?.();
|
||||
}
|
||||
panState.current.isPanning = false;
|
||||
document.body.style.cursor = "default";
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
};
|
||||
}, [onCanvasDeselect, onViewportChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const preventWheelScroll = (event: WheelEvent) => event.preventDefault();
|
||||
container.addEventListener("wheel", preventWheelScroll, { passive: false });
|
||||
return () => container.removeEventListener("wheel", preventWheelScroll);
|
||||
}, [containerRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative h-full w-full cursor-grab select-none overflow-hidden"
|
||||
style={{ background: theme.canvas.background }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<CanvasGrid viewport={viewport} mode={backgroundMode} />
|
||||
<div
|
||||
className="absolute origin-top-left"
|
||||
style={{
|
||||
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.k})`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasGrid({ viewport, mode }: { viewport: ViewportTransform; mode: CanvasBackgroundMode }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
if (mode === "blank") return null;
|
||||
|
||||
const gridSize = 48 * viewport.k;
|
||||
const x = viewport.x % gridSize;
|
||||
const y = viewport.y % gridSize;
|
||||
const dotSize = viewport.k < 0.12 ? 0.8 : 1.15;
|
||||
const backgroundImage = mode === "dots"
|
||||
? `radial-gradient(circle, ${theme.canvas.dot} ${dotSize}px, transparent ${dotSize + 0.2}px)`
|
||||
: `linear-gradient(${theme.canvas.line} 1px, transparent 1px), linear-gradient(90deg, ${theme.canvas.line} 1px, transparent 1px)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-40"
|
||||
style={{
|
||||
backgroundImage,
|
||||
backgroundSize: `${gridSize}px ${gridSize}px`,
|
||||
backgroundPosition: `${x}px ${y}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CanvasNodeType } from "./types";
|
||||
import type { CanvasNodeMetadata } from "./types";
|
||||
|
||||
type CanvasNodeSpec = {
|
||||
width: number;
|
||||
height: number;
|
||||
title: string;
|
||||
metadata?: CanvasNodeMetadata;
|
||||
};
|
||||
|
||||
export const NODE_DEFAULT_SIZE = {
|
||||
[CanvasNodeType.Image]: { width: 340, height: 240, title: "New Generation" },
|
||||
[CanvasNodeType.Text]: { width: 340, height: 240, title: "Note" },
|
||||
[CanvasNodeType.Config]: { width: 340, height: 240, title: "生成配置" },
|
||||
} satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>;
|
||||
|
||||
export const NODE_SPECS = {
|
||||
[CanvasNodeType.Image]: {
|
||||
...NODE_DEFAULT_SIZE[CanvasNodeType.Image],
|
||||
metadata: { content: "", status: "idle" },
|
||||
},
|
||||
[CanvasNodeType.Text]: {
|
||||
...NODE_DEFAULT_SIZE[CanvasNodeType.Text],
|
||||
metadata: { content: "", status: "idle", fontSize: 14 },
|
||||
},
|
||||
[CanvasNodeType.Config]: {
|
||||
...NODE_DEFAULT_SIZE[CanvasNodeType.Config],
|
||||
metadata: { content: "", status: "idle", generationMode: "image" },
|
||||
},
|
||||
} satisfies Record<CanvasNodeType, CanvasNodeSpec>;
|
||||
|
||||
export function getNodeSpec(type: CanvasNodeType) {
|
||||
return NODE_SPECS[type];
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { App, Button } from "antd";
|
||||
import { FileUp, Plus } from "lucide-react";
|
||||
|
||||
import { CanvasDeleteProjectsDialog } from "./components/canvas-delete-projects-dialog";
|
||||
import { CanvasProjectCard } from "./components/canvas-project-card";
|
||||
import { useCanvasStore, type CanvasProject } from "./stores/use-canvas-store";
|
||||
import { useCanvasUiStore } from "./stores/use-canvas-ui-store";
|
||||
|
||||
type CanvasExportFile = {
|
||||
app: "infinite-canvas";
|
||||
version: number;
|
||||
exportedAt: string;
|
||||
project: CanvasProject;
|
||||
};
|
||||
|
||||
export default function CanvasPage() {
|
||||
const { message } = App.useApp();
|
||||
const router = useRouter();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const hydrated = useCanvasStore((state) => state.hydrated);
|
||||
const projects = useCanvasStore((state) => state.projects);
|
||||
const createProject = useCanvasStore((state) => state.createProject);
|
||||
const importProject = useCanvasStore((state) => state.importProject);
|
||||
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
|
||||
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
|
||||
|
||||
const enterProject = (id: string) => {
|
||||
router.push(`/canvas/${id}`);
|
||||
};
|
||||
const createAndEnter = () => enterProject(createProject(`无限画布 ${projects.length + 1}`));
|
||||
const importCanvas = async (file?: File) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const data = JSON.parse(await file.text()) as CanvasExportFile;
|
||||
enterProject(importProject(data.project));
|
||||
message.success("画布已导入");
|
||||
} catch {
|
||||
message.error("导入失败,请选择有效的 JSON 文件");
|
||||
} finally {
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="h-full overflow-auto bg-background text-stone-950 dark:text-stone-100">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-10">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4 border-b border-stone-200 pb-6 dark:border-stone-800">
|
||||
<div>
|
||||
<p className="text-xs text-stone-500">画布库</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold">无限画布</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIds.length ? <Button disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>删除选中</Button> : null}
|
||||
{projects.length ? <Button disabled={!hydrated} onClick={() => setDeleteIds(projects.map((project) => project.id))}>删除全部</Button> : null}
|
||||
<Button disabled={!hydrated} icon={<FileUp className="size-4" />} onClick={() => inputRef.current?.click()}>导入画布</Button>
|
||||
<Button disabled={!hydrated} type="primary" icon={<Plus className="size-4" />} onClick={createAndEnter}>新建画布</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!hydrated ? (
|
||||
<section className="flex min-h-[360px] items-center justify-center border-y border-stone-200 text-sm text-stone-500 dark:border-stone-800">正在加载画布...</section>
|
||||
) : projects.length ? (
|
||||
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<CanvasProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<section className="flex min-h-[360px] flex-col items-center justify-center border-y border-stone-200 text-center dark:border-stone-800">
|
||||
<h2 className="text-xl font-medium">还没有画布</h2>
|
||||
<p className="mt-3 text-sm text-stone-500">新建一个画布后,就可以独立保存节点、连线和画布外观。</p>
|
||||
<Button type="primary" className="mt-6" icon={<Plus className="size-4" />} onClick={createAndEnter}>新建画布</Button>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input ref={inputRef} type="file" accept="application/json,.json" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
|
||||
<CanvasDeleteProjectsDialog />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, type PersistStorage, type StorageValue } from "zustand/middleware";
|
||||
|
||||
import { createId } from "@/lib/id";
|
||||
import { localForageStorage } from "@/lib/localforage-storage";
|
||||
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
|
||||
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "../types";
|
||||
|
||||
export type CanvasProject = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
nodes: CanvasNodeData[];
|
||||
connections: CanvasConnection[];
|
||||
chatSessions: CanvasAssistantSession[];
|
||||
activeChatId: string | null;
|
||||
backgroundMode: CanvasBackgroundMode;
|
||||
viewport: ViewportTransform;
|
||||
};
|
||||
|
||||
type CanvasStore = {
|
||||
hydrated: boolean;
|
||||
projects: CanvasProject[];
|
||||
createProject: (title?: string) => string;
|
||||
importProject: (project: Partial<CanvasProject>) => string;
|
||||
openProject: (id: string) => CanvasProject | null;
|
||||
renameProject: (id: string, title: string) => void;
|
||||
deleteProjects: (ids: string[]) => void;
|
||||
updateProject: (id: string, patch: Partial<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "viewport">>) => void;
|
||||
};
|
||||
|
||||
const initialViewport: ViewportTransform = { x: 0, y: 0, k: 1 };
|
||||
const CANVAS_STORE_KEY = "infinite-canvas:canvas_store";
|
||||
type PersistedCanvasState = Pick<CanvasStore, "projects">;
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let queuedPersistState: PersistedCanvasState | null = null;
|
||||
|
||||
const canvasStorage: PersistStorage<CanvasStore> = {
|
||||
getItem: async (name) => {
|
||||
const value = await localForageStorage.getItem(name);
|
||||
if (!value) return null;
|
||||
const parsed = JSON.parse(value) as StorageValue<CanvasStore>;
|
||||
queuedPersistState = parsed.state as PersistedCanvasState;
|
||||
return parsed;
|
||||
},
|
||||
setItem: (name, value) => {
|
||||
const nextState = value.state as PersistedCanvasState;
|
||||
if (queuedPersistState && queuedPersistState.projects === nextState.projects) return;
|
||||
queuedPersistState = nextState;
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
void localForageStorage.setItem(name, JSON.stringify(value));
|
||||
}, 400);
|
||||
},
|
||||
removeItem: (name) => localForageStorage.removeItem(name),
|
||||
};
|
||||
|
||||
export const useCanvasStore = create<CanvasStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
hydrated: false,
|
||||
projects: [],
|
||||
createProject: (title = "未命名画布") => {
|
||||
const now = new Date().toISOString();
|
||||
const id = createId();
|
||||
const project: CanvasProject = {
|
||||
id,
|
||||
title,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
nodes: [],
|
||||
connections: [],
|
||||
chatSessions: [],
|
||||
activeChatId: null,
|
||||
backgroundMode: "lines",
|
||||
viewport: initialViewport,
|
||||
};
|
||||
set((state) => ({ projects: [project, ...state.projects] }));
|
||||
return id;
|
||||
},
|
||||
importProject: (source) => {
|
||||
const now = new Date().toISOString();
|
||||
const project: CanvasProject = {
|
||||
id: createId(),
|
||||
title: source.title || "导入画布",
|
||||
createdAt: source.createdAt || now,
|
||||
updatedAt: now,
|
||||
nodes: source.nodes || [],
|
||||
connections: source.connections || [],
|
||||
chatSessions: source.chatSessions || [],
|
||||
activeChatId: source.activeChatId || null,
|
||||
backgroundMode: source.backgroundMode || "lines",
|
||||
viewport: source.viewport || initialViewport,
|
||||
};
|
||||
set((state) => ({ projects: [project, ...state.projects] }));
|
||||
return project.id;
|
||||
},
|
||||
openProject: (id) => {
|
||||
return get().projects.find((item) => item.id === id) || null;
|
||||
},
|
||||
renameProject: (id, title) => set((state) => ({
|
||||
projects: state.projects.map((project) => project.id === id ? { ...project, title: title.trim() || project.title, updatedAt: new Date().toISOString() } : project),
|
||||
})),
|
||||
deleteProjects: (ids) => set((state) => {
|
||||
const projects = state.projects.filter((project) => !ids.includes(project.id));
|
||||
return { projects };
|
||||
}),
|
||||
updateProject: (id, patch) => set((state) => ({
|
||||
projects: state.projects.map((project) => project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project),
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: CANVAS_STORE_KEY,
|
||||
storage: canvasStorage,
|
||||
partialize: (state) => ({
|
||||
projects: state.projects,
|
||||
}) as StorageValue<CanvasStore>["state"],
|
||||
onRehydrateStorage: () => () => {
|
||||
useCanvasStore.setState({ hydrated: true });
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
type CanvasUiStore = {
|
||||
editingProjectId: string | null;
|
||||
editingProjectTitle: string;
|
||||
selectedProjectIds: string[];
|
||||
deleteProjectIds: string[];
|
||||
startEditingProject: (id: string, title: string) => void;
|
||||
setEditingProjectTitle: (title: string) => void;
|
||||
stopEditingProject: () => void;
|
||||
toggleSelectedProjectId: (id: string, selected: boolean) => void;
|
||||
setDeleteProjectIds: (ids: string[]) => void;
|
||||
removeSelectedProjectIds: (ids: string[]) => void;
|
||||
};
|
||||
|
||||
export const useCanvasUiStore = create<CanvasUiStore>((set) => ({
|
||||
editingProjectId: null,
|
||||
editingProjectTitle: "",
|
||||
selectedProjectIds: [],
|
||||
deleteProjectIds: [],
|
||||
startEditingProject: (editingProjectId, editingProjectTitle) => set({ editingProjectId, editingProjectTitle }),
|
||||
setEditingProjectTitle: (editingProjectTitle) => set({ editingProjectTitle }),
|
||||
stopEditingProject: () => set({ editingProjectId: null }),
|
||||
toggleSelectedProjectId: (id, selected) => set((state) => ({ selectedProjectIds: selected ? [...new Set([...state.selectedProjectIds, id])] : state.selectedProjectIds.filter((item) => item !== id) })),
|
||||
setDeleteProjectIds: (deleteProjectIds) => set({ deleteProjectIds }),
|
||||
removeSelectedProjectIds: (ids) => set((state) => ({ selectedProjectIds: state.selectedProjectIds.filter((id) => !ids.includes(id)) })),
|
||||
}));
|
||||
@@ -0,0 +1,115 @@
|
||||
export type Position = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type ViewportTransform = {
|
||||
x: number;
|
||||
y: number;
|
||||
k: number;
|
||||
};
|
||||
|
||||
export enum CanvasNodeType {
|
||||
Image = "image",
|
||||
Text = "text",
|
||||
Config = "config",
|
||||
}
|
||||
|
||||
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
|
||||
export type CanvasGenerationMode = "text" | "image";
|
||||
|
||||
export type CanvasNodeMetadata = {
|
||||
content?: string;
|
||||
prompt?: string;
|
||||
status?: CanvasNodeStatus;
|
||||
errorDetails?: string;
|
||||
fontSize?: number;
|
||||
generationMode?: CanvasGenerationMode;
|
||||
model?: string;
|
||||
size?: string;
|
||||
count?: number;
|
||||
naturalWidth?: number;
|
||||
naturalHeight?: number;
|
||||
freeResize?: boolean;
|
||||
isBatchRoot?: boolean;
|
||||
batchRootId?: string;
|
||||
batchChildIds?: string[];
|
||||
primaryImageId?: string;
|
||||
imageBatchExpanded?: boolean;
|
||||
inputOrder?: string[];
|
||||
storageKey?: string;
|
||||
mimeType?: string;
|
||||
bytes?: number;
|
||||
};
|
||||
|
||||
export type CanvasNodeData = {
|
||||
id: string;
|
||||
type: CanvasNodeType;
|
||||
title: string;
|
||||
position: Position;
|
||||
width: number;
|
||||
height: number;
|
||||
metadata?: CanvasNodeMetadata;
|
||||
};
|
||||
|
||||
export type CanvasConnection = {
|
||||
id: string;
|
||||
fromNodeId: string;
|
||||
toNodeId: string;
|
||||
};
|
||||
|
||||
export type CanvasAssistantReference = {
|
||||
id: string;
|
||||
type: CanvasNodeType;
|
||||
title: string;
|
||||
dataUrl?: string;
|
||||
storageKey?: string;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
export type CanvasAssistantImage = {
|
||||
id: string;
|
||||
dataUrl: string;
|
||||
storageKey?: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type CanvasAssistantMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
mode: "ask" | "image";
|
||||
text: string;
|
||||
isLoading?: boolean;
|
||||
references?: CanvasAssistantReference[];
|
||||
images?: CanvasAssistantImage[];
|
||||
};
|
||||
|
||||
export type CanvasAssistantSession = {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: CanvasAssistantMessage[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ConnectionHandle = {
|
||||
nodeId: string;
|
||||
handleType: "source" | "target";
|
||||
};
|
||||
|
||||
export type SelectionBox = {
|
||||
startWorldX: number;
|
||||
startWorldY: number;
|
||||
currentWorldX: number;
|
||||
currentWorldY: number;
|
||||
additive: boolean;
|
||||
initialSelectedNodeIds: string[];
|
||||
};
|
||||
|
||||
export type ContextMenuState =
|
||||
{
|
||||
type: "node";
|
||||
x: number;
|
||||
y: number;
|
||||
nodeId: string;
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
export type ImageCropRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type ImageAngleTransform = {
|
||||
horizontalAngle: number;
|
||||
pitchAngle: number;
|
||||
cameraDistance: number;
|
||||
wideAngle: boolean;
|
||||
};
|
||||
|
||||
export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
|
||||
const image = await loadImage(dataUrl);
|
||||
if (crop) {
|
||||
return drawCrop(
|
||||
image,
|
||||
Math.floor(crop.x * image.width),
|
||||
Math.floor(crop.y * image.height),
|
||||
Math.ceil(crop.width * image.width),
|
||||
Math.ceil(crop.height * image.height),
|
||||
);
|
||||
}
|
||||
const size = Math.min(image.width, image.height);
|
||||
const sx = Math.max(0, Math.floor((image.width - size) / 2));
|
||||
const sy = Math.max(0, Math.floor((image.height - size) / 2));
|
||||
return drawCrop(image, sx, sy, size, size);
|
||||
}
|
||||
|
||||
export async function transformAngleDataUrl(dataUrl: string, params: ImageAngleTransform) {
|
||||
const image = await loadImage(dataUrl);
|
||||
const canvas = document.createElement("canvas");
|
||||
const padding = Math.round(Math.max(image.width, image.height) * 0.18);
|
||||
canvas.width = image.width + padding * 2;
|
||||
canvas.height = image.height + padding * 2;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return dataUrl;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const horizontal = params.horizontalAngle / 60;
|
||||
const pitch = params.pitchAngle / 45;
|
||||
const distanceScale = 1.12 - params.cameraDistance * 0.035;
|
||||
const wideScale = params.wideAngle ? 0.88 : 1;
|
||||
const scale = Math.max(0.64, Math.min(1.1, distanceScale * wideScale));
|
||||
const width = image.width * scale * (1 - Math.abs(horizontal) * 0.28);
|
||||
const height = image.height * scale * (1 - Math.abs(pitch) * 0.18);
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const skewX = horizontal * image.width * 0.18;
|
||||
const skewY = pitch * image.height * 0.12;
|
||||
const x = cx - width / 2 + horizontal * padding * 0.5;
|
||||
const y = cy - height / 2 + pitch * padding * 0.45;
|
||||
|
||||
context.save();
|
||||
context.setTransform(1, pitch * 0.08, horizontal * -0.1, 1, 0, 0);
|
||||
context.drawImage(image, x + skewX, y + skewY, width, height);
|
||||
context.restore();
|
||||
|
||||
if (params.wideAngle) {
|
||||
const gradient = context.createRadialGradient(cx, cy, Math.min(canvas.width, canvas.height) * 0.2, cx, cy, Math.max(canvas.width, canvas.height) * 0.62);
|
||||
gradient.addColorStop(0, "rgba(255,255,255,0)");
|
||||
gradient.addColorStop(1, "rgba(0,0,0,0.18)");
|
||||
context.fillStyle = gradient;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, sh: number) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, sw);
|
||||
canvas.height = Math.max(1, sh);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return image.src;
|
||||
context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
function loadImage(dataUrl: string) {
|
||||
return new Promise<HTMLImageElement>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.src = dataUrl;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
"use client";
|
||||
|
||||
import { BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, ImagePlus, LoaderCircle, PenLine, Plus, SlidersHorizontal, Sparkles, Trash2, Upload } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { App, AutoComplete, Button, Checkbox, Drawer, Empty, Image, Input, InputNumber, Modal, Select, Tag, Typography } from "antd";
|
||||
import localforage from "localforage";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
|
||||
import { AssetPickerModal, type InsertAssetPayload } from "@/app/(user)/canvas/components/asset-picker-modal";
|
||||
import type { AiConfig } from "@/lib/ai-config";
|
||||
import { createId } from "@/lib/id";
|
||||
import { formatBytes, formatDuration, getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
|
||||
import { requestEdit, requestGeneration } from "@/services/api/image";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { useAiConfigStore } from "@/stores/use-ai-config-store";
|
||||
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
|
||||
type GeneratedImage = {
|
||||
id: string;
|
||||
dataUrl: string;
|
||||
durationMs: number;
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
type GenerationResult = {
|
||||
id: string;
|
||||
status: "pending" | "success" | "failed";
|
||||
image?: GeneratedImage;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type GenerationLog = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
title: string;
|
||||
time: string;
|
||||
model: string;
|
||||
durationMs: number;
|
||||
successCount: number;
|
||||
failCount: number;
|
||||
imageCount: number;
|
||||
size: string;
|
||||
quality: string;
|
||||
status: "成功" | "失败";
|
||||
thumbnails: string[];
|
||||
};
|
||||
|
||||
type UpdateAiConfig = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
||||
|
||||
const sizeOptions = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"].map((value) => ({ label: value, value }));
|
||||
const qualityOptions = ["auto", "low", "medium", "high"].map((value) => ({ label: value, value }));
|
||||
const LOG_STORE_KEY = "infinite-canvas:image_generation_logs";
|
||||
const LOG_STORE_PREFIX = `${LOG_STORE_KEY}:`;
|
||||
const logStore = localforage.createInstance({ name: "infinite-canvas", storeName: "image_generation_logs" });
|
||||
|
||||
export default function ImagePage() {
|
||||
const { message } = App.useApp();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const config = useAiConfigStore((state) => state.config);
|
||||
const updateConfig = useAiConfigStore((state) => state.updateConfig);
|
||||
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [references, setReferences] = useState<ReferenceImage[]>([]);
|
||||
const [results, setResults] = useState<GenerationResult[]>([]);
|
||||
const [logs, setLogs] = useState<GenerationLog[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
|
||||
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
|
||||
const [startedAt, setStartedAt] = useState(0);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const [selectedLogIds, setSelectedLogIds] = useState<string[]>([]);
|
||||
const [previewLog, setPreviewLog] = useState<GenerationLog | null>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
|
||||
const model = config.imageModel || config.model;
|
||||
const canGenerate = Boolean(prompt.trim());
|
||||
const generationCount = Math.max(1, Math.min(10, Number(config.count) || 1));
|
||||
|
||||
useEffect(() => {
|
||||
if (!running || !startedAt) return;
|
||||
const timer = window.setInterval(() => setElapsedMs(performance.now() - startedAt), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [running, startedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshLogs();
|
||||
}, []);
|
||||
|
||||
const addReferences = async (files?: FileList | null) => {
|
||||
const imageFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
|
||||
const nextReferences = await Promise.all(imageFiles.map(async (file) => {
|
||||
const image = await uploadImage(file);
|
||||
return { id: createId(), name: file.name, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey };
|
||||
}));
|
||||
setReferences((value) => [...value, ...nextReferences]);
|
||||
};
|
||||
|
||||
const addReferencesFromClipboard = async () => {
|
||||
try {
|
||||
const items = await navigator.clipboard.read();
|
||||
const blobs = await Promise.all(items.flatMap((item) => item.types.filter((type) => type.startsWith("image/")).map((type) => item.getType(type))));
|
||||
if (!blobs.length) {
|
||||
message.error("剪切板里没有可读取的图片");
|
||||
return;
|
||||
}
|
||||
const nextReferences = await Promise.all(blobs.map(async (blob, index) => {
|
||||
const image = await uploadImage(blob);
|
||||
return { id: createId(), name: `clipboard-${index + 1}.png`, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey };
|
||||
}));
|
||||
setReferences((value) => [...value, ...nextReferences]);
|
||||
message.success(`已读取 ${nextReferences.length} 张参考图`);
|
||||
} catch {
|
||||
message.error("剪切板里没有可读取的图片");
|
||||
}
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
const text = prompt.trim();
|
||||
if (!text) {
|
||||
message.error("请输入生图提示词");
|
||||
return;
|
||||
}
|
||||
if (!config.baseUrl.trim() || !config.apiKey.trim() || !model) {
|
||||
message.warning("请先完成配置");
|
||||
openConfigDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = buildRequestSnapshot();
|
||||
if (!snapshot) return;
|
||||
|
||||
setElapsedMs(0);
|
||||
setRunning(true);
|
||||
setPreviewLog(null);
|
||||
setResults(Array.from({ length: generationCount }, () => ({ id: createId(), status: "pending" })));
|
||||
const batchStartedAt = performance.now();
|
||||
setStartedAt(batchStartedAt);
|
||||
|
||||
const tasks = Array.from({ length: generationCount }, (_, index) => runGenerationSlot(index, snapshot));
|
||||
|
||||
const result = await Promise.allSettled(tasks);
|
||||
const successImages = result
|
||||
.filter((item): item is PromiseFulfilledResult<GeneratedImage> => item.status === "fulfilled")
|
||||
.map((item) => item.value);
|
||||
const successCount = successImages.length;
|
||||
const failCount = generationCount - successCount;
|
||||
const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected");
|
||||
|
||||
try {
|
||||
saveLog(buildLog({ prompt: text, model, config: { ...snapshot.config, count: String(generationCount) }, durationMs: performance.now() - batchStartedAt, successCount, failCount, status: successCount ? "成功" : "失败", thumbnails: successImages.map((image) => image.dataUrl) }));
|
||||
successCount ? message.success("图片已生成") : message.error(failed?.reason instanceof Error ? failed.reason.message : "生成失败");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadImage = (image: GeneratedImage, index: number) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = image.dataUrl;
|
||||
link.download = `image-${index + 1}.png`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const addResultToReferences = async (image: GeneratedImage, index: number) => {
|
||||
const stored = await uploadImage(image.dataUrl);
|
||||
setReferences((value) => [...value, { id: createId(), name: `result-${index + 1}.png`, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]);
|
||||
message.success("已加入参考图");
|
||||
};
|
||||
|
||||
const saveResultToAssets = async (image: GeneratedImage, index: number) => {
|
||||
const stored = await uploadImage(image.dataUrl);
|
||||
addAsset({
|
||||
kind: "image",
|
||||
title: `生成结果 ${index + 1}`,
|
||||
coverUrl: stored.url,
|
||||
tags: [],
|
||||
source: "生图工作台",
|
||||
data: { dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType },
|
||||
metadata: { source: "image-page", prompt },
|
||||
});
|
||||
message.success("已加入我的素材");
|
||||
};
|
||||
|
||||
const insertPickedAsset = async (payload: InsertAssetPayload) => {
|
||||
if (payload.kind === "text") {
|
||||
setPrompt(payload.content);
|
||||
} else {
|
||||
const stored = await uploadImage(payload.dataUrl);
|
||||
setReferences((value) => [...value, { id: createId(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]);
|
||||
}
|
||||
setAssetPickerOpen(false);
|
||||
};
|
||||
|
||||
const createSession = () => {
|
||||
setPrompt("");
|
||||
setReferences([]);
|
||||
setResults([]);
|
||||
setElapsedMs(0);
|
||||
setStartedAt(0);
|
||||
setSelectedLogIds([]);
|
||||
setPreviewLog(null);
|
||||
};
|
||||
|
||||
const deleteSelectedLogs = () => {
|
||||
void Promise.all(selectedLogIds.map((id) => logStore.removeItem(id))).then(refreshLogs);
|
||||
if (previewLog && selectedLogIds.includes(previewLog.id)) {
|
||||
setPreviewLog(null);
|
||||
setResults([]);
|
||||
}
|
||||
setSelectedLogIds([]);
|
||||
setDeleteConfirmOpen(false);
|
||||
};
|
||||
|
||||
const saveLog = (log: GenerationLog) => {
|
||||
void logStore.setItem(log.id, log).then(refreshLogs);
|
||||
};
|
||||
|
||||
const refreshLogs = async () => setLogs(await readStoredLogs());
|
||||
|
||||
const previewGenerationLog = async (log: GenerationLog) => {
|
||||
setPreviewLog(log);
|
||||
setLogsOpen(false);
|
||||
const images = await Promise.all(log.thumbnails.map(async (dataUrl, index) => {
|
||||
const meta = await readImageMeta(dataUrl);
|
||||
return { id: `${log.id}-${index}`, dataUrl, durationMs: log.durationMs, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(dataUrl) };
|
||||
}));
|
||||
setResults(images.map((image) => ({ id: image.id, status: "success", image })));
|
||||
};
|
||||
|
||||
const buildRequestSnapshot = () => {
|
||||
const text = prompt.trim();
|
||||
if (!text) {
|
||||
message.error("请输入生图提示词");
|
||||
return null;
|
||||
}
|
||||
if (!config.baseUrl.trim() || !config.apiKey.trim() || !model) {
|
||||
message.warning("请先完成配置");
|
||||
openConfigDialog(true);
|
||||
return null;
|
||||
}
|
||||
return { text, config: { ...config, model, count: "1" }, references: [...references] };
|
||||
};
|
||||
|
||||
const runGenerationSlot = async (index: number, snapshot: { text: string; config: AiConfig; references: ReferenceImage[] }) => {
|
||||
const itemStartedAt = performance.now();
|
||||
try {
|
||||
const result = snapshot.references.length ? await requestEdit(snapshot.config, snapshot.text, snapshot.references) : await requestGeneration(snapshot.config, snapshot.text);
|
||||
const image = result[0];
|
||||
if (!image) throw new Error("接口没有返回图片");
|
||||
const meta = await readImageMeta(image.dataUrl);
|
||||
const nextImage = { id: image.id, dataUrl: image.dataUrl, durationMs: performance.now() - itemStartedAt, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(image.dataUrl) };
|
||||
setResults((value) => updateResultAt(value, index, { status: "success", image: nextImage }));
|
||||
return nextImage;
|
||||
} catch (error) {
|
||||
setResults((value) => updateResultAt(value, index, { status: "failed", error: error instanceof Error ? error.message : "生成失败" }));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const retryResult = (index: number) => {
|
||||
const snapshot = buildRequestSnapshot();
|
||||
if (!snapshot) return;
|
||||
setPreviewLog(null);
|
||||
setResults((value) => updateResultAt(value, index, { status: "pending", error: undefined, image: undefined }));
|
||||
void runGenerationSlot(index, snapshot).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-stone-50 text-stone-900 dark:bg-stone-950 dark:text-stone-100">
|
||||
<main className="grid min-h-0 flex-1 grid-cols-1 gap-3 overflow-y-auto p-3 lg:grid-cols-[300px_minmax(0,1fr)] lg:overflow-hidden xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<aside className="thin-scrollbar hidden min-h-0 overflow-y-auto rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:block">
|
||||
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={(log) => void previewGenerationLog(log)} />
|
||||
</aside>
|
||||
|
||||
<section className="grid gap-3 lg:min-h-0 lg:overflow-hidden xl:grid-cols-[420px_minmax(0,1fr)]">
|
||||
<div className="thin-scrollbar flex flex-col rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto">
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100">生图工作台</h1>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2 lg:hidden">
|
||||
<Button icon={<History className="size-4" />} onClick={() => setLogsOpen(true)}>记录</Button>
|
||||
<Button icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>参数</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-5">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<span className="text-base font-semibold">提示词</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" icon={<BookOpen className="size-3.5" />} onClick={() => setPromptDialogOpen(true)}>查看提示词库</Button>
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>查看我的素材</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input.TextArea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
rows={7}
|
||||
placeholder="描述画面主体、风格、构图、光线和用途"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<span className="text-base font-semibold">参考图</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" icon={<ClipboardPaste className="size-3.5" />} onClick={() => void addReferencesFromClipboard()}>剪切板</Button>
|
||||
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>上传</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700" onWheel={(event) => {
|
||||
if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.scrollLeft += event.deltaY;
|
||||
}}>
|
||||
{references.map((item) => (
|
||||
<div key={item.id} className="group relative size-20 shrink-0 overflow-hidden rounded-md border border-stone-200 dark:border-stone-800">
|
||||
<img src={item.dataUrl} alt={item.name} className="size-full object-cover" />
|
||||
<button type="button" className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" onClick={() => setReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label="移除参考图">
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">暂无参考图</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden">
|
||||
<span className="truncate text-stone-500 dark:text-stone-400">{model} · {config.size} · {config.quality}</span>
|
||||
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>调整</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden gap-4 sm:grid sm:grid-cols-2">
|
||||
<GenerationSettings config={config} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-6">
|
||||
<Button type="primary" size="large" block icon={<Sparkles className="size-4" />} loading={running} disabled={!canGenerate || running} onClick={() => void generate()}>
|
||||
开始生成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="thin-scrollbar rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto lg:p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">生成结果</h2>
|
||||
</div>
|
||||
{running ? <Tag className="m-0 px-2 py-1">等待 {formatDuration(elapsedMs)}</Tag> : null}
|
||||
</div>
|
||||
{results.length ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
||||
{results.map((result, index) => (
|
||||
result.status === "success" && result.image ? (
|
||||
<ResultImageCard key={result.id} image={result.image} index={index} onEdit={addResultToReferences} onDownload={downloadImage} onSaveAsset={saveResultToAssets} />
|
||||
) : result.status === "failed" ? (
|
||||
<FailedImageCard key={result.id} error={result.error || "生成失败"} onRetry={() => retryResult(index)} />
|
||||
) : (
|
||||
<PendingImageCard key={result.id} />
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed border-stone-300 text-center dark:border-stone-700 lg:min-h-[560px]">
|
||||
<ImagePlus className="mb-4 size-11 text-stone-400" />
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有生成图片" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={(event) => {
|
||||
void addReferences(event.target.files);
|
||||
event.target.value = "";
|
||||
}} />
|
||||
<Drawer title="生成记录" placement="bottom" size="large" open={logsOpen} onClose={() => setLogsOpen(false)}>
|
||||
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={(log) => void previewGenerationLog(log)} />
|
||||
</Drawer>
|
||||
<Drawer title="参数" placement="bottom" size="default" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<GenerationSettings config={config} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
|
||||
</div>
|
||||
</Drawer>
|
||||
<PromptSelectDialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen} onSelect={setPrompt} />
|
||||
<AssetPickerModal open={assetPickerOpen} defaultTab="my-assets" onInsert={(payload) => void insertPickedAsset(payload)} onClose={() => setAssetPickerOpen(false)} />
|
||||
<Modal title="删除生成记录" open={deleteConfirmOpen} onCancel={() => setDeleteConfirmOpen(false)} onOk={deleteSelectedLogs} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除选中的 {selectedLogIds.length} 条生成记录吗?
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GenerationSettings({ config, model, updateConfig, openConfigDialog }: { config: AiConfig; model: string; updateConfig: UpdateAiConfig; openConfigDialog: (shouldPromptContinue?: boolean) => void }) {
|
||||
return (
|
||||
<>
|
||||
<label className="col-span-2 block min-w-0 sm:col-span-1">
|
||||
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base">模型</span>
|
||||
<ModelPicker config={config} value={model} onChange={(value) => updateConfig("imageModel", value)} fullWidth onMissingConfig={() => openConfigDialog(false)} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base">生成次数</span>
|
||||
<InputNumber className="canvas-control-number !w-full" min={1} max={10} value={Number(config.count) || 1} onChange={(value) => updateConfig("count", String(value || 1))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base">尺寸</span>
|
||||
<AutoComplete className="canvas-control-select w-full" value={config.size} options={sizeOptions} placeholder="例如 1:1、3:2" onChange={(value) => updateConfig("size", value)} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base">质量</span>
|
||||
<Select className="canvas-control-select w-full" value={config.quality} options={qualityOptions} onChange={(value) => updateConfig("quality", value)} />
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultImageCard({ image, index, onEdit, onDownload, onSaveAsset }: { image: GeneratedImage; index: number; onEdit: (image: GeneratedImage, index: number) => void; onDownload: (image: GeneratedImage, index: number) => void; onSaveAsset: (image: GeneratedImage, index: number) => void }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
|
||||
<Image src={image.dataUrl} alt={`生成结果 ${index + 1}`} className="aspect-square object-cover" />
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-stone-200 px-3 py-2.5 dark:border-stone-800">
|
||||
<div className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 text-xs text-stone-500 dark:text-stone-400">
|
||||
<span>{image.width}x{image.height}</span>
|
||||
<span>{formatBytes(image.bytes)}</span>
|
||||
<span>{formatDuration(image.durationMs)}</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => void onSaveAsset(image, index)}>添加到素材</Button>
|
||||
<Button size="small" icon={<PenLine className="size-3.5" />} onClick={() => void onEdit(image, index)}>加入参考图</Button>
|
||||
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(image, index)}>下载</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingImageCard() {
|
||||
return (
|
||||
<div className="relative aspect-square overflow-hidden rounded-lg border border-dashed border-stone-300 bg-stone-50 dark:border-stone-700 dark:bg-stone-900">
|
||||
<div
|
||||
className="absolute inset-0 opacity-60"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(circle, rgba(120,113,108,0.35) 1.4px, transparent 1.6px)",
|
||||
backgroundSize: "16px 16px",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-sm text-stone-500 dark:text-stone-400">
|
||||
<LoaderCircle className="size-6 animate-spin" />
|
||||
<span>生成中</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FailedImageCard({ error, onRetry }: { error: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-red-200 bg-red-50 dark:border-red-950 dark:bg-red-950/20">
|
||||
<div className="flex aspect-square flex-col items-center justify-center gap-3 p-5 text-center">
|
||||
<div className="text-sm font-medium text-red-600 dark:text-red-300">生成失败</div>
|
||||
<Typography.Paragraph ellipsis={{ rows: 4 }} className="!mb-0 !text-xs !text-red-500 dark:!text-red-300">
|
||||
{error}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-red-200 p-3 dark:border-red-950">
|
||||
<Button size="small" danger onClick={onRetry}>重试</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function updateResultAt(results: GenerationResult[], index: number, next: Partial<GenerationResult>) {
|
||||
return results.map((item, itemIndex) => itemIndex === index ? { ...item, ...next } : item);
|
||||
}
|
||||
|
||||
function LogPanel({ logs, selectedLogIds, activeLogId, onSelectedLogIdsChange, onCreateSession, onDeleteSelected, onPreviewLog }: { logs: GenerationLog[]; selectedLogIds: string[]; activeLogId?: string; onSelectedLogIdsChange: (ids: string[]) => void; onCreateSession: () => void; onDeleteSelected: () => void; onPreviewLog: (log: GenerationLog) => void }) {
|
||||
const allSelected = Boolean(logs.length) && selectedLogIds.length === logs.length;
|
||||
const toggleAll = () => onSelectedLogIdsChange(allSelected ? [] : logs.map((log) => log.id));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">生成记录</h2>
|
||||
</div>
|
||||
<Tag className="m-0">{logs.length}</Tag>
|
||||
</div>
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreateSession}>新建</Button>
|
||||
<Button size="small" icon={<CheckSquare className="size-3.5" />} disabled={!logs.length} onClick={toggleAll}>{allSelected ? "取消" : "全选"}</Button>
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} disabled={!selectedLogIds.length} onClick={onDeleteSelected}>删除</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{logs.map((log) => (
|
||||
<LogCard
|
||||
key={log.id}
|
||||
log={log}
|
||||
selected={selectedLogIds.includes(log.id)}
|
||||
active={activeLogId === log.id}
|
||||
onSelectedChange={(checked) => onSelectedLogIdsChange(checked ? [...selectedLogIds, log.id] : selectedLogIds.filter((id) => id !== log.id))}
|
||||
onClick={() => onPreviewLog(log)}
|
||||
/>
|
||||
))}
|
||||
{!logs.length ? (
|
||||
<div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed border-stone-300 text-center text-sm text-stone-500 dark:border-stone-700">
|
||||
暂无生成记录
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className={`block w-full rounded-lg border p-2 text-left transition ${active ? "border-stone-900 bg-blue-50 dark:border-stone-100 dark:bg-blue-950/20" : "border-stone-200 bg-background hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900"}`} onClick={onClick}>
|
||||
<div className="grid grid-cols-[minmax(128px,1fr)_auto] gap-2">
|
||||
<div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-2">
|
||||
<Checkbox className="mt-0.5" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelectedChange(event.target.checked)} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-5">{log.title}</div>
|
||||
{log.thumbnails?.length ? (
|
||||
<div className="mt-2 flex gap-1 overflow-hidden">
|
||||
{log.thumbnails.slice(0, 4).map((image, index) => <img key={`${log.id}-${index}`} src={image} alt="" className="size-8 shrink-0 rounded-md object-cover" />)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid justify-items-end gap-2">
|
||||
<div className="flex gap-1">
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="blue">成功 {log.successCount ?? log.imageCount}</Tag>
|
||||
{log.failCount ? <Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="red">失败 {log.failCount}</Tag> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.imageCount} 张</Tag>
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="green">{formatDuration(log.durationMs)}</Tag>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.time}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
async function readStoredLogs() {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const legacyValue = window.localStorage.getItem(LOG_STORE_KEY);
|
||||
if (legacyValue) {
|
||||
await Promise.all((JSON.parse(legacyValue) as GenerationLog[]).map((log) => logStore.setItem(log.id, normalizeLog(log))));
|
||||
window.localStorage.removeItem(LOG_STORE_KEY);
|
||||
}
|
||||
const legacyKeys = Array.from({ length: window.localStorage.length }, (_, index) => window.localStorage.key(index)).filter((key): key is string => Boolean(key?.startsWith(LOG_STORE_PREFIX)));
|
||||
await Promise.all(legacyKeys.map(async (key) => {
|
||||
try {
|
||||
const log = normalizeLog(JSON.parse(window.localStorage.getItem(key) || ""));
|
||||
await logStore.setItem(log.id, log);
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {}
|
||||
}));
|
||||
|
||||
const logs: GenerationLog[] = [];
|
||||
await logStore.iterate<GenerationLog, void>((value) => {
|
||||
logs.push(normalizeLog(value));
|
||||
});
|
||||
return logs
|
||||
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLog(log: Partial<GenerationLog>): GenerationLog {
|
||||
return {
|
||||
id: log.id || createId(),
|
||||
createdAt: log.createdAt || Date.now(),
|
||||
title: log.title || log.model || "未命名",
|
||||
time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }),
|
||||
model: log.model || "",
|
||||
durationMs: log.durationMs || 0,
|
||||
successCount: log.successCount ?? log.imageCount ?? 0,
|
||||
failCount: log.failCount || 0,
|
||||
imageCount: log.imageCount || log.successCount || 0,
|
||||
size: log.size || "",
|
||||
quality: log.quality || "",
|
||||
status: log.status || "成功",
|
||||
thumbnails: log.thumbnails || [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildLog({ prompt, model, config, durationMs, successCount, failCount, status, thumbnails }: { prompt: string; model: string; config: { size: string; quality: string; count?: string }; durationMs: number; successCount: number; failCount: number; status: GenerationLog["status"]; thumbnails: string[] }): GenerationLog {
|
||||
return {
|
||||
id: createId(),
|
||||
createdAt: Date.now(),
|
||||
title: prompt.slice(0, 12) || "未命名",
|
||||
time: new Date().toLocaleString("zh-CN", { hour12: false }),
|
||||
model,
|
||||
durationMs,
|
||||
successCount,
|
||||
failCount,
|
||||
imageCount: Number(config.count) || successCount,
|
||||
size: config.size,
|
||||
quality: config.quality,
|
||||
status,
|
||||
thumbnails,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
|
||||
export default function UserLayout({ children }: { children: ReactNode }) {
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { LockOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { App, Button, Form, Input } from "antd";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
type LoginFormValues = {
|
||||
username: string;
|
||||
password: string;
|
||||
confirmPassword?: string;
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginContent() {
|
||||
const { message } = App.useApp();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const login = useUserStore((state) => state.login);
|
||||
const isLoading = useUserStore((state) => state.isLoading);
|
||||
const redirect = searchParams.get("redirect") || "/";
|
||||
|
||||
const submit = async (values: LoginFormValues) => {
|
||||
try {
|
||||
const user = await login({ username: values.username, password: values.password });
|
||||
message.success("登录成功");
|
||||
router.replace(redirect.startsWith("/") ? redirect : "/");
|
||||
router.refresh();
|
||||
if (user.role !== "admin") router.replace("/");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "登录失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]">
|
||||
<section className="w-full max-w-[420px]">
|
||||
<div className="mb-7 text-center">
|
||||
<span
|
||||
className="mx-auto mb-4 block size-12 bg-stone-950 dark:bg-stone-100"
|
||||
style={{
|
||||
mask: "url(/logo.svg) center / contain no-repeat",
|
||||
WebkitMask: "url(/logo.svg) center / contain no-repeat",
|
||||
}}
|
||||
aria-label="无限画布"
|
||||
/>
|
||||
<h1 className="text-3xl font-semibold tracking-normal text-stone-950 dark:text-stone-100">管理员登录</h1>
|
||||
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400">当前暂时关闭注册,仅允许管理员账号登录。</p>
|
||||
</div>
|
||||
|
||||
<Form<LoginFormValues> layout="vertical" size="large" requiredMark={false} onFinish={submit}>
|
||||
<Form.Item name="username" label={<span className="font-medium text-stone-800 dark:text-stone-200">用户名</span>} rules={[{ required: true, message: "请输入用户名" }]}>
|
||||
<Input prefix={<UserOutlined />} autoComplete="username" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label={<span className="font-medium text-stone-800 dark:text-stone-200">密码</span>} rules={[{ required: true, message: "请输入密码" }]}>
|
||||
<Input.Password prefix={<LockOutlined />} autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Button block type="primary" htmlType="submit" loading={isLoading}>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { App, Button, Image, Tag } from "antd";
|
||||
|
||||
import { fetchPrompts, type Prompt } from "@/services/api/prompts";
|
||||
import { navigationTools } from "@/lib/navigation-tools";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Highlighter({
|
||||
action,
|
||||
color,
|
||||
children,
|
||||
}: {
|
||||
action: "highlight" | "underline";
|
||||
color: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span className="relative inline-block px-1">
|
||||
{action === "highlight" ? (
|
||||
<span className="absolute inset-x-0 bottom-0 top-1 rounded-sm opacity-45" style={{ backgroundColor: color }} />
|
||||
) : (
|
||||
<span className="absolute inset-x-0 bottom-0 h-1 rounded-full opacity-80" style={{ backgroundColor: color }} />
|
||||
)}
|
||||
<span className="relative font-medium text-stone-800 dark:text-stone-200">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IndexPage() {
|
||||
const { message } = App.useApp();
|
||||
const [primaryTool] = navigationTools;
|
||||
const [promptShowcase, setPromptShowcase] = useState<Prompt[]>([]);
|
||||
const [previewIndex, setPreviewIndex] = useState(0);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPrompts({ pageSize: 12 }).then((data) => setPromptShowcase(data.items)).catch((error) => message.error(error instanceof Error ? error.message : "获取提示词失败"));
|
||||
}, [message]);
|
||||
|
||||
return (
|
||||
<main className="relative h-full overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] [background-size:16px_16px] text-stone-950 dark:bg-[radial-gradient(rgba(245,245,244,.18)_1px,transparent_1px)] dark:text-stone-100">
|
||||
<section className="relative mx-auto min-h-[calc(100vh-4rem)] max-w-7xl overflow-hidden px-6">
|
||||
<div className="pointer-events-none absolute left-[15%] top-24 size-20 rounded-full border border-dashed border-stone-200 dark:border-stone-800" />
|
||||
<div className="pointer-events-none absolute right-[23%] top-[48%] size-20 rounded-full border border-dashed border-stone-200 dark:border-stone-800" />
|
||||
|
||||
<div className="relative flex min-h-[620px] flex-col items-center justify-center pt-10 text-center">
|
||||
<h1 className="ai-title-aurora max-w-5xl text-balance text-5xl font-semibold tracking-normal sm:text-7xl lg:text-8xl">
|
||||
无限画布
|
||||
</h1>
|
||||
<p className="mt-8 max-w-3xl text-balance text-lg leading-8 text-stone-500 dark:text-stone-400">
|
||||
在<Highlighter action="underline" color="#FF9800">无限画布</Highlighter>中生成、连接和重组<Highlighter action="highlight" color="#87CEFA">图片、文字与图形</Highlighter>,让创作从单次生成变成连续推演。
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||
<Button type="primary" size="large" href={`/${primaryTool.slug}`} icon={<ArrowRight className="size-4" />} iconPlacement="end">
|
||||
开始使用
|
||||
</Button>
|
||||
<Button size="large" href="/canvas">
|
||||
打开画布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="relative mx-auto mb-20 max-w-6xl border-t border-stone-200 pt-12 dark:border-stone-800">
|
||||
<div className="mb-8 grid gap-4 md:grid-cols-[1fr_auto_1fr] md:items-start">
|
||||
<div />
|
||||
<div className="max-w-2xl text-center">
|
||||
<h2 className="text-3xl font-semibold text-stone-950 dark:text-stone-100">沉淀每一次好结果</h2>
|
||||
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400">
|
||||
收藏稳定出图的提示词、参考风格和结果图片,让下一次创作从已有经验开始。
|
||||
</p>
|
||||
</div>
|
||||
<Button type="link" href="/prompts" className="justify-self-center md:justify-self-end" icon={<ArrowRight className="size-4" />} iconPlacement="end">
|
||||
查看提示词库
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid auto-rows-[210px] gap-4 md:grid-cols-4">
|
||||
{promptShowcase.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPreviewIndex(index);
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
className={cn(
|
||||
"group relative cursor-pointer overflow-hidden border border-stone-200 bg-stone-100 text-left dark:border-stone-800 dark:bg-stone-900",
|
||||
index === 0 && "md:col-span-2 md:row-span-2",
|
||||
index === 3 && "md:col-span-2",
|
||||
)}
|
||||
>
|
||||
<img src={item.coverUrl} alt={item.title} className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]" />
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/35 to-transparent p-4 text-white">
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{item.tags.slice(0, 2).map((tag) => (
|
||||
<Tag key={tag} variant="filled" className="m-0 bg-white/15 text-[11px] text-white backdrop-blur">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<h3 className="text-sm font-medium">{item.title}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-white/75">{item.prompt}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<Image.PreviewGroup
|
||||
preview={{
|
||||
open: previewOpen,
|
||||
current: previewIndex,
|
||||
onOpenChange: setPreviewOpen,
|
||||
onChange: setPreviewIndex,
|
||||
}}
|
||||
>
|
||||
<div className="hidden">
|
||||
{promptShowcase.map((item) => <Image key={item.id} src={item.coverUrl} alt={item.title} />)}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { FolderPlus, Search } from "lucide-react";
|
||||
import { type UIEvent, useEffect, useState } from "react";
|
||||
import { App, Button, Empty, Input, Spin, Tag } from "antd";
|
||||
|
||||
import { PromptCard } from "@/components/prompts/prompt-card";
|
||||
import { PromptDetailDialog } from "@/components/prompts/prompt-detail-dialog";
|
||||
import { usePromptList } from "@/components/prompts/use-prompt-list";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { ALL_PROMPTS_OPTION, type Prompt } from "@/services/api/prompts";
|
||||
|
||||
export default function PromptsPage() {
|
||||
const { message } = App.useApp();
|
||||
const [titleKeyword, setTitleKeyword] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
|
||||
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory });
|
||||
|
||||
useEffect(() => {
|
||||
if (query.isError) {
|
||||
message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
|
||||
}
|
||||
}, [message, query.error, query.isError]);
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
|
||||
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]);
|
||||
};
|
||||
|
||||
const copyPrompt = async (prompt: string) => {
|
||||
await navigator.clipboard.writeText(prompt);
|
||||
message.success("提示词已复制");
|
||||
};
|
||||
|
||||
const savePromptAsset = (item: Prompt) => {
|
||||
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
|
||||
message.success("已加入我的素材");
|
||||
};
|
||||
|
||||
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) {
|
||||
void query.fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
|
||||
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]" onScroll={handleListScroll}>
|
||||
<div className="pb-8">
|
||||
<div className="mx-auto max-w-5xl text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">提示词中心</h1>
|
||||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">共 {totalPrompts} 条提示词,按标题、标签与分类快速查找灵感。</p>
|
||||
</div>
|
||||
{query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null}
|
||||
{!query.isLoading ? (
|
||||
<>
|
||||
<div className="mx-auto mt-8 w-full max-w-2xl">
|
||||
<Input size="large" className="w-full" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder="按标题查询" onChange={(event) => setTitleKeyword(event.target.value)} />
|
||||
</div>
|
||||
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
|
||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
||||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">分类</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{promptCategoryOptions.map((category) => (
|
||||
<Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}>
|
||||
{category}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
||||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">标签</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{promptTags.map((tag) => (
|
||||
<Tag.CheckableTag key={tag} checked={tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)} className={cn("prompt-filter-tag", (tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)) && "is-active")} onChange={() => toggleTag(tag)}>
|
||||
{tag}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!query.isLoading ? (
|
||||
<div>
|
||||
<div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{promptItems.map((item) => (
|
||||
<PromptCard key={item.id} item={item} onOpen={() => setSelectedPrompt(item)} onCopy={() => void copyPrompt(item.prompt)} extraAction={<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>加入我的素材</Button>} />
|
||||
))}
|
||||
</div>
|
||||
{promptItems.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-16" /> : null}
|
||||
<div className="mx-auto mt-6 max-w-7xl text-center text-xs text-stone-500 dark:text-stone-400">{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => void copyPrompt(prompt)} onSaveAsset={savePromptAsset} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1,443 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-border: var(--border);
|
||||
--color-ring: var(--ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-input: var(--input);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--border: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--input: oklch(0.922 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--admin-card-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--admin-card-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
textarea {
|
||||
resize: none;
|
||||
}
|
||||
textarea::-webkit-resizer {
|
||||
display: none;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.ai-title-aurora {
|
||||
background:
|
||||
linear-gradient(90deg, #111827 0%, #111827 36%, #6b7280 54%, #111827 72%, #111827 100%);
|
||||
background-size: 220% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: ai-title-aurora 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dark .ai-title-aurora {
|
||||
background:
|
||||
linear-gradient(90deg, #fafaf9 0%, #fafaf9 36%, #a8a29e 54%, #fafaf9 72%, #fafaf9 100%);
|
||||
background-size: 220% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
@keyframes ai-title-aurora {
|
||||
0%, 100% {
|
||||
background-position: 0% 50%;
|
||||
filter: drop-shadow(0 0 0 rgba(0, 0, 0, 0));
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
filter: drop-shadow(0 12px 28px rgba(28, 25, 23, 0.08));
|
||||
}
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.prompt-filter-tag.ant-tag-checkable {
|
||||
margin: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
color: #57534e;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
transition: background .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease;
|
||||
}
|
||||
|
||||
.prompt-filter-tag.ant-tag-checkable:hover {
|
||||
background: rgba(28, 25, 23, 0.06);
|
||||
color: #1c1917;
|
||||
}
|
||||
|
||||
.prompt-filter-tag.ant-tag-checkable.is-active,
|
||||
.prompt-filter-tag.ant-tag-checkable-checked {
|
||||
background: #111111;
|
||||
border-color: #111111;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark .prompt-filter-tag.ant-tag-checkable {
|
||||
color: #e7e5e4;
|
||||
}
|
||||
|
||||
.dark .prompt-filter-tag.ant-tag-checkable:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.dark .prompt-filter-tag.ant-tag-checkable.is-active,
|
||||
.dark .prompt-filter-tag.ant-tag-checkable-checked {
|
||||
background: rgba(250, 250, 249, 0.96);
|
||||
border-color: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18), 0 6px 18px rgba(0, 0, 0, 0.22);
|
||||
color: #0c0a09 !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.thin-scrollbar {
|
||||
scrollbar-color: rgba(120, 113, 108, 0.45) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(120, 113, 108, 0.45);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.hover-scrollbar {
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.hover-scrollbar:hover {
|
||||
scrollbar-color: rgba(120, 113, 108, 0.5) transparent;
|
||||
}
|
||||
|
||||
.hover-scrollbar::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.hover-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.hover-scrollbar:hover::-webkit-scrollbar-track {
|
||||
background: rgba(120, 113, 108, 0.12);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.hover-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(120, 113, 108, 0.5);
|
||||
}
|
||||
|
||||
.hover-scrollbar-hint {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hover-scrollbar-hint::after {
|
||||
background: rgba(120, 113, 108, 0.42);
|
||||
border-radius: 999px;
|
||||
bottom: 4px;
|
||||
content: "";
|
||||
height: 3px;
|
||||
left: 12px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.hover-scrollbar-hint:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.canvas-control-select.ant-select .ant-select-selector,
|
||||
.canvas-control-number.ant-input-number {
|
||||
border-color: rgba(120, 113, 108, 0.32) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.canvas-compact-control.canvas-control-select.ant-select {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selector,
|
||||
.canvas-compact-control.canvas-control-number.ant-input-number {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-item,
|
||||
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-placeholder {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-item,
|
||||
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-placeholder,
|
||||
.canvas-compact-control.canvas-control-select.ant-select .ant-select-selection-search-input,
|
||||
.canvas-compact-control.canvas-control-number.ant-input-number .ant-input-number-input {
|
||||
height: 100% !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.canvas-control-select.ant-select-focused .ant-select-selector,
|
||||
.canvas-control-number.ant-input-number-focused {
|
||||
border-color: #a8a29e !important;
|
||||
}
|
||||
|
||||
.dark .canvas-control-select.ant-select .ant-select-selector,
|
||||
.dark .canvas-control-number.ant-input-number {
|
||||
border-color: rgba(214, 211, 209, 0.2) !important;
|
||||
}
|
||||
|
||||
.dark .canvas-control-select.ant-select-focused .ant-select-selector,
|
||||
.dark .canvas-control-number.ant-input-number-focused {
|
||||
border-color: #78716c !important;
|
||||
}
|
||||
|
||||
.canvas-composer-mode.ant-segmented .ant-segmented-item-label {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 30px;
|
||||
justify-content: center;
|
||||
padding-inline: 9px;
|
||||
}
|
||||
|
||||
.canvas-image-settings-popover .ant-popover-inner {
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 18px 54px rgba(28, 25, 23, 0.16);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.canvas-image-settings-popover .ant-segmented {
|
||||
border-radius: 999px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.canvas-image-settings-popover .ant-segmented-item {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.dark .canvas-image-settings-popover .ant-popover-inner {
|
||||
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.canvas-node-info-modal .ant-modal-header {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.canvas-node-info-modal .ant-modal-title {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.canvas-node-info-modal .ant-modal-close {
|
||||
top: 21px;
|
||||
}
|
||||
|
||||
.admin-layout .ant-layout-sider {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.admin-layout .ant-layout-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.admin-layout .ant-layout-sider-children > .ant-flex:first-child {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.admin-logo {
|
||||
background: currentColor;
|
||||
display: inline-block;
|
||||
height: 32px;
|
||||
mask: url(/logo.svg) center / contain no-repeat;
|
||||
width: 32px;
|
||||
-webkit-mask: url(/logo.svg) center / contain no-repeat;
|
||||
}
|
||||
|
||||
.admin-layout .ant-layout-sider-children > .ant-flex:last-child {
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.admin-layout .ant-card,
|
||||
.admin-layout .ant-pro-card {
|
||||
box-shadow: var(--admin-card-shadow);
|
||||
}
|
||||
|
||||
.admin-layout .ant-table-cell .ant-btn-icon-only.ant-btn-sm {
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
}
|
||||
|
||||
@keyframes canvas-batch-child-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(var(--batch-from-x), var(--batch-from-y)) rotate(var(--batch-from-rotate)) scale(0.72);
|
||||
}
|
||||
|
||||
72% {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) rotate(-1deg) scale(1.015);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) rotate(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes canvas-batch-child-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) rotate(0) scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate(var(--batch-from-x), var(--batch-from-y)) rotate(var(--batch-from-rotate)) scale(0.72);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
::view-transition-old(root), ::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AntThemeProvider } from "@/components/ant-theme-provider";
|
||||
import { QueryProvider } from "@/components/query-provider";
|
||||
import { ThemeSync } from "@/components/theme-sync";
|
||||
import { UserSessionSync } from "@/components/user-session-sync";
|
||||
import "antd/dist/reset.css";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "无限画布",
|
||||
description: "一个无限画布创作工具",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning className="font-sans">
|
||||
<body
|
||||
className="bg-background text-foreground antialiased"
|
||||
style={{
|
||||
fontFamily:
|
||||
'"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
|
||||
}}
|
||||
>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `try{var s=JSON.parse(localStorage.getItem("infinite-canvas:theme_store")||"{}");var t=s.state&&s.state.theme==="light"?"light":"dark";document.documentElement.classList.toggle("dark",t==="dark");document.documentElement.style.colorScheme=t}catch(e){}`,
|
||||
}}
|
||||
/>
|
||||
<ThemeSync />
|
||||
<AntThemeProvider>
|
||||
<QueryProvider>
|
||||
<UserSessionSync />
|
||||
{children}
|
||||
</QueryProvider>
|
||||
</AntThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Home, LogIn } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<AppShell>
|
||||
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 text-stone-900 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)] dark:text-stone-100">
|
||||
<section className="w-full max-w-md text-center">
|
||||
<div className="mx-auto mb-6 flex size-16 items-center justify-center rounded-lg border border-stone-200 bg-white text-2xl font-semibold shadow-sm dark:border-stone-800 dark:bg-stone-900">
|
||||
404
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold tracking-normal">页面不存在</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-stone-500 dark:text-stone-400">
|
||||
这个地址没有对应的页面,可能已经移动或被合并到其他入口。
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||
<Link href="/" className="inline-flex h-10 items-center gap-2 rounded-lg bg-stone-950 px-4 text-sm font-medium text-white transition hover:bg-stone-800 dark:bg-stone-100 dark:text-stone-950 dark:hover:bg-stone-200">
|
||||
<Home className="size-4" />
|
||||
返回首页
|
||||
</Link>
|
||||
<Link href="/login" className="inline-flex h-10 items-center gap-2 rounded-lg border border-stone-200 bg-background px-4 text-sm font-medium text-stone-900 transition hover:bg-stone-100 dark:border-stone-800 dark:text-stone-100 dark:hover:bg-stone-800">
|
||||
<LogIn className="size-4" />
|
||||
去登录
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { ProConfigProvider } from "@ant-design/pro-components";
|
||||
import { App, ConfigProvider, theme as antdTheme } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
|
||||
export function AntThemeProvider({ children }: { children: ReactNode }) {
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const dark = theme === "dark";
|
||||
const colors = dark
|
||||
? {
|
||||
bg: "#1f1d1a",
|
||||
layout: "#181715",
|
||||
panel: "#24211e",
|
||||
elevated: "#292524",
|
||||
fill: "#322e29",
|
||||
fillHover: "#3a3631",
|
||||
border: "#44403c",
|
||||
borderSoft: "rgba(214, 211, 209, 0.18)",
|
||||
text: "#f5f5f4",
|
||||
textSecondary: "#d6d3d1",
|
||||
textTertiary: "#a8a29e",
|
||||
primary: "#f5f5f4",
|
||||
primaryText: "#1c1917",
|
||||
menuSelected: "#3a3631",
|
||||
tableHeader: "#2b2521",
|
||||
}
|
||||
: {
|
||||
bg: "#fbfaf7",
|
||||
layout: "#f4f2ed",
|
||||
panel: "#ffffff",
|
||||
elevated: "#ffffff",
|
||||
fill: "#f5f5f4",
|
||||
fillHover: "#e7e5df",
|
||||
border: "#d6d3ca",
|
||||
borderSoft: "rgba(87, 83, 78, 0.18)",
|
||||
text: "#292524",
|
||||
textSecondary: "#57534e",
|
||||
textTertiary: "#78716c",
|
||||
primary: "#111111",
|
||||
primaryText: "#ffffff",
|
||||
menuSelected: "#f5f5f4",
|
||||
tableHeader: "#f8fafc",
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: colors.primary,
|
||||
colorPrimaryHover: dark ? "#ffffff" : "#2a2a2a",
|
||||
colorPrimaryActive: dark ? "#e7e5e4" : "#000000",
|
||||
colorInfo: colors.primary,
|
||||
colorBgBase: colors.bg,
|
||||
colorBgLayout: colors.layout,
|
||||
colorBgContainer: colors.panel,
|
||||
colorBgElevated: colors.elevated,
|
||||
colorFill: colors.fill,
|
||||
colorFillSecondary: colors.fill,
|
||||
colorFillTertiary: colors.fillHover,
|
||||
colorFillQuaternary: dark ? "rgba(245, 245, 244, 0.08)" : "rgba(28, 25, 23, 0.04)",
|
||||
colorBorder: colors.border,
|
||||
colorBorderSecondary: colors.borderSoft,
|
||||
colorSplit: colors.borderSoft,
|
||||
colorText: colors.text,
|
||||
colorTextBase: colors.text,
|
||||
colorTextSecondary: colors.textSecondary,
|
||||
colorTextTertiary: colors.textTertiary,
|
||||
colorTextQuaternary: dark ? "#78716c" : "#a8a29e",
|
||||
colorIcon: colors.textSecondary,
|
||||
colorIconHover: colors.text,
|
||||
colorLink: colors.text,
|
||||
colorLinkHover: colors.text,
|
||||
colorBgSpotlight: dark ? "#f5f5f4" : "#1c1917",
|
||||
colorTextLightSolid: dark ? "#1c1917" : "#ffffff",
|
||||
colorError: "#ff4d4f",
|
||||
colorErrorHover: "#ff7875",
|
||||
colorErrorActive: "#d9363e",
|
||||
colorWarning: "#faad14",
|
||||
colorBgMask: dark ? "rgba(0, 0, 0, 0.62)" : "rgba(28, 25, 23, 0.35)",
|
||||
borderRadius: 8,
|
||||
borderRadiusLG: 12,
|
||||
boxShadow: dark ? "0 18px 48px rgba(0, 0, 0, 0.46)" : "0 18px 48px rgba(41, 37, 36, 0.16)",
|
||||
boxShadowSecondary: dark ? "0 0 0 1px rgba(255,255,255,.06)" : "0 1px 2px rgba(15,23,42,.04)",
|
||||
fontFamily: '"SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
primaryColor: colors.primaryText,
|
||||
defaultColor: colors.text,
|
||||
defaultBg: dark ? "#1f1d1a" : "#ffffff",
|
||||
defaultBorderColor: colors.border,
|
||||
defaultHoverColor: colors.text,
|
||||
defaultHoverBg: colors.fillHover,
|
||||
defaultHoverBorderColor: dark ? "#78716c" : "#a8a29e",
|
||||
dangerColor: "#ffffff",
|
||||
primaryShadow: "none",
|
||||
defaultShadow: "none",
|
||||
dangerShadow: "none",
|
||||
},
|
||||
Modal: {
|
||||
contentBg: colors.elevated,
|
||||
headerBg: colors.elevated,
|
||||
footerBg: colors.elevated,
|
||||
titleColor: colors.text,
|
||||
},
|
||||
Menu: {
|
||||
popupBg: colors.elevated,
|
||||
itemBg: colors.panel,
|
||||
itemColor: colors.textSecondary,
|
||||
itemHoverBg: colors.fillHover,
|
||||
itemHoverColor: colors.text,
|
||||
itemActiveBg: colors.menuSelected,
|
||||
itemSelectedBg: colors.menuSelected,
|
||||
itemSelectedColor: dark ? "#ffffff" : colors.text,
|
||||
darkItemBg: colors.panel,
|
||||
darkItemColor: colors.textSecondary,
|
||||
darkItemHoverBg: colors.fillHover,
|
||||
darkItemHoverColor: colors.text,
|
||||
darkItemSelectedBg: colors.menuSelected,
|
||||
darkItemSelectedColor: "#ffffff",
|
||||
},
|
||||
Layout: {
|
||||
bodyBg: colors.layout,
|
||||
headerBg: colors.panel,
|
||||
headerColor: colors.text,
|
||||
lightSiderBg: colors.panel,
|
||||
siderBg: colors.panel,
|
||||
},
|
||||
Card: {
|
||||
bodyPadding: 24,
|
||||
headerBg: colors.panel,
|
||||
headerHeight: 56,
|
||||
},
|
||||
Table: {
|
||||
borderColor: colors.borderSoft,
|
||||
cellPaddingBlockMD: 14,
|
||||
headerBg: colors.tableHeader,
|
||||
headerColor: colors.text,
|
||||
rowHoverBg: colors.fill,
|
||||
},
|
||||
Segmented: {
|
||||
trackBg: colors.fill,
|
||||
itemSelectedBg: colors.primary,
|
||||
itemSelectedColor: colors.primaryText,
|
||||
},
|
||||
Select: {
|
||||
selectorBg: dark ? "#1f1d1a" : "#ffffff",
|
||||
optionActiveBg: colors.fillHover,
|
||||
optionSelectedBg: dark ? "#3f3a35" : "#f5f5f4",
|
||||
optionSelectedColor: colors.text,
|
||||
activeBorderColor: dark ? "#78716c" : "#a8a29e",
|
||||
hoverBorderColor: dark ? "#57534e" : "#a8a29e",
|
||||
activeOutlineColor: "transparent",
|
||||
},
|
||||
Input: {
|
||||
activeBg: dark ? "#1f1d1a" : "#ffffff",
|
||||
hoverBg: dark ? "#1f1d1a" : "#ffffff",
|
||||
activeBorderColor: dark ? "#78716c" : "#a8a29e",
|
||||
hoverBorderColor: dark ? "#57534e" : "#a8a29e",
|
||||
activeShadow: "none",
|
||||
},
|
||||
InputNumber: {
|
||||
activeBg: dark ? "#1f1d1a" : "#ffffff",
|
||||
hoverBg: dark ? "#1f1d1a" : "#ffffff",
|
||||
activeBorderColor: dark ? "#78716c" : "#a8a29e",
|
||||
hoverBorderColor: dark ? "#57534e" : "#a8a29e",
|
||||
activeShadow: "none",
|
||||
},
|
||||
Tabs: {
|
||||
itemColor: colors.textSecondary,
|
||||
itemSelectedColor: colors.text,
|
||||
itemHoverColor: colors.text,
|
||||
inkBarColor: colors.text,
|
||||
},
|
||||
Checkbox: {
|
||||
colorPrimary: colors.primary,
|
||||
colorPrimaryHover: colors.primary,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ProConfigProvider dark={dark}>
|
||||
<App>{children}</App>
|
||||
</ProConfigProvider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { AppTopNav } from "@/components/app-top-nav";
|
||||
import { useAiConfigStore } from "@/stores/use-ai-config-store";
|
||||
import { navigationTools, type NavigationToolSlug } from "@/lib/navigation-tools";
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return <MainAppShell pathname={pathname}>{children}</MainAppShell>;
|
||||
}
|
||||
|
||||
function MainAppShell({ pathname, children }: { pathname: string; children: ReactNode }) {
|
||||
const config = useAiConfigStore((state) => state.config);
|
||||
const updateConfig = useAiConfigStore((state) => state.updateConfig);
|
||||
const slug = pathname.split("/").filter(Boolean)[0];
|
||||
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
|
||||
const isCanvasDetail = /^\/canvas\/[^/]+/.test(pathname);
|
||||
|
||||
return (
|
||||
<ShellFrame>
|
||||
<AppTopNav activeToolSlug={activeToolSlug} config={config} onConfigChange={updateConfig} hideHeader={isCanvasDetail} />
|
||||
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
|
||||
</ShellFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function ShellFrame({ children }: { children: ReactNode }) {
|
||||
return <div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { LogOut, Menu, Settings2, Shield } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { App, Button, Drawer, Form, Input, Modal } from "antd";
|
||||
|
||||
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { GitHubLink } from "@/components/github-link";
|
||||
import { UserStatusActions } from "@/components/user-status-actions";
|
||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||
import type { AiConfig } from "@/lib/ai-config";
|
||||
import { navigationTools, type NavigationToolSlug } from "@/lib/navigation-tools";
|
||||
import { fetchImageModels } from "@/services/api/image";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
type AppTopNavProps = {
|
||||
activeToolSlug?: NavigationToolSlug;
|
||||
config: AiConfig;
|
||||
onConfigChange: <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
||||
hideHeader?: boolean;
|
||||
};
|
||||
|
||||
export function AppTopNav({ activeToolSlug, config, onConfigChange, hideHeader = false }: AppTopNavProps) {
|
||||
const { message } = App.useApp();
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev";
|
||||
const isConfigOpen = useConfigDialogStore((state) => state.isOpen);
|
||||
const shouldPromptContinue = useConfigDialogStore((state) => state.shouldPromptContinue);
|
||||
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
|
||||
const setConfigDialogOpen = useConfigDialogStore((state) => state.setConfigDialogOpen);
|
||||
const clearPromptContinue = useConfigDialogStore((state) => state.clearPromptContinue);
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const isReady = useUserStore((state) => state.isReady);
|
||||
const logout = useUserStore((state) => state.clearSession);
|
||||
|
||||
const finishConfig = () => {
|
||||
setConfigDialogOpen(false);
|
||||
if (!config.baseUrl.trim() || !config.imageModel.trim() || !config.textModel.trim() || !config.apiKey.trim()) return;
|
||||
if (shouldPromptContinue) {
|
||||
message.success("配置已保存,请继续刚才的请求");
|
||||
} else {
|
||||
message.success("配置已保存");
|
||||
}
|
||||
clearPromptContinue();
|
||||
};
|
||||
const refreshModels = async () => {
|
||||
if (!config.baseUrl.trim() || !config.apiKey.trim()) {
|
||||
message.error("请先填写 Base URL 和 API Key");
|
||||
return;
|
||||
}
|
||||
setLoadingModels(true);
|
||||
try {
|
||||
const models = await fetchImageModels(config);
|
||||
onConfigChange("models", models);
|
||||
if (models.length && !models.includes(config.imageModel)) onConfigChange("imageModel", models[0]);
|
||||
if (models.length && !models.includes(config.textModel)) onConfigChange("textModel", models[0]);
|
||||
message.success("模型列表已更新");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取模型失败");
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hideHeader ? (
|
||||
<header className="sticky top-0 z-20 h-16 shrink-0 border-b border-stone-200 bg-background/90 backdrop-blur-xl dark:border-stone-800">
|
||||
<div className="mx-auto flex h-full max-w-7xl items-stretch justify-between gap-5 px-6">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex h-full shrink-0 items-center gap-2 text-sm font-semibold leading-none tracking-tight text-stone-950 transition hover:text-stone-600 dark:text-stone-100 dark:hover:text-stone-300"
|
||||
>
|
||||
<span
|
||||
className="size-5 shrink-0 bg-current"
|
||||
style={{
|
||||
mask: "url(/logo.svg) center / contain no-repeat",
|
||||
WebkitMask: "url(/logo.svg) center / contain no-repeat",
|
||||
}}
|
||||
/>
|
||||
<span className="text-base font-medium">无限画布</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="ml-3 inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 md:hidden dark:text-stone-300 dark:hover:text-white"
|
||||
onClick={() => setMobileNavOpen(true)}
|
||||
aria-label="打开导航菜单"
|
||||
title="导航菜单"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
|
||||
<nav className="hide-scrollbar ml-8 hidden h-16 min-w-0 items-center gap-7 overflow-x-auto md:flex">
|
||||
{navigationTools.map((tool) => {
|
||||
const Icon = tool.icon;
|
||||
const active = tool.slug === activeToolSlug;
|
||||
return (
|
||||
<Link
|
||||
key={tool.slug}
|
||||
href={`/${tool.slug}`}
|
||||
className={cn(
|
||||
"relative flex h-16 shrink-0 items-center gap-2 text-sm leading-6 transition after:absolute after:inset-x-0 after:bottom-0 after:h-px",
|
||||
active
|
||||
? "font-medium text-stone-950 after:bg-stone-950 dark:text-stone-100 dark:after:bg-stone-100"
|
||||
: "text-stone-500 after:bg-transparent hover:text-stone-950 dark:text-stone-400 dark:hover:text-stone-100",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span className="truncate">{tool.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="my-auto flex h-9 min-w-0 items-center justify-end gap-2 justify-self-end whitespace-nowrap">
|
||||
{isReady && user ? (
|
||||
<UserStatusActions
|
||||
version={appVersion}
|
||||
theme={theme}
|
||||
onThemeChange={setTheme}
|
||||
onOpenConfig={() => openConfigDialog(false)}
|
||||
userName={user.username}
|
||||
menuItems={[
|
||||
...(user.role === "admin" ? [{ key: "admin", icon: <Shield className="size-4" />, label: <Link href="/admin">管理后台</Link> }] : []),
|
||||
{ key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", onClick: logout },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4"
|
||||
onClick={() => openConfigDialog(false)}
|
||||
aria-label="配置"
|
||||
title="配置"
|
||||
>
|
||||
<Settings2 className="size-4" />
|
||||
</button>
|
||||
<AnimatedThemeToggler
|
||||
theme={theme}
|
||||
onThemeChange={setTheme}
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4"
|
||||
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
|
||||
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
|
||||
/>
|
||||
<span className="shrink-0 text-xs font-medium text-stone-500 dark:text-stone-400">{appVersion}</span>
|
||||
<GitHubLink />
|
||||
<Link href="/login" className="text-sm font-medium text-stone-600 underline-offset-4 transition hover:text-stone-950 hover:underline dark:text-stone-300 dark:hover:text-stone-100">
|
||||
登录
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
<Drawer
|
||||
title="导航"
|
||||
placement="left"
|
||||
size={280}
|
||||
open={mobileNavOpen}
|
||||
onClose={() => setMobileNavOpen(false)}
|
||||
className="md:hidden"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{navigationTools.map((tool) => {
|
||||
const Icon = tool.icon;
|
||||
const active = tool.slug === activeToolSlug;
|
||||
return (
|
||||
<Link
|
||||
key={tool.slug}
|
||||
href={`/${tool.slug}`}
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-3 text-base transition",
|
||||
active
|
||||
? "bg-stone-100 font-medium text-stone-950 dark:bg-stone-800 dark:text-stone-100"
|
||||
: "text-stone-600 hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
<span>{tool.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title={<div><div className="text-lg font-semibold">配置</div><div className="mt-1 text-xs font-normal text-stone-500">模型和密钥</div></div>}
|
||||
open={isConfigOpen}
|
||||
width={560}
|
||||
centered
|
||||
onCancel={() => setConfigDialogOpen(false)}
|
||||
footer={<Button type="primary" size="large" onClick={finishConfig}>完成</Button>}
|
||||
>
|
||||
<div className="pt-1">
|
||||
<Form layout="vertical" requiredMark={false} size="large">
|
||||
<Form.Item label="Base URL" className="mb-4">
|
||||
<Input value={config.baseUrl} onChange={(event) => onConfigChange("baseUrl", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="API Key" className="mb-4">
|
||||
<Input.Password value={config.apiKey} onChange={(event) => onConfigChange("apiKey", event.target.value)} />
|
||||
</Form.Item>
|
||||
<div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">模型列表</div>
|
||||
<div className="mt-1 text-xs text-stone-500">当前已保存 {config.models.length} 个模型</div>
|
||||
</div>
|
||||
<Button loading={loadingModels} onClick={() => void refreshModels()}>拉取模型列表</Button>
|
||||
</div>
|
||||
<Form.Item label="默认生图模型" className="mb-4">
|
||||
<ModelPicker config={config} value={config.imageModel} onChange={(model) => onConfigChange("imageModel", model)} fullWidth />
|
||||
</Form.Item>
|
||||
<Form.Item label="默认文本模型" className="mb-0">
|
||||
<ModelPicker config={config} value={config.textModel} onChange={(model) => onConfigChange("textModel", model)} fullWidth />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { GithubOutlined } from "@ant-design/icons";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type GitHubLinkProps = {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export function GitHubLink({ className, style }: GitHubLinkProps) {
|
||||
return (
|
||||
<a
|
||||
className={cn("inline-flex size-9 shrink-0 items-center justify-center rounded-full text-stone-600 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-white", className)}
|
||||
style={style}
|
||||
href="https://github.com/basketikun/infinite-canvas"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="GitHub"
|
||||
title="GitHub"
|
||||
>
|
||||
<GithubOutlined className="text-base" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user