小优化,产品愿景,视觉规范,交互和导航大版本
This commit is contained in:
206
art-agent/ENVIRONMENT.md
Normal file
206
art-agent/ENVIRONMENT.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# EPEEKit 开发环境说明
|
||||
|
||||
> 最后更新:2026-04-16
|
||||
|
||||
## 系统要求
|
||||
|
||||
| 项目 | 最低版本 | 备注 |
|
||||
|------|---------|------|
|
||||
| OS | Windows 10+ / macOS / Linux | 跨平台支持 |
|
||||
| Python | 3.12+ | 用于后端 |
|
||||
| Node.js | 22+ LTS | 用于前端 |
|
||||
| npm | 10+ | 随 Node.js 安装 |
|
||||
| Ollama | latest | Mem0 embedding 服务依赖,需启动 `nomic-embed-text` 模型 |
|
||||
|
||||
## 本地环境配置(local-env.json)
|
||||
|
||||
设备绑定的路径(如 Node.js 安装位置)**不硬编码在任何 Skill 或配置中**,
|
||||
统一通过 `.cursor/local-env.json` 管理。
|
||||
|
||||
| 字段 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `nodejs_path` | Node.js 安装目录(含 node/npm),为 `null` 表示已在系统 PATH | `C:\Users\xxx\AppData\Local\nodejs` |
|
||||
| `shell` | 当前设备的 shell 类型 | `powershell` / `bash` / `zsh` |
|
||||
|
||||
- **首次使用**:Agent 会自动探测并生成,也可手动复制 `.cursor/local-env.example.json` 并修改
|
||||
- **已 gitignored**:不进入版本控制,每台设备独立维护
|
||||
- **模板参考**:`.cursor/local-env.example.json`
|
||||
|
||||
### Node.js PATH 注意事项
|
||||
|
||||
如果 `nodejs_path` 不为 null,说明 Node.js 未在系统 PATH 中,启动前端前需注入:
|
||||
|
||||
```powershell
|
||||
# PowerShell(路径来自 local-env.json 的 nodejs_path)
|
||||
$env:PATH = "你的nodejs_path;" + $env:PATH
|
||||
```
|
||||
|
||||
```bash
|
||||
# bash/zsh
|
||||
export PATH="你的nodejs_path:$PATH"
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
EPEEAIKit/
|
||||
├── .cursor/
|
||||
│ ├── local-env.json # 本地环境配置(gitignored,设备绑定)
|
||||
│ ├── local-env.example.json # 本地环境配置模板(git tracked)
|
||||
│ ├── hooks.json # Cursor hooks 配置
|
||||
│ └── hooks/ # Hook 脚本(.ps1 + .sh 双版本)
|
||||
├── art-agent/
|
||||
│ ├── backend/ # FastAPI 后端(Python)
|
||||
│ │ ├── app/ # 应用代码
|
||||
│ │ ├── venv/ # Python 虚拟环境(gitignored)
|
||||
│ │ ├── requirements.txt # 直接依赖(锁定版本)
|
||||
│ │ └── requirements-lock.txt # 完整依赖树(pip freeze)
|
||||
│ ├── frontend/ # Next.js 前端(TypeScript)
|
||||
│ │ ├── src/ # 应用代码
|
||||
│ │ ├── node_modules/ # npm 依赖(gitignored)
|
||||
│ │ ├── package.json # 直接依赖
|
||||
│ │ └── package-lock.json # 完整依赖树锁定
|
||||
│ └── ENVIRONMENT.md # 本文件
|
||||
```
|
||||
|
||||
## 后端依赖(Python)
|
||||
|
||||
### 直接依赖
|
||||
|
||||
| 包名 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| fastapi | 0.135.3 | Web 框架 |
|
||||
| uvicorn | 0.44.0 | ASGI 服务器 |
|
||||
| openai | 2.32.0 | OpenAI / 向量引擎 LLM & 生图 API |
|
||||
| replicate | 1.0.7 | Replicate 生图 API(Flux/SDXL/Kolors 等) |
|
||||
| sse-starlette | 3.3.4 | Server-Sent Events 流式响应 |
|
||||
| python-multipart | 0.0.26 | 文件上传处理 |
|
||||
| httpx | 0.28.1 | 异步 HTTP 客户端 |
|
||||
| python-dotenv | 1.2.2 | 环境变量 `.env` 加载 |
|
||||
| Pillow | 12.2.0 | 图像处理 |
|
||||
| mem0ai | 1.0.11 | Mem0 长期记忆系统 |
|
||||
| ollama | 0.6.1 | Ollama 本地 LLM/embedding 客户端 |
|
||||
| sqlmodel | 0.0.38 | SQLModel ORM(SQLite 用户表) |
|
||||
| pwdlib[argon2,bcrypt] | 0.3.0 | 密码哈希(Argon2id + bcrypt 兼容) |
|
||||
| python-jose[cryptography] | 3.5.0 | JWT 认证 |
|
||||
|
||||
### 间接依赖(关键)
|
||||
|
||||
| 包名 | 版本 | 来源 |
|
||||
|------|------|------|
|
||||
| pydantic | 2.13.1 | fastapi 依赖 |
|
||||
| starlette | 1.0.0 | fastapi 依赖 |
|
||||
| SQLAlchemy | 2.0.49 | sqlmodel / mem0ai 依赖 |
|
||||
| qdrant-client | 1.17.1 | mem0ai 依赖(向量数据库) |
|
||||
| numpy | 2.4.4 | qdrant-client 依赖 |
|
||||
| grpcio | 1.80.0 | qdrant-client 依赖 |
|
||||
| cryptography | 46.0.7 | python-jose[cryptography] 依赖 |
|
||||
| argon2-cffi | 25.1.0 | pwdlib[argon2] 依赖 |
|
||||
| bcrypt | 5.0.0 | pwdlib[bcrypt] 依赖 |
|
||||
|
||||
完整依赖树见 `requirements-lock.txt`(共 61 个包)。
|
||||
|
||||
## 前端依赖(Node.js)
|
||||
|
||||
### 直接依赖(dependencies)
|
||||
|
||||
| 包名 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| next | 15.5.15 | React 全栈框架 |
|
||||
| react | 19.2.5 | UI 库 |
|
||||
| react-dom | 19.2.5 | React DOM 渲染 |
|
||||
|
||||
### 开发依赖(devDependencies)
|
||||
|
||||
| 包名 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| typescript | 5.9.3 | TypeScript 编译器 |
|
||||
| tailwindcss | 4.2.2 | CSS 工具框架 |
|
||||
| @tailwindcss/postcss | 4.2.2 | Tailwind PostCSS 插件 |
|
||||
| postcss | 8.5.9 | CSS 后处理器 |
|
||||
| @types/node | 22.19.17 | Node.js 类型定义 |
|
||||
| @types/react | 19.2.14 | React 类型定义 |
|
||||
| @types/react-dom | 19.2.3 | ReactDOM 类型定义 |
|
||||
|
||||
完整依赖树见 `package-lock.json`(共 46 个包)。
|
||||
|
||||
## 外部服务依赖
|
||||
|
||||
| 服务 | 端口 | 用途 | 必需 |
|
||||
|------|------|------|------|
|
||||
| Ollama | 11434 | 本地 embedding(nomic-embed-text)| 是(Mem0 依赖) |
|
||||
| Qdrant | 6333 | 本地向量数据库(Mem0 存储) | 是(Mem0 依赖) |
|
||||
|
||||
## 环境变量
|
||||
|
||||
后端通过 `.env` 文件加载配置,主要包含:
|
||||
- LLM API Key(向量引擎 / DeepSeek)
|
||||
- Replicate API Token
|
||||
- 代理配置(HTTPS_PROXY)
|
||||
- Gemini 超时参数(VECTORENGINE_GEMINI_*_TIMEOUT)
|
||||
- JWT Secret
|
||||
|
||||
详见 `art-agent/backend/.env`(gitignored,不纳入版本控制)。
|
||||
|
||||
## 环境搭建步骤
|
||||
|
||||
### 0. 本地环境配置(首次)
|
||||
|
||||
复制 `.cursor/local-env.example.json` 为 `.cursor/local-env.json`,修改为本机实际值。
|
||||
或者启动 Cursor Agent 会话,Agent 会自动探测并生成。
|
||||
|
||||
### 1. 后端
|
||||
|
||||
```bash
|
||||
cd art-agent/backend
|
||||
|
||||
# 创建虚拟环境
|
||||
python -m venv venv
|
||||
|
||||
# 激活虚拟环境
|
||||
# Windows PowerShell:
|
||||
.\venv\Scripts\Activate.ps1
|
||||
# macOS/Linux:
|
||||
source venv/bin/activate
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 启动(需要先启动 Ollama)
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### 2. 前端
|
||||
|
||||
```bash
|
||||
cd art-agent/frontend
|
||||
|
||||
# 如果 Node.js 未在系统 PATH 中(参考 local-env.json 的 nodejs_path):
|
||||
# PowerShell: $env:PATH = "你的nodejs_path;" + $env:PATH
|
||||
# bash/zsh: export PATH="你的nodejs_path:$PATH"
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. Ollama
|
||||
|
||||
```bash
|
||||
# 启动 Ollama 服务
|
||||
ollama serve
|
||||
|
||||
# 拉取 embedding 模型(首次)
|
||||
ollama pull nomic-embed-text
|
||||
```
|
||||
|
||||
## 访问地址
|
||||
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| 前端页面 | http://localhost:3000 |
|
||||
| 后端 API | http://localhost:8000 |
|
||||
| API 文档 | http://localhost:8000/docs |
|
||||
| Ollama | http://localhost:11434 |
|
||||
@@ -46,6 +46,7 @@ SYSTEM_PROMPT = """\
|
||||
- 如果用户提供了参考图(支持多张),将参考图的风格元素融入生成 prompt
|
||||
- 理解多张参考图各自的角色(如"图1的主体 + 图2的风格/视角"),并在 prompt 中准确传达
|
||||
- 理解用户在图片上的标注(框选区域 + 文字批注),精准定位需要修改的部分
|
||||
- 将一张图片转换为多个不同视角(使用 transform_view 工具),适合建筑、物体等需要从不同角度查看的场景
|
||||
|
||||
## 工作流程
|
||||
1. 理解用户需求,必要时追问细节(尺寸、风格、用途等)
|
||||
@@ -268,9 +269,13 @@ async def run_agent_loop(
|
||||
tc_data = tool_calls_data[idx]
|
||||
tool_name = tc_data["name"]
|
||||
|
||||
tool_message = (
|
||||
"正在进行视角变换..." if tool_name == "transform_view"
|
||||
else "正在生成图片..."
|
||||
)
|
||||
yield {
|
||||
"type": "tool_start",
|
||||
"data": {"tool": tool_name, "message": "正在生成图片..."},
|
||||
"data": {"tool": tool_name, "message": tool_message},
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -292,7 +297,29 @@ async def run_agent_loop(
|
||||
},
|
||||
}
|
||||
|
||||
if result.get("images"):
|
||||
if result.get("error") and not result.get("success"):
|
||||
yield {
|
||||
"type": "tool_error",
|
||||
"data": {
|
||||
"tool": tool_name,
|
||||
"errors": [result["error"]],
|
||||
"model_name": used_model,
|
||||
},
|
||||
}
|
||||
|
||||
if tool_name == "transform_view" and result.get("images"):
|
||||
# transform_view 返回带视角信息的图片列表
|
||||
image_urls = [v["url"] for v in result["images"]]
|
||||
yield {
|
||||
"type": "image_result",
|
||||
"data": {
|
||||
"images": image_urls,
|
||||
"prompt_used": "",
|
||||
"model_name": used_model,
|
||||
"view_info": result["images"],
|
||||
},
|
||||
}
|
||||
elif result.get("images"):
|
||||
yield {
|
||||
"type": "image_result",
|
||||
"data": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Agent 可调用的工具定义和实现。"""
|
||||
|
||||
from app.services.image_gen import generate_images
|
||||
from app.services.view_transform import transform_view
|
||||
|
||||
# OpenAI Function Calling 格式的工具定义
|
||||
TOOL_DEFINITIONS = [
|
||||
@@ -32,7 +33,27 @@ TOOL_DEFINITIONS = [
|
||||
"required": ["prompt"],
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "transform_view",
|
||||
"description": (
|
||||
"将一张图片转换为多个不同视角。"
|
||||
"使用 Zero123++ 模型从单张图片生成 6 个固定视角的图片。"
|
||||
"适用于建筑、物体等需要从不同角度查看的场景。"
|
||||
"输入图片必须是正方形(或会被自动裁切为正方形),建议分辨率 >= 320x320。"
|
||||
"输出 6 个视角:方位角 30°/90°/150°/210°/270°/330°,"
|
||||
"仰角交替为 30°/-20°(正俯视/微仰视)。"
|
||||
"此工具需要参考图作为输入——必须先有用户上传的图片才能使用。"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -65,4 +86,13 @@ async def execute_tool(
|
||||
"model_id": result.model_id,
|
||||
}
|
||||
|
||||
if tool_name == "transform_view":
|
||||
if not ref_image_urls:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "视角变换需要一张输入图片,请先上传参考图",
|
||||
}
|
||||
result = await transform_view(ref_image_urls[0])
|
||||
return result
|
||||
|
||||
return {"success": False, "error": f"未知工具: {tool_name}"}
|
||||
|
||||
@@ -228,6 +228,50 @@ IMAGE_MODELS: dict[str, dict[str, Any]] = {
|
||||
}
|
||||
|
||||
|
||||
# ─── 视角变换模型注册表 ─────────────────────────────────
|
||||
#
|
||||
# 视角变换模型不用于生图,而是将已有图片转换为不同视角。
|
||||
# 输入通常只需一张图,输出为多个固定视角的图片。
|
||||
|
||||
VIEW_TRANSFORM_MODELS: dict[str, dict[str, Any]] = {
|
||||
"zero123plus": {
|
||||
"id": "zero123plus",
|
||||
"name": "Zero123++",
|
||||
"provider": "replicate",
|
||||
"model_id": "jd7h/zero123plusplus:c69c6559a29011b576f1ff0371b3bc1add2856480c60520c7e9ce0b40a6e9052",
|
||||
"description": "单图生成 6 个固定视角,适合建筑/物体的多角度预览",
|
||||
"output_views": [
|
||||
{"azimuth": 30, "elevation": 30},
|
||||
{"azimuth": 90, "elevation": -20},
|
||||
{"azimuth": 150, "elevation": 30},
|
||||
{"azimuth": 210, "elevation": -20},
|
||||
{"azimuth": 270, "elevation": 30},
|
||||
{"azimuth": 330, "elevation": -20},
|
||||
],
|
||||
"grid_layout": {"cols": 3, "rows": 2},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_view_transform_model_config(model_id: str | None = None) -> dict[str, Any]:
|
||||
"""获取视角变换模型配置,默认返回 zero123plus。"""
|
||||
if model_id and model_id in VIEW_TRANSFORM_MODELS:
|
||||
return VIEW_TRANSFORM_MODELS[model_id]
|
||||
return VIEW_TRANSFORM_MODELS["zero123plus"]
|
||||
|
||||
|
||||
def get_view_transform_models_list() -> list[dict]:
|
||||
"""返回前端下拉列表所需的视角变换模型摘要信息。"""
|
||||
return [
|
||||
{
|
||||
"id": cfg["id"],
|
||||
"name": cfg["name"],
|
||||
"description": cfg["description"],
|
||||
}
|
||||
for cfg in VIEW_TRANSFORM_MODELS.values()
|
||||
]
|
||||
|
||||
|
||||
def get_default_image_model_id() -> str:
|
||||
"""返回 .env 中配置的默认模型短 ID,若不在注册表中则回退到 flux-schnell。"""
|
||||
env_model = os.getenv("IMAGE_MODEL", "flux-schnell")
|
||||
|
||||
157
art-agent/backend/app/services/view_transform.py
Normal file
157
art-agent/backend/app/services/view_transform.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""视角变换服务 — 调用 Zero123++ 等模型,将单张图片转为多视角图片。"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from PIL import Image
|
||||
|
||||
from app.config import get_view_transform_model_config
|
||||
from app.services.image_gen import _make_replicate_client, GENERATED_DIR, to_data_uri
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_replicate_client = _make_replicate_client()
|
||||
|
||||
|
||||
def _split_grid_image(
|
||||
image_path: Path,
|
||||
cols: int,
|
||||
rows: int,
|
||||
view_labels: list[dict[str, int]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""将 Zero123++ 输出的 grid 拼接图切分为独立的视角图片。
|
||||
|
||||
返回格式:[{"url": "/generated/xxx.png", "azimuth": 30, "elevation": 30}, ...]
|
||||
Grid 布局从左到右、从上到下依次对应 view_labels 中的视角。
|
||||
"""
|
||||
img = Image.open(image_path)
|
||||
w, h = img.size
|
||||
cell_w = w // cols
|
||||
cell_h = h // rows
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for idx, view in enumerate(view_labels):
|
||||
row_idx = idx // cols
|
||||
col_idx = idx % cols
|
||||
left = col_idx * cell_w
|
||||
upper = row_idx * cell_h
|
||||
right = left + cell_w
|
||||
lower = upper + cell_h
|
||||
|
||||
cropped = img.crop((left, upper, right, lower))
|
||||
filename = f"view_{uuid.uuid4().hex[:8]}_az{view['azimuth']}_el{view['elevation']}.png"
|
||||
filepath = GENERATED_DIR / filename
|
||||
cropped.save(filepath, "PNG")
|
||||
|
||||
results.append({
|
||||
"url": f"/generated/{filename}",
|
||||
"azimuth": view["azimuth"],
|
||||
"elevation": view["elevation"],
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def _download_to_local(url: str) -> Path:
|
||||
"""下载远程图片到本地 generated/ 目录,返回本地路径。"""
|
||||
filename = f"grid_{uuid.uuid4().hex}.png"
|
||||
filepath = GENERATED_DIR / filename
|
||||
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
|
||||
async with httpx.AsyncClient(proxy=proxy, timeout=httpx.Timeout(120.0)) as client:
|
||||
resp = await client.get(url, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
filepath.write_bytes(resp.content)
|
||||
return filepath
|
||||
|
||||
|
||||
async def transform_view(
|
||||
image_path: str,
|
||||
model_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""调用视角变换模型,返回多视角图片列表。
|
||||
|
||||
Args:
|
||||
image_path: 输入图片路径(本地路径、data URI 或 URL)
|
||||
model_id: 视角变换模型 ID,默认 zero123plus
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"images": [{"url": str, "azimuth": int, "elevation": int}, ...],
|
||||
"grid_image": str, # 原始拼接图的本地 URL
|
||||
"model_name": str,
|
||||
"error": str | None,
|
||||
}
|
||||
"""
|
||||
config = get_view_transform_model_config(model_id)
|
||||
model_name = config["name"]
|
||||
replicate_model_id = config["model_id"]
|
||||
grid_layout = config["grid_layout"]
|
||||
output_views = config["output_views"]
|
||||
|
||||
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
image_uri = to_data_uri(image_path)
|
||||
input_params = {"image": image_uri}
|
||||
|
||||
logger.info("调用 %s 进行视角变换...", model_name)
|
||||
output = await _replicate_client.async_run(
|
||||
replicate_model_id, input=input_params, wait=False
|
||||
)
|
||||
|
||||
# Zero123++ 返回单张 grid 拼接图
|
||||
items = output if isinstance(output, list) else [output]
|
||||
if not items:
|
||||
return {
|
||||
"success": False,
|
||||
"images": [],
|
||||
"grid_image": None,
|
||||
"model_name": model_name,
|
||||
"error": "模型未返回任何输出",
|
||||
}
|
||||
|
||||
grid_url = str(items[0])
|
||||
if not (grid_url.startswith("http://") or grid_url.startswith("https://")):
|
||||
return {
|
||||
"success": False,
|
||||
"images": [],
|
||||
"grid_image": None,
|
||||
"model_name": model_name,
|
||||
"error": f"模型返回非图片内容: {grid_url[:200]}",
|
||||
}
|
||||
|
||||
grid_local_path = await _download_to_local(grid_url)
|
||||
grid_local_url = f"/generated/{grid_local_path.name}"
|
||||
|
||||
view_images = _split_grid_image(
|
||||
grid_local_path,
|
||||
cols=grid_layout["cols"],
|
||||
rows=grid_layout["rows"],
|
||||
view_labels=output_views,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"images": view_images,
|
||||
"grid_image": grid_local_url,
|
||||
"model_name": model_name,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
|
||||
logger.error("视角变换失败: %s", detail, exc_info=True)
|
||||
return {
|
||||
"success": False,
|
||||
"images": [],
|
||||
"grid_image": None,
|
||||
"model_name": model_name,
|
||||
"error": detail,
|
||||
}
|
||||
BIN
art-agent/backend/requirements-lock.txt
Normal file
BIN
art-agent/backend/requirements-lock.txt
Normal file
Binary file not shown.
@@ -1,14 +1,14 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn>=0.32.0
|
||||
openai>=1.55.0
|
||||
replicate>=1.0.0
|
||||
sse-starlette>=2.1.0
|
||||
python-multipart>=0.0.12
|
||||
httpx>=0.27.0
|
||||
python-dotenv>=1.0.0
|
||||
Pillow>=10.4.0
|
||||
mem0ai
|
||||
ollama
|
||||
sqlmodel>=0.0.22
|
||||
pwdlib[argon2,bcrypt]>=0.3.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
fastapi==0.135.3
|
||||
uvicorn==0.44.0
|
||||
openai==2.32.0
|
||||
replicate==1.0.7
|
||||
sse-starlette==3.3.4
|
||||
python-multipart==0.0.26
|
||||
httpx==0.28.1
|
||||
python-dotenv==1.2.2
|
||||
Pillow==12.2.0
|
||||
mem0ai==1.0.11
|
||||
ollama==0.6.1
|
||||
sqlmodel==0.0.38
|
||||
pwdlib[argon2,bcrypt]==0.3.0
|
||||
python-jose[cryptography]==3.5.0
|
||||
|
||||
127
art-agent/frontend/package-lock.json
generated
127
art-agent/frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "epeekit-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.38.0",
|
||||
"next": "^15.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
@@ -138,9 +139,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -157,9 +155,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -176,9 +171,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -195,9 +187,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -214,9 +203,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -233,9 +219,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -252,9 +235,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -271,9 +251,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -290,9 +267,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -315,9 +289,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -340,9 +311,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -365,9 +333,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -390,9 +355,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -415,9 +377,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -440,9 +399,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -465,9 +421,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -654,9 +607,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -673,9 +623,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -692,9 +639,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -711,9 +655,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -897,9 +838,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -917,9 +855,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -937,9 +872,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -957,9 +889,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1134,6 +1063,33 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
|
||||
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.38.0",
|
||||
"motion-utils": "^12.36.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -1294,9 +1250,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1318,9 +1271,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1342,9 +1292,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1366,9 +1313,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1434,6 +1378,21 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
|
||||
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.36.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.36.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
|
||||
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
|
||||
@@ -9,17 +9,18 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.38.0",
|
||||
"next": "^15.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"postcss": "^8.5.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"postcss": "^8.5.0"
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useFireflyBurst, FireflyBurst } from "@/components/ui/firefly-burst";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { getImageUrl } from "@/lib/api";
|
||||
import { staggerContainer, staggerItem, slideUp } from "@/components/ui/motion-presets";
|
||||
import type { ImageAsset } from "@/lib/types";
|
||||
|
||||
type ViewMode = "grid" | "list";
|
||||
@@ -110,12 +114,13 @@ export default function GalleryPage() {
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col relative z-[1]">
|
||||
<AmbientParticles count={filtered.length === 0 ? 25 : 15} />
|
||||
<TopNav />
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex-shrink-0 border-b border-[var(--border)] bg-[var(--bg-secondary)]/80
|
||||
<div className="flex-shrink-0 border-b border-[var(--border)] surface-1
|
||||
backdrop-blur-xl px-4 md:px-5 py-3
|
||||
flex items-center gap-2 md:gap-3 flex-wrap">
|
||||
{/* 区块标题 */}
|
||||
@@ -125,7 +130,7 @@ export default function GalleryPage() {
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">资源库</span>
|
||||
<span className="typo-strong text-sm">资源库</span>
|
||||
</div>
|
||||
|
||||
{/* 搜索 */}
|
||||
@@ -272,19 +277,28 @@ export default function GalleryPage() {
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-5">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
|
||||
bg-[var(--accent)]/10 border border-[var(--accent)]/20">
|
||||
<motion.div
|
||||
className="text-center space-y-4"
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<motion.div variants={staggerItem} className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
|
||||
bg-[var(--accent)]/10 border border-[var(--accent)]/20
|
||||
shadow-[0_0_20px_rgba(46,139,122,0.1)]">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" opacity="0.6">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
</motion.div>
|
||||
<motion.p variants={staggerItem} className="typo-display text-lg">
|
||||
{assets.length === 0 ? "还没有生成过图片" : "没有匹配的资源"}
|
||||
</p>
|
||||
</div>
|
||||
</motion.p>
|
||||
<motion.p variants={staggerItem} className="typo-caption">
|
||||
{assets.length === 0 ? "在对话中生成的图片会自动出现在这里" : "试试调整筛选条件"}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 md:gap-4">
|
||||
@@ -294,7 +308,7 @@ export default function GalleryPage() {
|
||||
className={`group relative rounded-xl overflow-hidden neon-border cursor-pointer
|
||||
bg-[var(--bg-card)] backdrop-blur-sm transition-all ${
|
||||
selectedIds.has(asset.id)
|
||||
? "!border-[var(--accent)] ring-1 ring-[var(--accent)]/30 shadow-[0_0_15px_rgba(77,184,164,0.08)]"
|
||||
? "!border-[var(--accent)] ring-1 ring-[var(--accent)]/30 shadow-[0_0_15px_rgba(46,139,122,0.08)]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setDetailAsset(asset)}
|
||||
@@ -310,7 +324,7 @@ export default function GalleryPage() {
|
||||
<div
|
||||
className={`w-5 h-5 rounded-md border-2 flex items-center justify-center transition-all ${
|
||||
selectedIds.has(asset.id)
|
||||
? "bg-[var(--accent)] border-[var(--accent)] shadow-[0_0_8px_rgba(77,184,164,0.25)]"
|
||||
? "bg-[var(--accent)] border-[var(--accent)] shadow-[0_0_8px_rgba(46,139,122,0.25)]"
|
||||
: "border-white/40 bg-black/30 backdrop-blur-sm opacity-100 md:opacity-0 md:group-hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
@@ -527,15 +541,17 @@ function GalleryDetailModal({
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="glass-panel rounded-2xl max-w-3xl w-full mx-2 md:mx-4
|
||||
max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col
|
||||
shadow-xl shadow-black/40"
|
||||
<motion.div
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="surface-3 rounded-2xl max-w-3xl w-full mx-2 md:mx-4
|
||||
max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[var(--border)]">
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">资源详情</span>
|
||||
<span className="typo-h2">资源详情</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--accent)] cursor-pointer
|
||||
@@ -565,7 +581,7 @@ function GalleryDetailModal({
|
||||
onClick={onDownload}
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(77,184,164,0.25)]
|
||||
hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
|
||||
cursor-pointer transition-all"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
@@ -602,7 +618,7 @@ function GalleryDetailModal({
|
||||
{asset.prompt && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)]">Prompt</span>
|
||||
<span className="typo-strong text-xs">Prompt</span>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(asset.prompt)}
|
||||
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
@@ -621,7 +637,7 @@ function GalleryDetailModal({
|
||||
{/* 标签 */}
|
||||
{asset.tags.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5">标签</span>
|
||||
<span className="typo-strong text-xs block mb-1.5">标签</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{asset.tags.map((tid) => {
|
||||
const tag = tagMap.get(tid);
|
||||
@@ -648,7 +664,7 @@ function GalleryDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ===== 青绿山水色彩体系 ===== */
|
||||
/* ===== 青绿山水色彩体系(白底·绢本) ===== */
|
||||
:root {
|
||||
--bg-primary: #0C1210;
|
||||
--bg-secondary: #131E1A;
|
||||
--bg-tertiary: #1C2A24;
|
||||
--bg-card: rgba(16, 30, 24, 0.65);
|
||||
--text-primary: #E0E8E2;
|
||||
--text-secondary: #7A9485;
|
||||
--accent: #4DB8A4;
|
||||
--accent-hover: #6CD4BE;
|
||||
--accent-secondary: #3A8FB7;
|
||||
--bg-primary: #FAFAF7;
|
||||
--bg-secondary: #F2F1EC;
|
||||
--bg-tertiary: #E8E6DF;
|
||||
--bg-card: rgba(255, 255, 255, 0.8);
|
||||
--text-primary: #1A1A1A;
|
||||
--text-secondary: #6B7B8A;
|
||||
--accent: #2E8B7A;
|
||||
--accent-hover: #3AA38E;
|
||||
--accent-secondary: #3A7FB8;
|
||||
--hot: #C4654A;
|
||||
--border: rgba(77, 184, 164, 0.12);
|
||||
--border-glow: rgba(77, 184, 164, 0.25);
|
||||
--mist: rgba(77, 184, 164, 0.06);
|
||||
--border: rgba(46, 139, 122, 0.15);
|
||||
--border-glow: rgba(46, 139, 122, 0.30);
|
||||
--mist: rgba(46, 139, 122, 0.04);
|
||||
--gold: #B8935A;
|
||||
}
|
||||
|
||||
@@ -22,8 +22,126 @@ body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.01em;
|
||||
overscroll-behavior: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
/* 绢本底纹:极淡暖白,像宣纸的自然质感 */
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 80%, rgba(46, 139, 122, 0.03) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 20%, rgba(58, 127, 184, 0.02) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
/* ===== 排版层级体系(7 级) ===== */
|
||||
|
||||
.typo-display {
|
||||
font-size: 32px;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-secondary) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.typo-h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary);
|
||||
position: relative;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.typo-h1::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--accent) 0%, transparent 100%);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.typo-h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
padding-left: 12px;
|
||||
position: relative;
|
||||
}
|
||||
.typo-h2::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
width: 3px;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.typo-strong {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.typo-caption {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.typo-micro {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ===== 表面层级系统(Surface Elevation) ===== */
|
||||
|
||||
.surface-1 {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.surface-2 {
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
backdrop-filter: blur(20px) saturate(1.1);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.1);
|
||||
border: 1px solid rgba(46, 139, 122, 0.12);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06),
|
||||
0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.surface-3 {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(24px) saturate(1.15);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.15);
|
||||
border: 1px solid rgba(46, 139, 122, 0.18);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08),
|
||||
0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* ===== 网格底纹(绢本纹理潜意识层) ===== */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(circle, var(--accent) 0.5px, transparent 0.5px);
|
||||
background-size: 24px 24px;
|
||||
opacity: 0.018;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ===== 山雾背景 — 远景层 ===== */
|
||||
@@ -41,60 +159,50 @@ body {
|
||||
100% { transform: translate(0, 0) scale(1); }
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
.fog-layer-far {
|
||||
position: fixed;
|
||||
inset: -10%;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 15% 75%, rgba(77, 184, 164, 0.07) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 60% 40% at 75% 25%, rgba(58, 143, 183, 0.05) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 90% 60% at 50% 50%, rgba(77, 184, 164, 0.03) 0%, transparent 70%);
|
||||
radial-gradient(ellipse 80% 50% at 15% 75%, rgba(46, 139, 122, 0.04) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 60% 40% at 75% 25%, rgba(58, 127, 184, 0.03) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 90% 60% at 50% 50%, rgba(46, 139, 122, 0.02) 0%, transparent 70%);
|
||||
animation: fogDriftFar 60s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
body::after {
|
||||
content: '';
|
||||
.fog-layer-near {
|
||||
position: fixed;
|
||||
inset: -15%;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 45% at 80% 70%, rgba(77, 184, 164, 0.06) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 50% 60% at 25% 30%, rgba(58, 143, 183, 0.04) 0%, transparent 50%);
|
||||
radial-gradient(ellipse 70% 45% at 80% 70%, rgba(46, 139, 122, 0.03) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 50% 60% at 25% 30%, rgba(58, 127, 184, 0.025) 0%, transparent 50%);
|
||||
animation: fogDriftNear 45s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ===== 山雾玻璃面板 ===== */
|
||||
/* ===== 山雾玻璃面板(兼容旧用法) ===== */
|
||||
.glass-panel {
|
||||
background:
|
||||
linear-gradient(165deg, rgba(16, 30, 24, 0.15) 0%, transparent 50%),
|
||||
rgba(16, 30, 24, 0.55);
|
||||
backdrop-filter: blur(24px) saturate(1.1);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.1);
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
backdrop-filter: blur(20px) saturate(1.1);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.1);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ===== 静态霓虹边框(卡片等普通元素) ===== */
|
||||
/* ===== 静态发光边框(卡片等普通元素) ===== */
|
||||
.neon-border {
|
||||
border: 1px solid var(--border);
|
||||
transition: border-color 200ms ease, box-shadow 200ms ease;
|
||||
transition: border-color 200ms ease, box-shadow 200ms ease, transform 200ms ease;
|
||||
}
|
||||
.neon-border:hover {
|
||||
border-color: var(--border-glow);
|
||||
box-shadow: 0 0 18px rgba(77, 184, 164, 0.08),
|
||||
inset 0 0 12px rgba(77, 184, 164, 0.03);
|
||||
box-shadow: 0 0 18px rgba(46, 139, 122, 0.08),
|
||||
inset 0 0 12px rgba(46, 139, 122, 0.03);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ===== 云烟缭绕边框 ===== */
|
||||
|
||||
/*
|
||||
* .glow-border — 选中态的云烟边框
|
||||
* 常态:石青色微弱均匀发光
|
||||
* hover:云烟缓慢缭绕,opacity 平滑过渡
|
||||
* 移开:云烟淡散回静态
|
||||
*/
|
||||
|
||||
@property --glow-angle {
|
||||
syntax: "<angle>";
|
||||
initial-value: 0deg;
|
||||
@@ -107,19 +215,16 @@ body::after {
|
||||
.glow-border {
|
||||
position: relative;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid rgba(77, 184, 164, 0.18) !important;
|
||||
box-shadow: 0 0 10px rgba(77, 184, 164, 0.05),
|
||||
inset 0 0 8px rgba(77, 184, 164, 0.02);
|
||||
border: 1px solid rgba(46, 139, 122, 0.20) !important;
|
||||
box-shadow: 0 0 10px rgba(46, 139, 122, 0.06);
|
||||
transition: border-color 400ms ease, box-shadow 400ms ease;
|
||||
z-index: 0;
|
||||
}
|
||||
.glow-border:hover {
|
||||
border-color: rgba(77, 184, 164, 0.28) !important;
|
||||
box-shadow: 0 0 16px rgba(77, 184, 164, 0.08),
|
||||
inset 0 0 12px rgba(77, 184, 164, 0.03);
|
||||
border-color: rgba(46, 139, 122, 0.32) !important;
|
||||
box-shadow: 0 0 16px rgba(46, 139, 122, 0.10);
|
||||
}
|
||||
|
||||
/* 云烟边框层 — 宽弧段柔化渐变,6s 缓慢旋转 */
|
||||
.glow-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -130,11 +235,11 @@ body::after {
|
||||
from var(--glow-angle),
|
||||
transparent 0%,
|
||||
transparent 15%,
|
||||
rgba(77, 184, 164, 0.2) 25%,
|
||||
rgba(58, 143, 183, 0.35) 38%,
|
||||
rgba(77, 184, 164, 0.4) 50%,
|
||||
rgba(58, 143, 183, 0.35) 62%,
|
||||
rgba(77, 184, 164, 0.2) 75%,
|
||||
rgba(46, 139, 122, 0.2) 25%,
|
||||
rgba(58, 127, 184, 0.3) 38%,
|
||||
rgba(46, 139, 122, 0.35) 50%,
|
||||
rgba(58, 127, 184, 0.3) 62%,
|
||||
rgba(46, 139, 122, 0.2) 75%,
|
||||
transparent 85%,
|
||||
transparent 100%
|
||||
);
|
||||
@@ -149,7 +254,6 @@ body::after {
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* 雾气扩散层 — 更大范围、更模糊 */
|
||||
.glow-border::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -159,9 +263,9 @@ body::after {
|
||||
from var(--glow-angle),
|
||||
transparent 0%,
|
||||
transparent 20%,
|
||||
rgba(77, 184, 164, 0.06) 35%,
|
||||
rgba(58, 143, 183, 0.08) 50%,
|
||||
rgba(77, 184, 164, 0.06) 65%,
|
||||
rgba(46, 139, 122, 0.05) 35%,
|
||||
rgba(58, 127, 184, 0.06) 50%,
|
||||
rgba(46, 139, 122, 0.05) 65%,
|
||||
transparent 80%,
|
||||
transparent 100%
|
||||
);
|
||||
@@ -194,6 +298,144 @@ body::after {
|
||||
);
|
||||
}
|
||||
|
||||
/* ===== 数据流光(AI 活动时输入栏上方) ===== */
|
||||
@keyframes dataStream {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(200%); }
|
||||
}
|
||||
.data-stream {
|
||||
height: 2px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
var(--accent) 30%,
|
||||
var(--accent-secondary) 50%,
|
||||
var(--accent) 70%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dataStream 2s ease-in-out infinite;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.data-stream-slow {
|
||||
animation-duration: 3s;
|
||||
}
|
||||
.data-stream-fast {
|
||||
animation-duration: 1.2s;
|
||||
}
|
||||
|
||||
/* ===== 水波纹 loading 动画 ===== */
|
||||
@keyframes waveRise {
|
||||
0%, 100% { transform: scaleY(0.4); }
|
||||
50% { transform: scaleY(1); }
|
||||
}
|
||||
.wave-dot {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
animation: waveRise 1.2s ease-in-out infinite;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
/* ===== 图片加载占位 — 石青渐变呼吸脉冲 ===== */
|
||||
@keyframes stoneBreath {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.stone-placeholder {
|
||||
background: linear-gradient(135deg, rgba(46, 139, 122, 0.10) 0%, rgba(58, 127, 184, 0.06) 100%);
|
||||
animation: stoneBreath 2s ease-in-out infinite;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ===== shimmer 骨架屏 ===== */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
.skeleton-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-tertiary) 0%,
|
||||
var(--bg-secondary) 40%,
|
||||
var(--bg-tertiary) 60%,
|
||||
var(--bg-tertiary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ===== 涟漪效果 ===== */
|
||||
@keyframes rippleExpand {
|
||||
0% { transform: scale(0); opacity: 0.4; }
|
||||
100% { transform: scale(2.5); opacity: 0; }
|
||||
}
|
||||
.ripple-ring {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--accent);
|
||||
animation: rippleExpand 600ms ease-out forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== 水墨晕染揭示 ===== */
|
||||
@keyframes inkReveal {
|
||||
0% { clip-path: circle(0% at 50% 50%); }
|
||||
100% { clip-path: circle(75% at 50% 50%); }
|
||||
}
|
||||
.ink-reveal {
|
||||
animation: inkReveal 600ms ease-out forwards;
|
||||
}
|
||||
|
||||
/* ===== 错误状态震动 ===== */
|
||||
@keyframes errorShake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-2px); }
|
||||
40% { transform: translateX(2px); }
|
||||
60% { transform: translateX(-2px); }
|
||||
80% { transform: translateX(2px); }
|
||||
}
|
||||
.error-shake {
|
||||
animation: errorShake 300ms ease-out;
|
||||
}
|
||||
|
||||
/* ===== 呼吸脉冲(发送按钮就绪态) ===== */
|
||||
@keyframes breathPulse {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(46, 139, 122, 0.18); }
|
||||
50% { box-shadow: 0 0 20px rgba(46, 139, 122, 0.35); }
|
||||
}
|
||||
.breath-pulse {
|
||||
animation: breathPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ===== 输入框水面感应 ===== */
|
||||
@keyframes waterExpand {
|
||||
0% { transform: scaleX(0); }
|
||||
100% { transform: scaleX(1); }
|
||||
}
|
||||
.water-focus-line {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent 0%, var(--accent) 50%, transparent 100%);
|
||||
transform: scaleX(0);
|
||||
transition: transform 300ms ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
.water-focus-active .water-focus-line {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* ===== 用户消息气泡 — 左上切角 ===== */
|
||||
.user-bubble {
|
||||
border-radius: 2px 16px 16px 16px;
|
||||
}
|
||||
|
||||
/* ===== 自定义滚动条 ===== */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -203,11 +445,11 @@ body::after {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(77, 184, 164, 0.15);
|
||||
background: rgba(46, 139, 122, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(77, 184, 164, 0.28);
|
||||
background: rgba(46, 139, 122, 0.30);
|
||||
}
|
||||
|
||||
select option {
|
||||
@@ -215,6 +457,7 @@ select option {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ===== 通用动画 ===== */
|
||||
.sidebar-overlay {
|
||||
animation: fadeIn 200ms ease-out;
|
||||
}
|
||||
@@ -226,7 +469,81 @@ select option {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes floatUp {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(-12px); }
|
||||
}
|
||||
|
||||
/* ===== 通用按钮 hover 浮起 ===== */
|
||||
.btn-hover-lift {
|
||||
transition: transform 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
.btn-hover-lift:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* ===== 侧栏会话左侧竖线 hover ===== */
|
||||
.session-hover-line {
|
||||
position: relative;
|
||||
}
|
||||
.session-hover-line::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 1px;
|
||||
transform: scaleY(0);
|
||||
transition: transform 200ms ease-out;
|
||||
transform-origin: center;
|
||||
}
|
||||
.session-hover-line:hover::before {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
/* ===== 全屏加载 — 云雾凝聚 ===== */
|
||||
@keyframes mistCondense {
|
||||
0% { transform: scale(1.5); opacity: 0; filter: blur(20px); }
|
||||
50% { transform: scale(1.1); opacity: 0.6; filter: blur(8px); }
|
||||
100% { transform: scale(1); opacity: 1; filter: blur(0); }
|
||||
}
|
||||
@keyframes mistRing {
|
||||
0% { transform: scale(0.3); opacity: 0; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { transform: scale(2); opacity: 0; }
|
||||
}
|
||||
.mist-loader {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.mist-loader-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--accent);
|
||||
animation: mistRing 2s ease-out infinite;
|
||||
opacity: 0;
|
||||
}
|
||||
.mist-loader-ring-2 {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
.mist-loader-core {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 20px rgba(46, 139, 122, 0.4), 0 0 60px rgba(46, 139, 122, 0.15);
|
||||
animation: mistCondense 1.5s ease-out forwards, breathPulse 2s ease-in-out 1.5s infinite;
|
||||
}
|
||||
|
||||
/* ===== 100dvh 兼容 ===== */
|
||||
@supports (height: 100dvh) {
|
||||
.h-screen {
|
||||
height: 100dvh;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, type FormEvent } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { staggerContainer, staggerItem, buttonTap, errorFloat } from "@/components/ui/motion-presets";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
@@ -32,6 +35,7 @@ export default function LoginPage() {
|
||||
router.replace("/");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "登录失败");
|
||||
setTimeout(() => setError(""), 4000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -39,24 +43,19 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)] relative">
|
||||
{/* 背景环境光 */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute inset-0"
|
||||
style={{
|
||||
background: `
|
||||
radial-gradient(ellipse at 30% 70%, rgba(77, 184, 164, 0.08) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 70% 30%, rgba(58, 143, 183, 0.06) 0%, transparent 50%)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<AmbientParticles count={25} />
|
||||
|
||||
<div className="w-full max-w-sm mx-4 relative z-10">
|
||||
<div className="text-center mb-8">
|
||||
<motion.div
|
||||
className="w-full max-w-sm mx-4 relative z-10"
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<motion.div variants={staggerItem} className="text-center mb-8">
|
||||
{/* Logo 发光 */}
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-4
|
||||
bg-[var(--accent)]/10 border border-[var(--accent)]/20
|
||||
shadow-[0_0_40px_rgba(77,184,164,0.15)]">
|
||||
shadow-[0_0_40px_rgba(46,139,122,0.15)]">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="var(--accent)" />
|
||||
<circle cx="23" cy="12" r="3.5" stroke="var(--accent)" strokeWidth="1.8" fill="none" />
|
||||
@@ -64,12 +63,15 @@ export default function LoginPage() {
|
||||
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)]">EPEEKit</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1.5">AI 美术资源生成工具</p>
|
||||
</div>
|
||||
<h1 className="typo-display mb-1">EPEEKit</h1>
|
||||
<p className="typo-caption mt-1.5">AI 美术资源生成工具</p>
|
||||
</motion.div>
|
||||
|
||||
<form onSubmit={handleSubmit}
|
||||
className="glass-panel rounded-2xl p-6 space-y-4 shadow-xl shadow-black/20">
|
||||
<motion.form
|
||||
variants={staggerItem}
|
||||
onSubmit={handleSubmit}
|
||||
className="surface-2 rounded-2xl p-6 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
@@ -82,7 +84,7 @@ export default function LoginPage() {
|
||||
bg-[var(--bg-primary)] border border-[var(--border)]
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none focus:border-[var(--accent)]/50
|
||||
focus:shadow-[0_0_10px_rgba(77,184,164,0.06)]
|
||||
focus:shadow-[0_0_10px_rgba(46,139,122,0.06)]
|
||||
transition-all"
|
||||
/>
|
||||
</div>
|
||||
@@ -97,32 +99,42 @@ export default function LoginPage() {
|
||||
bg-[var(--bg-primary)] border border-[var(--border)]
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none focus:border-[var(--accent)]/50
|
||||
focus:shadow-[0_0_10px_rgba(77,184,164,0.06)]
|
||||
focus:shadow-[0_0_10px_rgba(46,139,122,0.06)]
|
||||
transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-[var(--hot)] bg-[var(--hot)]/10 rounded-xl px-4 py-2.5
|
||||
border border-[var(--hot)]/20">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
key="login-error"
|
||||
initial="visible"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
variants={errorFloat}
|
||||
className="text-sm text-[var(--hot)] bg-[var(--hot)]/10 rounded-xl px-4 py-2.5
|
||||
border border-[var(--hot)]/20 error-shake"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<button
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={loading || !username.trim() || !password}
|
||||
whileTap={buttonTap}
|
||||
className="w-full py-3 rounded-xl font-semibold
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
hover:shadow-[0_0_20px_rgba(77,184,164,0.25)]
|
||||
hover:shadow-[0_0_20px_rgba(46,139,122,0.25)]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all cursor-pointer"
|
||||
transition-all cursor-pointer btn-hover-lift"
|
||||
>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</motion.button>
|
||||
</motion.form>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChatMessages } from "@/components/chat/chat-messages";
|
||||
import { ChatInput, type ChatInputHandle } from "@/components/chat/chat-input";
|
||||
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { ImageDetailPanel } from "@/components/detail/image-detail-panel";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { sendChat, getImageUrl, uploadRefImage } from "@/lib/api";
|
||||
import { generateId } from "@/lib/store";
|
||||
import { scrollBtnEnter, scrollBtnClick, staggerContainer, staggerItem } from "@/components/ui/motion-presets";
|
||||
import type { ChatMessage, ImageAsset, ApiMessage, AnnotationData } from "@/lib/types";
|
||||
|
||||
export default function Home() {
|
||||
@@ -29,6 +32,7 @@ export default function Home() {
|
||||
const [streamingText, setStreamingText] = useState("");
|
||||
const [streamingImages, setStreamingImages] = useState<ImageAsset[]>([]);
|
||||
const [statusText, setStatusText] = useState("");
|
||||
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
|
||||
const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
|
||||
const [lastRefImageUrls, setLastRefImageUrls] = useState<string[]>([]);
|
||||
const lastRefPerSession = useRef<Map<string, string[]>>(new Map());
|
||||
@@ -57,7 +61,6 @@ export default function Home() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 实时记录当前会话的滚动位置 + 判断是否显示"回到底部"按钮
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !activeSessionId) return;
|
||||
@@ -72,7 +75,6 @@ export default function Home() {
|
||||
return () => el.removeEventListener("scroll", handler);
|
||||
}, [activeSessionId]);
|
||||
|
||||
// 会话切换:标记 switching,等 DOM 更新后恢复位置 + 恢复参考图状态
|
||||
useEffect(() => {
|
||||
if (!activeSessionId) return;
|
||||
if (prevSessionId.current && prevSessionId.current !== activeSessionId) {
|
||||
@@ -152,6 +154,7 @@ export default function Home() {
|
||||
};
|
||||
appendMessage(activeSessionId, userMessage);
|
||||
setIsLoading(true);
|
||||
setIsGeneratingImage(false);
|
||||
setStreamingText("");
|
||||
setStreamingImages([]);
|
||||
setStatusText("");
|
||||
@@ -179,6 +182,7 @@ export default function Home() {
|
||||
|
||||
case "tool_start":
|
||||
setStatusText(event.data.message as string);
|
||||
setIsGeneratingImage(true);
|
||||
scrollToBottom();
|
||||
break;
|
||||
|
||||
@@ -207,6 +211,7 @@ export default function Home() {
|
||||
|
||||
collectedImages = [...collectedImages, ...newAssets];
|
||||
setStreamingImages([...collectedImages]);
|
||||
setIsGeneratingImage(false);
|
||||
if (modelName) {
|
||||
usedModelName = modelName;
|
||||
setStatusText(`由 ${modelName} 生成`);
|
||||
@@ -225,6 +230,7 @@ export default function Home() {
|
||||
assistantText += `\n\n⚠️ 图片生成失败${modelHint}:\n${errorDetail}`;
|
||||
setStreamingText(assistantText);
|
||||
setStatusText("");
|
||||
setIsGeneratingImage(false);
|
||||
scrollToBottom();
|
||||
break;
|
||||
}
|
||||
@@ -256,6 +262,7 @@ export default function Home() {
|
||||
};
|
||||
appendMessage(activeSessionId, assistantMessage);
|
||||
setIsLoading(false);
|
||||
setIsGeneratingImage(false);
|
||||
setStreamingText("");
|
||||
setStreamingImages([]);
|
||||
setStatusText("");
|
||||
@@ -268,8 +275,13 @@ export default function Home() {
|
||||
setPendingAnnotation(data);
|
||||
}, []);
|
||||
|
||||
const isEmptyState = messages.length === 0 && !isLoading;
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col relative z-[1]">
|
||||
{/* 环境粒子(萤火 + 云雾) */}
|
||||
<AmbientParticles count={isEmptyState ? 28 : 18} />
|
||||
|
||||
<TopNav />
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
@@ -323,7 +335,7 @@ export default function Home() {
|
||||
border border-[var(--border)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:border-[var(--accent)]/40
|
||||
transition-all cursor-pointer
|
||||
transition-all cursor-pointer btn-hover-lift
|
||||
hidden md:flex"
|
||||
title="展开侧边栏"
|
||||
>
|
||||
@@ -341,7 +353,7 @@ export default function Home() {
|
||||
src={pendingAnnotation.snapshot}
|
||||
alt="标注预览"
|
||||
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/40
|
||||
shadow-[0_0_8px_rgba(77,184,164,0.12)]"
|
||||
shadow-[0_0_8px_rgba(46,139,122,0.12)]"
|
||||
/>
|
||||
<span className="text-xs text-[var(--accent)]">
|
||||
标注已就绪({pendingAnnotation.annotations.length} 处)— 输入修改意见后发送
|
||||
@@ -356,83 +368,103 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto fog-scroll">
|
||||
{messages.length === 0 && !isLoading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
{/* Logo 发光效果 */}
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl
|
||||
bg-[var(--accent)]/10 border border-[var(--accent)]/20
|
||||
shadow-[0_0_30px_rgba(77,184,164,0.12)]">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="var(--accent)" />
|
||||
<circle cx="23" cy="12" r="3.5" stroke="var(--accent)" strokeWidth="1.8" fill="none" />
|
||||
<path d="M23 15.5v5" stroke="var(--accent)" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
|
||||
</svg>
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto fog-scroll relative">
|
||||
<div className="min-h-full">
|
||||
{isEmptyState ? (
|
||||
<div className="h-full flex items-center justify-center" style={{ minHeight: "calc(100vh - 200px)" }}>
|
||||
<motion.div
|
||||
className="text-center space-y-5"
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* Logo 发光效果 */}
|
||||
<motion.div variants={staggerItem} className="inline-flex items-center justify-center w-16 h-16 rounded-2xl
|
||||
bg-[var(--accent)]/10 border border-[var(--accent)]/20
|
||||
shadow-[0_0_30px_rgba(46,139,122,0.12)]">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="var(--accent)" />
|
||||
<circle cx="23" cy="12" r="3.5" stroke="var(--accent)" strokeWidth="1.8" fill="none" />
|
||||
<path d="M23 15.5v5" stroke="var(--accent)" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<circle cx="23" cy="22.5" r="1" fill="var(--accent)" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
<motion.div variants={staggerItem}>
|
||||
<h2 className="typo-display mb-2">
|
||||
欢迎使用 EPEEKit
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)] max-w-md leading-relaxed mx-auto">
|
||||
描述你想要的美术资源,我来帮你生成。
|
||||
<br />
|
||||
你可以上传参考图来引导风格方向。
|
||||
</p>
|
||||
</motion.div>
|
||||
<motion.div variants={staggerItem} className="flex flex-wrap justify-center gap-2 pt-2">
|
||||
{[
|
||||
"画一个赛博朋克风格的退出按钮",
|
||||
"设计一个卡通风格的金币图标",
|
||||
"画一个奇幻风格的游戏角色立绘",
|
||||
].map((hint) => (
|
||||
<button
|
||||
key={hint}
|
||||
onClick={() => handleSend(hint, [], null)}
|
||||
className="text-xs px-4 py-2 rounded-xl
|
||||
border border-[var(--border)] text-[var(--text-secondary)]
|
||||
hover:border-[var(--accent)]/50 hover:text-[var(--accent)]
|
||||
hover:bg-[var(--accent)]/5
|
||||
transition-all cursor-pointer btn-hover-lift"
|
||||
>
|
||||
{hint}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
欢迎使用 EPEEKit
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)] max-w-md leading-relaxed">
|
||||
描述你想要的美术资源,我来帮你生成。
|
||||
<br />
|
||||
你可以上传参考图来引导风格方向。
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-5">
|
||||
{[
|
||||
"画一个赛博朋克风格的退出按钮",
|
||||
"设计一个卡通风格的金币图标",
|
||||
"画一个奇幻风格的游戏角色立绘",
|
||||
].map((hint) => (
|
||||
<button
|
||||
key={hint}
|
||||
onClick={() => handleSend(hint, [], null)}
|
||||
className="text-xs px-4 py-2 rounded-xl
|
||||
border border-[var(--border)] text-[var(--text-secondary)]
|
||||
hover:border-[var(--accent)]/50 hover:text-[var(--accent)]
|
||||
hover:bg-[var(--accent)]/5
|
||||
transition-all cursor-pointer"
|
||||
>
|
||||
{hint}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
isLoading={isLoading}
|
||||
streamingText={streamingText}
|
||||
streamingImages={streamingImages}
|
||||
statusText={statusText}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
isLoading={isLoading}
|
||||
streamingText={streamingText}
|
||||
streamingImages={streamingImages}
|
||||
statusText={statusText}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showScrollBtn && (
|
||||
<button
|
||||
onClick={() => scrollToBottom()}
|
||||
className="absolute bottom-16 right-4 z-10
|
||||
w-9 h-9 rounded-xl flex items-center justify-center
|
||||
glass-panel text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
||||
shadow-lg shadow-black/30
|
||||
transition-all duration-200 cursor-pointer
|
||||
animate-[fadeIn_150ms_ease-out]"
|
||||
title="回到底部"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{/* 回到底部 — 落叶归根(水滴形) */}
|
||||
<AnimatePresence>
|
||||
{showScrollBtn && (
|
||||
<motion.button
|
||||
key="scroll-btn"
|
||||
variants={scrollBtnEnter}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
onClick={() => {
|
||||
scrollToBottom();
|
||||
}}
|
||||
className="absolute bottom-16 right-4 z-10
|
||||
w-9 h-11 flex items-center justify-center
|
||||
surface-2 text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
||||
shadow-lg shadow-black/30
|
||||
cursor-pointer"
|
||||
style={{ borderRadius: "50% 50% 50% 50% / 35% 35% 65% 65%" }}
|
||||
title="回到底部"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="mt-1">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSend={handleSend}
|
||||
disabled={isLoading}
|
||||
isGeneratingImage={isGeneratingImage}
|
||||
lastRefServerUrls={lastRefImageUrls}
|
||||
lastRefPreviewUrls={lastRefImageUrls.map((u) => getImageUrl(u))}
|
||||
onClearLastRefImage={() => {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback, useImperativeHandle, forwardRef, type KeyboardEvent, type DragEvent, type ClipboardEvent } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { DataStream } from "@/components/ui/data-stream";
|
||||
import { useRipple, RippleLayer } from "@/components/ui/ripple-effect";
|
||||
import { uploadRefImage, type UploadProgress } from "@/lib/api";
|
||||
import { sendBounce } from "@/components/ui/motion-presets";
|
||||
|
||||
type UploadStatus = "idle" | "uploading" | "done" | "error";
|
||||
|
||||
@@ -26,6 +30,8 @@ interface ChatInputProps {
|
||||
onClearLastRefImage?: () => void;
|
||||
/** 文件在 ChatInput 区域内被 drop 时触发,通知父组件清除拖拽覆盖层 */
|
||||
onFileDrop?: () => void;
|
||||
/** 是否正在生成图片(用于数据流光加速) */
|
||||
isGeneratingImage?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatInputHandle {
|
||||
@@ -34,7 +40,6 @@ export interface ChatInputHandle {
|
||||
|
||||
let _uploadCounter = 0;
|
||||
|
||||
/** 从剪贴板提取可上传的图片文件(Ctrl+V / 右键粘贴共用) */
|
||||
function imageFilesFromClipboard(data: DataTransfer | null): File[] {
|
||||
if (!data?.items?.length) return [];
|
||||
const out: File[] = [];
|
||||
@@ -49,25 +54,38 @@ function imageFilesFromClipboard(data: DataTransfer | null): File[] {
|
||||
}
|
||||
|
||||
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
|
||||
{ onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop },
|
||||
{ onSend, disabled, lastRefServerUrls, lastRefPreviewUrls, onClearLastRefImage, onFileDrop, isGeneratingImage },
|
||||
ref
|
||||
) {
|
||||
const [text, setText] = useState("");
|
||||
const [uploads, setUploads] = useState<UploadItem[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const { ripples, trigger: triggerRipple } = useRipple();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const sendBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const abortRefs = useRef<Map<string, AbortController>>(new Map());
|
||||
|
||||
const hasActiveUpload = uploads.some((u) => u.status === "uploading");
|
||||
const allDone = uploads.length > 0 && uploads.every((u) => u.status === "done" || u.status === "error");
|
||||
const doneUrls = uploads.filter((u) => u.status === "done" && u.serverUrl).map((u) => u.serverUrl!);
|
||||
|
||||
const canSend = text.trim() && !disabled && !hasActiveUpload;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
if (hasActiveUpload) return;
|
||||
|
||||
// 投石入水涟漪
|
||||
if (sendBtnRef.current) {
|
||||
const rect = sendBtnRef.current.getBoundingClientRect();
|
||||
const parent = sendBtnRef.current.offsetParent as HTMLElement | null;
|
||||
const parentRect = parent?.getBoundingClientRect() ?? rect;
|
||||
triggerRipple(rect.left - parentRect.left + rect.width / 2, rect.top - parentRect.top + rect.height / 2);
|
||||
}
|
||||
|
||||
const refUrls = doneUrls.length > 0 ? doneUrls : (lastRefServerUrls ?? []);
|
||||
onSend(trimmed, refUrls, selectedModel || null);
|
||||
setText("");
|
||||
@@ -183,7 +201,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
|
||||
return (
|
||||
<div
|
||||
className={`border-t border-[var(--border)] bg-[var(--bg-secondary)]/80 backdrop-blur-xl
|
||||
px-3 md:px-5 py-2.5 md:py-3 transition-colors ${
|
||||
transition-colors ${
|
||||
isDragging ? "bg-[var(--accent)]/5 border-[var(--accent)]/40" : ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -191,192 +209,210 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function Ch
|
||||
onDrop={handleDrop}
|
||||
onPasteCapture={handlePasteCapture}
|
||||
>
|
||||
{isDragging && (
|
||||
<div className="mb-2 text-center text-xs text-[var(--accent)]">
|
||||
松开以添加参考图(可多张)
|
||||
</div>
|
||||
)}
|
||||
{/* 数据流光 */}
|
||||
<DataStream active={disabled} speed={isGeneratingImage ? "fast" : "slow"} />
|
||||
|
||||
{/* 沿用上次参考图提示 */}
|
||||
{uploads.length === 0 && lastRefPreviewUrls && lastRefPreviewUrls.length > 0 && (
|
||||
<div className="mb-3 flex items-center gap-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
{lastRefPreviewUrls.map((url, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={url}
|
||||
alt={`沿用参考图 ${i + 1}`}
|
||||
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/30
|
||||
shadow-[0_0_8px_rgba(77,184,164,0.1)]"
|
||||
/>
|
||||
))}
|
||||
<div className="relative px-3 md:px-5 py-2.5 md:py-3">
|
||||
{isDragging && (
|
||||
<div className="mb-2 text-center text-xs text-[var(--accent)]">
|
||||
松开以添加参考图(可多张)
|
||||
</div>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
沿用上次参考图({lastRefPreviewUrls.length} 张)
|
||||
</span>
|
||||
<button
|
||||
onClick={onClearLastRefImage}
|
||||
className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--hot)]
|
||||
cursor-pointer transition-colors"
|
||||
title="清除参考图"
|
||||
>
|
||||
✕ 清除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* 参考图上传状态区(多图) */}
|
||||
{uploads.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{uploads.map((item) => (
|
||||
<div key={item.id} className="relative flex-shrink-0">
|
||||
{item.previewUrl && (
|
||||
{/* 沿用上次参考图提示 */}
|
||||
{uploads.length === 0 && lastRefPreviewUrls && lastRefPreviewUrls.length > 0 && (
|
||||
<div className="mb-3 flex items-center gap-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
{lastRefPreviewUrls.map((url, i) => (
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt="参考图"
|
||||
className={`w-16 h-16 rounded-xl object-cover border border-[var(--border)] transition-opacity ${
|
||||
item.status === "uploading" ? "opacity-60" : ""
|
||||
}`}
|
||||
key={i}
|
||||
src={url}
|
||||
alt={`沿用参考图 ${i + 1}`}
|
||||
className="w-10 h-10 rounded-lg object-cover border border-[var(--accent)]/30
|
||||
shadow-[0_0_8px_rgba(46,139,122,0.1)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
沿用上次参考图({lastRefPreviewUrls.length} 张)
|
||||
</span>
|
||||
<button
|
||||
onClick={onClearLastRefImage}
|
||||
className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--hot)]
|
||||
cursor-pointer transition-colors"
|
||||
title="清除参考图"
|
||||
>
|
||||
✕ 清除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参考图上传状态区(多图) */}
|
||||
{uploads.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{uploads.map((item) => (
|
||||
<div key={item.id} className="relative flex-shrink-0">
|
||||
{item.previewUrl && (
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt="参考图"
|
||||
className={`w-16 h-16 rounded-xl object-cover border border-[var(--border)] transition-opacity ${
|
||||
item.status === "uploading" ? "opacity-60" : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{item.status === "uploading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(46,139,122,0.12)" strokeWidth="3" />
|
||||
<circle
|
||||
cx="18" cy="18" r="14" fill="none"
|
||||
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
|
||||
strokeDasharray={`${item.progress * 0.88} 88`}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "done" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--accent)]
|
||||
text-[var(--bg-primary)] text-[10px] flex items-center justify-center font-bold
|
||||
shadow-[0_0_8px_rgba(46,139,122,0.3)]">
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--hot)]
|
||||
text-white text-[10px] flex items-center justify-center font-bold">
|
||||
!
|
||||
</div>
|
||||
)}
|
||||
{item.status !== "uploading" && (
|
||||
<button
|
||||
onClick={() => removeUpload(item.id)}
|
||||
className="absolute -top-1.5 -left-1.5 w-5 h-5 rounded-full
|
||||
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
text-[var(--text-secondary)] text-xs flex items-center justify-center
|
||||
hover:bg-[var(--hot)] hover:text-white hover:border-[var(--hot)]
|
||||
cursor-pointer transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-col justify-center gap-1 min-w-0">
|
||||
{hasActiveUpload && (
|
||||
<span className="text-xs text-[var(--accent)] flex items-center gap-1.5">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
|
||||
shadow-[0_0_6px_rgba(46,139,122,0.3)]" />
|
||||
上传中...
|
||||
</span>
|
||||
)}
|
||||
{item.status === "uploading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(77,184,164,0.12)" strokeWidth="3" />
|
||||
<circle
|
||||
cx="18" cy="18" r="14" fill="none"
|
||||
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
|
||||
strokeDasharray={`${item.progress * 0.88} 88`}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{allDone && (
|
||||
<span className="text-xs text-[var(--accent)]">
|
||||
{doneUrls.length} 张参考图已就绪
|
||||
</span>
|
||||
)}
|
||||
{item.status === "done" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--accent)]
|
||||
text-[var(--bg-primary)] text-[10px] flex items-center justify-center font-bold
|
||||
shadow-[0_0_8px_rgba(77,184,164,0.3)]">
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-[var(--hot)]
|
||||
text-white text-[10px] flex items-center justify-center font-bold">
|
||||
!
|
||||
</div>
|
||||
)}
|
||||
{item.status !== "uploading" && (
|
||||
<button
|
||||
onClick={() => removeUpload(item.id)}
|
||||
className="absolute -top-1.5 -left-1.5 w-5 h-5 rounded-full
|
||||
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
text-[var(--text-secondary)] text-xs flex items-center justify-center
|
||||
hover:bg-[var(--hot)] hover:text-white hover:border-[var(--hot)]
|
||||
cursor-pointer transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{uploads.some((u) => u.status === "error") && (
|
||||
<span className="text-xs text-[var(--hot)]">
|
||||
{uploads.filter((u) => u.status === "error").length} 张上传失败
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* 汇总状态 */}
|
||||
<div className="flex flex-col justify-center gap-1 min-w-0">
|
||||
{hasActiveUpload && (
|
||||
<span className="text-xs text-[var(--accent)] flex items-center gap-1.5">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
|
||||
shadow-[0_0_6px_rgba(77,184,164,0.3)]" />
|
||||
上传中...
|
||||
</span>
|
||||
)}
|
||||
{allDone && (
|
||||
<span className="text-xs text-[var(--accent)]">
|
||||
{doneUrls.length} 张参考图已就绪
|
||||
</span>
|
||||
)}
|
||||
{uploads.some((u) => u.status === "error") && (
|
||||
<span className="text-xs text-[var(--hot)]">
|
||||
{uploads.filter((u) => u.status === "error").length} 张上传失败
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<ModelSelector value={selectedModel} onChange={setSelectedModel} />
|
||||
|
||||
{/* 上传参考图按钮 */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled || hasActiveUpload}
|
||||
className={`relative flex-shrink-0 w-10 h-10 rounded-xl border
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
||||
hover:bg-[var(--accent)]/5
|
||||
flex items-center justify-center transition-all
|
||||
disabled:opacity-50 cursor-pointer btn-hover-lift ${
|
||||
doneUrls.length > 0
|
||||
? "border-[var(--accent)]/40 text-[var(--accent)]"
|
||||
: "border-[var(--border)]"
|
||||
}`}
|
||||
title="上传参考图(可多选)"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
{doneUrls.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--accent)]
|
||||
text-[var(--bg-primary)] text-[9px] flex items-center justify-center font-bold">
|
||||
{doneUrls.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* 文字输入框 — 水面感应 */}
|
||||
<div className={`relative flex-1 ${isFocused ? "water-focus-active" : ""}`}>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder="描述你想要的美术资源...(可粘贴图片)"
|
||||
title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图"
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="w-full resize-none rounded-xl border border-[var(--border)]
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-primary)]
|
||||
placeholder:text-[var(--text-secondary)]
|
||||
px-4 py-2.5 text-sm leading-relaxed
|
||||
focus:outline-none focus:border-[var(--accent)]/50
|
||||
disabled:opacity-50 transition-all
|
||||
min-h-[42px] max-h-[120px]"
|
||||
style={{
|
||||
fieldSizing: "content",
|
||||
boxShadow: isFocused
|
||||
? `0 0 ${8 + Math.min(text.length, 50) * 0.3}px rgba(46,139,122,${0.04 + Math.min(text.length, 50) * 0.002})`
|
||||
: "none",
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
<div className="water-focus-line" />
|
||||
</div>
|
||||
|
||||
{/* 发送按钮 — 投石入水 */}
|
||||
<motion.button
|
||||
ref={sendBtnRef}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSend}
|
||||
whileTap={canSend ? sendBounce : undefined}
|
||||
className={`flex-shrink-0 w-10 h-10 rounded-xl
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
hover:shadow-[0_0_15px_rgba(46,139,122,0.25)]
|
||||
flex items-center justify-center transition-all
|
||||
disabled:opacity-50 cursor-pointer
|
||||
${canSend ? "breath-pulse" : ""}`}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</svg>
|
||||
</motion.button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<ModelSelector value={selectedModel} onChange={setSelectedModel} />
|
||||
|
||||
{/* 上传参考图按钮 */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled || hasActiveUpload}
|
||||
className={`relative flex-shrink-0 w-10 h-10 rounded-xl border
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] hover:border-[var(--accent)]/40
|
||||
hover:bg-[var(--accent)]/5
|
||||
flex items-center justify-center transition-all
|
||||
disabled:opacity-50 cursor-pointer ${
|
||||
doneUrls.length > 0
|
||||
? "border-[var(--accent)]/40 text-[var(--accent)]"
|
||||
: "border-[var(--border)]"
|
||||
}`}
|
||||
title="上传参考图(可多选)"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
{doneUrls.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--accent)]
|
||||
text-[var(--bg-primary)] text-[9px] flex items-center justify-center font-bold">
|
||||
{doneUrls.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* 文字输入框 */}
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="描述你想要的美术资源...(可粘贴图片)"
|
||||
title="支持 Ctrl+V / 右键粘贴剪贴板图片为参考图"
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border border-[var(--border)]
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-primary)]
|
||||
placeholder:text-[var(--text-secondary)]
|
||||
px-4 py-2.5 text-sm leading-relaxed
|
||||
focus:outline-none focus:border-[var(--accent)]/50
|
||||
focus:shadow-[0_0_10px_rgba(77,184,164,0.06)]
|
||||
disabled:opacity-50 transition-all
|
||||
min-h-[42px] max-h-[120px]"
|
||||
style={{ fieldSizing: "content" } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
{/* 发送按钮 */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !text.trim() || hasActiveUpload}
|
||||
className="flex-shrink-0 w-10 h-10 rounded-xl
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
hover:shadow-[0_0_15px_rgba(77,184,164,0.25)]
|
||||
flex items-center justify-center transition-all
|
||||
disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* 涟漪层 */}
|
||||
<RippleLayer ripples={ripples} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import type { ChatMessage, ImageAsset } from "@/lib/types";
|
||||
import { ImageGrid } from "./image-grid";
|
||||
import { messageEnter, assistantEnter } from "@/components/ui/motion-presets";
|
||||
|
||||
function StreamingLines({ text }: { text: string }) {
|
||||
const lines = useMemo(() => text.split("\n"), [text]);
|
||||
const total = lines.length;
|
||||
return (
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{lines.map((line, i) => {
|
||||
const fromEnd = total - 1 - i;
|
||||
const opacity = fromEnd <= 0 ? 1 : fromEnd === 1 ? 0.85 : fromEnd === 2 ? 0.7 : 0.6;
|
||||
return (
|
||||
<span key={i} style={{ opacity, transition: "opacity 0.3s ease" }}>
|
||||
{line}
|
||||
{i < total - 1 && "\n"}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -21,7 +42,7 @@ function CopyButton({ text }: { text: string }) {
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:bg-[var(--accent)]/5 transition-colors cursor-pointer"
|
||||
hover:bg-[var(--accent)]/5 transition-colors cursor-pointer btn-hover-lift"
|
||||
title="复制文本"
|
||||
>
|
||||
{copied ? (
|
||||
@@ -56,17 +77,19 @@ export function ChatMessages({
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-3 md:px-5 py-4 md:py-6 space-y-4 md:space-y-5">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
<motion.div
|
||||
key={msg.id}
|
||||
variants={msg.role === "user" ? messageEnter : assistantEnter}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className={`group/msg flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
style={{ animation: "slideUp 200ms ease-out" }}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<div
|
||||
className={`max-w-[90vw] md:max-w-[80vw] rounded-2xl px-4 md:px-5 py-3 ${
|
||||
className={`max-w-[90vw] md:max-w-[80vw] px-4 md:px-5 py-3 ${
|
||||
msg.role === "user"
|
||||
? "bg-[var(--accent)]/15 text-[var(--text-primary)] border border-[var(--accent)]/25"
|
||||
: "glass-panel text-[var(--text-primary)]"
|
||||
? "user-bubble bg-gradient-to-r from-[var(--accent)]/8 to-[var(--accent)]/15 text-[var(--text-primary)]"
|
||||
: "rounded-2xl surface-2 text-[var(--text-primary)] border-l-2 border-l-[var(--accent)]/40"
|
||||
}`}
|
||||
>
|
||||
{/* 多图参考(新格式) */}
|
||||
@@ -80,11 +103,11 @@ export function ChatMessages({
|
||||
alt={`参考图 ${i + 1}`}
|
||||
className="max-w-[120px] max-h-[90px] rounded-lg object-cover
|
||||
border border-[var(--accent)]/20
|
||||
shadow-[0_0_10px_rgba(77,184,164,0.08)]"
|
||||
shadow-[0_0_10px_rgba(46,139,122,0.08)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block">
|
||||
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block typo-micro" style={{ textTransform: "none" }}>
|
||||
参考图({msg.refImageUrls.length} 张)
|
||||
</span>
|
||||
</div>
|
||||
@@ -97,7 +120,7 @@ export function ChatMessages({
|
||||
alt="参考图"
|
||||
className="max-w-[160px] max-h-[120px] rounded-lg object-cover
|
||||
border border-[var(--accent)]/20
|
||||
shadow-[0_0_10px_rgba(77,184,164,0.08)]"
|
||||
shadow-[0_0_10px_rgba(46,139,122,0.08)]"
|
||||
/>
|
||||
<span className="text-[10px] text-[var(--accent)]/60 mt-1 block">参考图</span>
|
||||
</div>
|
||||
@@ -107,7 +130,7 @@ export function ChatMessages({
|
||||
<>
|
||||
<ImageGrid images={msg.images} />
|
||||
{msg.modelName && (
|
||||
<div className="mt-1.5 text-[10px] text-[var(--accent)]/50">
|
||||
<div className="mt-1.5 text-[10px] text-[var(--accent)]/50 typo-caption">
|
||||
由 {msg.modelName} 生成
|
||||
</div>
|
||||
)}
|
||||
@@ -121,33 +144,43 @@ export function ChatMessages({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-start" style={{ animation: "slideUp 200ms ease-out" }}>
|
||||
<div className="max-w-[90%] md:max-w-[80%] rounded-2xl px-4 md:px-5 py-3 glass-panel">
|
||||
{statusText && (
|
||||
<div className="text-xs text-[var(--accent)] mb-2 flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
|
||||
shadow-[0_0_8px_rgba(77,184,164,0.4)]" />
|
||||
{statusText}
|
||||
</div>
|
||||
)}
|
||||
{streamingText && (
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed">{streamingText}</div>
|
||||
)}
|
||||
{streamingImages.length > 0 && <ImageGrid images={streamingImages} />}
|
||||
{!streamingText && !statusText && (
|
||||
<div className="flex gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--accent)]/60 animate-bounce" />
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--accent)]/60 animate-bounce [animation-delay:0.1s]" />
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--accent)]/60 animate-bounce [animation-delay:0.2s]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* AI 正在回复(流式) */}
|
||||
<AnimatePresence>
|
||||
{isLoading && (
|
||||
<motion.div
|
||||
key="streaming"
|
||||
variants={assistantEnter}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
className="flex justify-start"
|
||||
>
|
||||
<div className="max-w-[90%] md:max-w-[80%] rounded-2xl px-4 md:px-5 py-3 surface-2 border-l-2 border-l-[var(--accent)]/40">
|
||||
{statusText && (
|
||||
<div className="text-xs text-[var(--accent)] mb-2 flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse
|
||||
shadow-[0_0_8px_rgba(46,139,122,0.4)]" />
|
||||
{statusText}
|
||||
</div>
|
||||
)}
|
||||
{streamingText && (
|
||||
<StreamingLines text={streamingText} />
|
||||
)}
|
||||
{streamingImages.length > 0 && <ImageGrid images={streamingImages} />}
|
||||
{!streamingText && !statusText && (
|
||||
<div className="flex items-end gap-1 h-5">
|
||||
<div className="wave-dot" style={{ animationDelay: "0s" }} />
|
||||
<div className="wave-dot" style={{ animationDelay: "0.15s" }} />
|
||||
<div className="wave-dot" style={{ animationDelay: "0.3s" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { getImageUrl } from "@/lib/api";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { useFireflyBurst, FireflyBurst } from "@/components/ui/firefly-burst";
|
||||
import { inkReveal, staggerContainer, staggerItem } from "@/components/ui/motion-presets";
|
||||
import type { ImageAsset } from "@/lib/types";
|
||||
|
||||
interface ImageGridProps {
|
||||
images: ImageAsset[];
|
||||
}
|
||||
|
||||
function FavoriteButton({ asset }: { asset: ImageAsset }) {
|
||||
const { toggleFavorite } = useApp();
|
||||
const { sparks, burst } = useFireflyBurst();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!asset.favorited) burst();
|
||||
toggleFavorite(asset.id);
|
||||
}}
|
||||
className="relative p-1.5 rounded-lg cursor-pointer transition-colors
|
||||
hover:bg-[var(--accent)]/10"
|
||||
style={{ color: asset.favorited ? "var(--gold)" : "rgba(255,255,255,0.5)" }}
|
||||
title={asset.favorited ? "取消收藏" : "收藏"}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
<FireflyBurst sparks={sparks} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageGrid({ images }: ImageGridProps) {
|
||||
const { setDetailImage, toggleFavorite } = useApp();
|
||||
const { setDetailImage } = useApp();
|
||||
|
||||
const handleDownload = async (imageUrl: string, index: number) => {
|
||||
try {
|
||||
@@ -35,21 +63,29 @@ export function ImageGrid({ images }: ImageGridProps) {
|
||||
: "grid-cols-2 max-w-[320px] md:max-w-xl";
|
||||
|
||||
return (
|
||||
<div className={`grid ${gridCols} gap-3 my-3`}>
|
||||
<motion.div
|
||||
className={`grid ${gridCols} gap-3 my-3`}
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{images.map((asset, i) => (
|
||||
<div
|
||||
<motion.div
|
||||
key={asset.id}
|
||||
variants={staggerItem}
|
||||
className="group relative rounded-xl overflow-hidden neon-border
|
||||
bg-[var(--bg-card)] backdrop-blur-sm"
|
||||
>
|
||||
<img
|
||||
src={getImageUrl(asset.url)}
|
||||
alt={`生成图片 ${i + 1}`}
|
||||
className="w-full aspect-square object-cover cursor-pointer
|
||||
transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
onClick={() => setDetailImage(asset)}
|
||||
/>
|
||||
<ImageReveal src={getImageUrl(asset.url)} alt={`生成图片 ${i + 1}`}>
|
||||
<img
|
||||
src={getImageUrl(asset.url)}
|
||||
alt={`生成图片 ${i + 1}`}
|
||||
className="w-full aspect-square object-cover cursor-pointer
|
||||
transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
onClick={() => setDetailImage(asset)}
|
||||
/>
|
||||
</ImageReveal>
|
||||
|
||||
{/* 底部渐变 + 操作栏 */}
|
||||
<div
|
||||
@@ -79,34 +115,47 @@ export function ImageGrid({ images }: ImageGridProps) {
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(asset.id);
|
||||
}}
|
||||
className="p-1.5 rounded-lg cursor-pointer transition-colors
|
||||
hover:bg-[var(--accent)]/10"
|
||||
style={{ color: asset.favorited ? "var(--accent)" : "rgba(255,255,255,0.5)" }}
|
||||
title={asset.favorited ? "取消收藏" : "收藏"}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
</button>
|
||||
<FavoriteButton asset={asset} />
|
||||
</div>
|
||||
|
||||
{/* 最新生成标签 */}
|
||||
{i === 0 && images.length > 1 && (
|
||||
<div className="absolute top-2 left-2">
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-md
|
||||
<span className="typo-micro px-2 py-0.5 rounded-md
|
||||
bg-[var(--accent)]/20 text-[var(--accent)]
|
||||
border border-[var(--accent)]/30 backdrop-blur-sm">
|
||||
border border-[var(--accent)]/30 backdrop-blur-sm"
|
||||
style={{ textTransform: "uppercase" }}>
|
||||
New
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageReveal({ src, alt, children }: { src: string; alt: string; children: React.ReactNode }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{!loaded && (
|
||||
<div className="w-full aspect-square stone-placeholder" />
|
||||
)}
|
||||
<motion.div
|
||||
variants={inkReveal}
|
||||
initial="hidden"
|
||||
animate={loaded ? "visible" : "hidden"}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="hidden"
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import type { ImageModelInfo } from "@/lib/types";
|
||||
import { fetchModels } from "@/lib/api";
|
||||
import { slideUp } from "@/components/ui/motion-presets";
|
||||
|
||||
import { getStoreUserId } from "@/lib/store";
|
||||
|
||||
@@ -61,7 +63,7 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:border-[var(--accent)]/40 hover:bg-[var(--accent)]/5
|
||||
transition-all cursor-pointer"
|
||||
transition-all cursor-pointer btn-hover-lift"
|
||||
title="切换生图模型"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
@@ -79,49 +81,56 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-1.5 w-56 rounded-xl
|
||||
glass-panel shadow-xl shadow-black/40 overflow-hidden z-50"
|
||||
>
|
||||
{models.map((m) => {
|
||||
const isActive = m.id === value;
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => {
|
||||
onChange(m.id);
|
||||
localStorage.setItem(getModelStorageKey(), m.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3.5 py-2.5 flex flex-col gap-0.5
|
||||
transition-all cursor-pointer
|
||||
${isActive
|
||||
? "bg-[var(--accent)]/8 text-[var(--accent)]"
|
||||
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium flex items-center gap-1.5">
|
||||
{m.name}
|
||||
{m.supports_ref_image && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full
|
||||
bg-[var(--accent)]/10 text-[var(--accent)] font-medium leading-none
|
||||
border border-[var(--accent)]/20">
|
||||
参考图
|
||||
</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-secondary)]">{m.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
|
||||
className="absolute bottom-full left-0 mb-1.5 w-56 rounded-xl
|
||||
surface-3 overflow-hidden z-50"
|
||||
>
|
||||
{models.map((m) => {
|
||||
const isActive = m.id === value;
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => {
|
||||
onChange(m.id);
|
||||
localStorage.setItem(getModelStorageKey(), m.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3.5 py-2.5 flex flex-col gap-0.5
|
||||
transition-all cursor-pointer
|
||||
${isActive
|
||||
? "bg-[var(--accent)]/8 text-[var(--accent)]"
|
||||
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium flex items-center gap-1.5">
|
||||
{m.name}
|
||||
{m.supports_ref_image && (
|
||||
<span className="typo-micro px-1.5 py-0.5 rounded-full
|
||||
bg-[var(--accent)]/10 text-[var(--accent)] leading-none
|
||||
border border-[var(--accent)]/20"
|
||||
style={{ textTransform: "none", fontSize: "10px" }}>
|
||||
参考图
|
||||
</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className="typo-caption">{m.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { getImageUrl } from "@/lib/api";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { AnnotationCanvas } from "./annotation-canvas";
|
||||
import { useFireflyBurst, FireflyBurst } from "@/components/ui/firefly-burst";
|
||||
import { slideUp } from "@/components/ui/motion-presets";
|
||||
import type { Annotation, AnnotationData } from "@/lib/types";
|
||||
|
||||
interface ImageDetailPanelProps {
|
||||
@@ -14,6 +17,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
const { detailImage, setDetailImage, toggleFavorite, tags, deleteAssetById } = useApp();
|
||||
const [scale, setScale] = useState(1);
|
||||
const [showAnnotate, setShowAnnotate] = useState(false);
|
||||
const { sparks, burst } = useFireflyBurst();
|
||||
|
||||
if (!detailImage) return null;
|
||||
|
||||
@@ -67,10 +71,15 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
|
||||
<motion.aside
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
|
||||
md:w-[360px] flex-shrink-0 border-l border-[var(--border)]
|
||||
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
|
||||
flex flex-col overflow-hidden">
|
||||
flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">图片详情</span>
|
||||
@@ -103,7 +112,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}
|
||||
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] border border-[var(--border)]
|
||||
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
|
||||
hover:border-[var(--accent)]/40 cursor-pointer transition-all btn-hover-lift"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
@@ -114,7 +123,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
onClick={() => setScale((s) => Math.min(3, s + 0.25))}
|
||||
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] border border-[var(--border)]
|
||||
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
|
||||
hover:border-[var(--accent)]/40 cursor-pointer transition-all btn-hover-lift"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
@@ -122,7 +131,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
onClick={() => setScale(1)}
|
||||
className="text-xs px-2.5 py-1 rounded-lg bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] border border-[var(--border)]
|
||||
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
|
||||
hover:border-[var(--accent)]/40 cursor-pointer transition-all btn-hover-lift"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
@@ -135,8 +144,8 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(77,184,164,0.25)]
|
||||
transition-all cursor-pointer"
|
||||
hover:bg-[var(--accent-hover)] hover:shadow-[0_0_12px_rgba(46,139,122,0.25)]
|
||||
transition-all cursor-pointer btn-hover-lift"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
@@ -144,11 +153,14 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleFavorite(detailImage.id)}
|
||||
className={`flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border transition-all cursor-pointer ${
|
||||
onClick={() => {
|
||||
if (!detailImage.favorited) burst();
|
||||
toggleFavorite(detailImage.id);
|
||||
}}
|
||||
className={`relative flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
border transition-all cursor-pointer btn-hover-lift ${
|
||||
detailImage.favorited
|
||||
? "border-[var(--accent)]/40 bg-[var(--accent)]/8 text-[var(--accent)]"
|
||||
? "border-[var(--gold)]/40 bg-[var(--gold)]/8 text-[var(--gold)]"
|
||||
: "border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
@@ -156,13 +168,14 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
{detailImage.favorited ? "已收藏" : "收藏"}
|
||||
<FireflyBurst sparks={sparks} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAnnotate(true)}
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] border border-[var(--border)]
|
||||
hover:border-[var(--accent)]/40 transition-all cursor-pointer"
|
||||
hover:border-[var(--accent)]/40 transition-all cursor-pointer btn-hover-lift"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
@@ -174,7 +187,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--bg-tertiary)] text-[var(--hot)]/60
|
||||
hover:text-[var(--hot)] border border-[var(--border)]
|
||||
hover:border-[var(--hot)]/40 transition-all cursor-pointer"
|
||||
hover:border-[var(--hot)]/40 transition-all cursor-pointer btn-hover-lift"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
@@ -188,7 +201,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
{detailImage.prompt && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)]">Prompt</span>
|
||||
<span className="typo-caption font-medium">Prompt</span>
|
||||
<button
|
||||
onClick={handleCopyPrompt}
|
||||
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
@@ -207,7 +220,7 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
{/* 标签 */}
|
||||
{detailImage.tags.length > 0 && (
|
||||
<div className="px-4 pb-3">
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5">标签</span>
|
||||
<span className="typo-caption font-medium block mb-1.5">标签</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{detailImage.tags.map((tid) => {
|
||||
const tag = tagMap.get(tid);
|
||||
@@ -228,12 +241,12 @@ export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="px-4 pb-4">
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5">信息</span>
|
||||
<span className="typo-caption font-medium block mb-1.5">信息</span>
|
||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>创建时间:{new Date(detailImage.createdAt).toLocaleString("zh-CN")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { ProfileModal } from "@/components/profile-modal";
|
||||
import { slideUp } from "@/components/ui/motion-presets";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{
|
||||
@@ -51,7 +53,7 @@ export function TopNav() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="relative z-10 flex-shrink-0 h-14 border-b border-[var(--border)] bg-[var(--bg-secondary)]/80 backdrop-blur-xl flex items-center px-4 md:px-5 gap-4 md:gap-6">
|
||||
<header className="relative z-10 flex-shrink-0 h-14 border-b border-[var(--border)] surface-1 backdrop-blur-xl flex items-center px-4 md:px-5 gap-4 md:gap-6">
|
||||
{/* 移动端汉堡菜单 */}
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
@@ -68,13 +70,13 @@ export function TopNav() {
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-2.5 flex-shrink-0 group">
|
||||
<div className="w-8 h-8 rounded-lg bg-[var(--accent)] flex items-center justify-center
|
||||
shadow-[0_0_12px_rgba(77,184,164,0.25)]
|
||||
group-hover:shadow-[0_0_20px_rgba(77,184,164,0.4)] transition-shadow">
|
||||
shadow-[0_0_12px_rgba(46,139,122,0.25)]
|
||||
group-hover:shadow-[0_0_20px_rgba(46,139,122,0.4)] transition-shadow">
|
||||
<svg width="18" height="18" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="#0C1210" />
|
||||
<circle cx="23" cy="12" r="3.5" stroke="#0C1210" strokeWidth="1.8" fill="none" />
|
||||
<path d="M23 15.5v5" stroke="#0C1210" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<circle cx="23" cy="22.5" r="1" fill="#0C1210" />
|
||||
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="white" />
|
||||
<circle cx="23" cy="12" r="3.5" stroke="white" strokeWidth="1.8" fill="none" />
|
||||
<path d="M23 15.5v5" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<circle cx="23" cy="22.5" r="1" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-base font-bold tracking-tight text-[var(--text-primary)]">
|
||||
@@ -90,10 +92,10 @@ export function TopNav() {
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`relative flex items-center gap-1.5 px-3 md:px-4 py-2 text-sm rounded-lg ${
|
||||
className={`relative flex items-center gap-1.5 px-3 md:px-4 py-2 text-sm rounded-lg transition-all ${
|
||||
active
|
||||
? "glow-border text-[var(--accent)] font-medium !bg-[var(--bg-secondary)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-transparent hover:border-[var(--border-glow)] transition-all"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-transparent hover:border-[var(--border-glow)]"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
@@ -123,33 +125,41 @@ export function TopNav() {
|
||||
{user.display_name || user.username}
|
||||
</span>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 top-full mt-1.5 w-48 rounded-xl
|
||||
glass-panel shadow-xl shadow-black/30 py-1.5 z-50">
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); setProfileOpen(true); }}
|
||||
className="w-full text-left px-3.5 py-2 text-xs text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] hover:bg-[var(--bg-tertiary)]
|
||||
border-b border-[var(--border)] transition-colors cursor-pointer
|
||||
flex items-center gap-1.5"
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
|
||||
className="absolute right-0 top-full mt-1.5 w-48 rounded-xl
|
||||
surface-3 py-1.5 z-50"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<span>{user.username}</span>
|
||||
{user.is_admin && <span className="text-[var(--accent)]">(管理员)</span>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); logout(); }}
|
||||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]
|
||||
transition-colors cursor-pointer"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); setProfileOpen(true); }}
|
||||
className="w-full text-left px-3.5 py-2 text-xs text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] hover:bg-[var(--bg-tertiary)]
|
||||
border-b border-[var(--border)] transition-colors cursor-pointer
|
||||
flex items-center gap-1.5"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<span>{user.username}</span>
|
||||
{user.is_admin && <span className="text-[var(--accent)]">(管理员)</span>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); logout(); }}
|
||||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]
|
||||
transition-colors cursor-pointer"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useAuth, type AuthUser } from "@/lib/auth-context";
|
||||
import { motion } from "framer-motion";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { slideUp } from "@/components/ui/motion-presets";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
@@ -107,14 +109,20 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* 遮罩层 */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
{/* 弹窗主体 */}
|
||||
<div
|
||||
className="relative w-[90vw] max-w-[560px] max-h-[80vh] rounded-2xl glass-panel
|
||||
shadow-[0_0_40px_rgba(77,184,164,0.1)] border-[var(--border-glow)]
|
||||
<motion.div
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="relative w-[90vw] max-w-[560px] max-h-[80vh] rounded-2xl surface-3
|
||||
flex flex-col overflow-hidden"
|
||||
style={{ animation: "slideUp 200ms ease-out" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
@@ -133,18 +141,19 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
||||
<div className="px-6 pt-6 pb-4 flex items-center gap-4 flex-shrink-0">
|
||||
<div className="w-14 h-14 rounded-full bg-[var(--accent)]/15 border-2 border-[var(--accent)]/40
|
||||
flex items-center justify-center text-[var(--accent)] text-xl font-bold
|
||||
shadow-[0_0_20px_rgba(77,184,164,0.12)]">
|
||||
shadow-[0_0_20px_rgba(46,139,122,0.12)]">
|
||||
{(user.display_name || user.username).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)] truncate">
|
||||
<h2 className="typo-h1 truncate">
|
||||
{user.display_name || user.username}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-sm text-[var(--text-secondary)]">@{user.username}</span>
|
||||
<span className="typo-caption">@{user.username}</span>
|
||||
{user.is_admin && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-md bg-[var(--accent)]/10
|
||||
text-[var(--accent)] border border-[var(--accent)]/30">
|
||||
<span className="typo-micro px-1.5 py-0.5 rounded-md bg-[var(--accent)]/10
|
||||
text-[var(--accent)] border border-[var(--accent)]/30"
|
||||
style={{ textTransform: "none" }}>
|
||||
管理员
|
||||
</span>
|
||||
)}
|
||||
@@ -152,7 +161,6 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分割线 */}
|
||||
<div className="mx-6 h-px bg-[var(--border)]" />
|
||||
|
||||
{/* 记忆区标题 */}
|
||||
@@ -161,9 +169,9 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
||||
<path d="M12 2a7 7 0 017 7c0 2.38-1.19 4.47-3 5.74V17a2 2 0 01-2 2h-4a2 2 0 01-2-2v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 017-7z" />
|
||||
<path d="M10 21h4" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">记忆系统</span>
|
||||
<span className="typo-strong text-sm">记忆系统</span>
|
||||
{!loading && (
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
<span className="typo-caption">
|
||||
{memories.length} 条记录
|
||||
</span>
|
||||
)}
|
||||
@@ -173,14 +181,17 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6 min-h-0">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-5 h-5 border-2 border-[var(--accent)]/30 border-t-[var(--accent)]
|
||||
rounded-full animate-spin" />
|
||||
<div className="flex items-end gap-1 h-5">
|
||||
<div className="wave-dot" style={{ animationDelay: "0s" }} />
|
||||
<div className="wave-dot" style={{ animationDelay: "0.15s" }} />
|
||||
<div className="wave-dot" style={{ animationDelay: "0.3s" }} />
|
||||
</div>
|
||||
<span className="ml-3 text-sm text-[var(--text-secondary)]">加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && memories.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-center py-12 error-shake">
|
||||
<p className="text-sm text-[var(--hot)]">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -193,7 +204,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
||||
<path d="M10 21h4" />
|
||||
</svg>
|
||||
<p className="text-sm text-[var(--text-secondary)]">暂无记忆记录</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1 opacity-60">
|
||||
<p className="typo-caption mt-1 opacity-60">
|
||||
与助手对话时,系统会自动记录关键信息
|
||||
</p>
|
||||
</div>
|
||||
@@ -201,7 +212,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
{!loading && groups.map((group) => (
|
||||
<div key={group.label} className="mb-4 last:mb-0">
|
||||
<div className="text-xs font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-2">
|
||||
<div className="typo-micro mb-2">
|
||||
{group.label}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -216,7 +227,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
||||
{item.memory}
|
||||
</p>
|
||||
{item.created_at && (
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1 opacity-0
|
||||
<p className="typo-caption mt-1 opacity-0
|
||||
group-hover:opacity-60 transition-opacity">
|
||||
{formatDate(item.created_at)}
|
||||
</p>
|
||||
@@ -227,7 +238,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { getImageUrl, fetchLlmModels } from "@/lib/api";
|
||||
import { slideUp } from "@/components/ui/motion-presets";
|
||||
import type { LlmModelInfo } from "@/lib/types";
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
@@ -111,7 +113,7 @@ export function SessionList() {
|
||||
text-sm rounded-xl border border-dashed border-[var(--accent)]/30
|
||||
text-[var(--accent)]/70 hover:border-[var(--accent)]
|
||||
hover:text-[var(--accent)] hover:bg-[var(--accent)]/5
|
||||
transition-all cursor-pointer"
|
||||
transition-all cursor-pointer btn-hover-lift"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
@@ -127,7 +129,7 @@ export function SessionList() {
|
||||
<div
|
||||
onClick={() => switchSession(session.id)}
|
||||
className={`group relative flex items-start gap-3 px-3 py-2.5 rounded-xl cursor-pointer
|
||||
transition-all ${
|
||||
transition-all session-hover-line ${
|
||||
session.id === activeSessionId
|
||||
? "glow-border !bg-[var(--bg-secondary)]"
|
||||
: "hover:bg-[var(--bg-tertiary)]/50 border border-transparent"
|
||||
@@ -139,7 +141,7 @@ export function SessionList() {
|
||||
src={getImageUrl(session.thumbnail)}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-lg object-cover flex-shrink-0 border border-[var(--border)]
|
||||
shadow-[0_0_8px_rgba(77,184,164,0.08)]"
|
||||
shadow-[0_0_8px_rgba(46,139,122,0.08)]"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--bg-primary)] border border-[var(--border)]
|
||||
@@ -181,8 +183,8 @@ export function SessionList() {
|
||||
return (
|
||||
<span
|
||||
key={tid}
|
||||
className="text-[10px] px-1.5 py-px rounded-full font-medium"
|
||||
style={{ backgroundColor: tag.color + "22", color: tag.color }}
|
||||
className="typo-micro px-1.5 py-px rounded-full"
|
||||
style={{ backgroundColor: tag.color + "22", color: tag.color, textTransform: "none", fontSize: "10px" }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
@@ -194,7 +196,7 @@ export function SessionList() {
|
||||
{llmModels.find((m) => m.id === session.llmModel)?.name || session.llmModel}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-[var(--text-secondary)] ml-auto flex-shrink-0">
|
||||
<span className="typo-caption ml-auto flex-shrink-0" style={{ fontSize: "10px" }}>
|
||||
{timeAgo(session.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -216,95 +218,100 @@ export function SessionList() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 下拉菜单:在会话项正下方展开 */}
|
||||
{menuSessionId === session.id && menuSession && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="mx-1 mt-1 glass-panel rounded-xl shadow-xl shadow-black/30 py-1.5
|
||||
overflow-hidden z-10 relative"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 模型选择(折叠/展开式) */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModelSub(!showModelSub);
|
||||
}}
|
||||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-primary)]
|
||||
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors
|
||||
flex items-center justify-between"
|
||||
{/* 下拉菜单 */}
|
||||
<AnimatePresence>
|
||||
{menuSessionId === session.id && menuSession && (
|
||||
<motion.div
|
||||
ref={menuRef}
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
|
||||
className="mx-1 mt-1 surface-3 rounded-xl py-1.5
|
||||
overflow-hidden z-10 relative"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>对话模型</span>
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2"
|
||||
className={`transition-transform ${showModelSub ? "rotate-90" : ""}`}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModelSub(!showModelSub);
|
||||
}}
|
||||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-primary)]
|
||||
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors
|
||||
flex items-center justify-between"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<span>对话模型</span>
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2"
|
||||
className={`transition-transform ${showModelSub ? "rotate-90" : ""}`}
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showModelSub && (
|
||||
<div className="border-t border-[var(--border)] mx-2 mt-1 pt-1 max-h-[240px] overflow-y-auto">
|
||||
{llmModels.map((model) => {
|
||||
const isActive = menuSession.llmModel
|
||||
? menuSession.llmModel === model.id
|
||||
: model.id === defaultLlmModel;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelectModel(menuSessionId!, model.id);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm cursor-pointer transition-colors
|
||||
rounded-lg flex items-center gap-2 ${
|
||||
isActive
|
||||
? "text-[var(--accent)] bg-[var(--accent)]/5"
|
||||
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 flex-shrink-0 text-center">
|
||||
{isActive && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{model.name}</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] truncate">{model.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{showModelSub && (
|
||||
<div className="border-t border-[var(--border)] mx-2 mt-1 pt-1 max-h-[240px] overflow-y-auto">
|
||||
{llmModels.map((model) => {
|
||||
const isActive = menuSession.llmModel
|
||||
? menuSession.llmModel === model.id
|
||||
: model.id === defaultLlmModel;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelectModel(menuSessionId!, model.id);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm cursor-pointer transition-colors
|
||||
rounded-lg flex items-center gap-2 ${
|
||||
isActive
|
||||
? "text-[var(--accent)] bg-[var(--accent)]/5"
|
||||
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 flex-shrink-0 text-center">
|
||||
{isActive && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{model.name}</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] truncate">{model.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-2 my-1 border-t border-[var(--border)]" />
|
||||
<div className="mx-2 my-1 border-t border-[var(--border)]" />
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (menuSession) startRename(menuSession.id, menuSession.title);
|
||||
}}
|
||||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-primary)]
|
||||
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors"
|
||||
>
|
||||
重命名
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (menuSession) startRename(menuSession.id, menuSession.title);
|
||||
}}
|
||||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-primary)]
|
||||
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors"
|
||||
>
|
||||
重命名
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteSession(menuSessionId!);
|
||||
closeMenu();
|
||||
}}
|
||||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--hot)]
|
||||
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteSession(menuSessionId!);
|
||||
closeMenu();
|
||||
}}
|
||||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--hot)]
|
||||
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export function Sidebar() {
|
||||
fixed inset-y-0 left-0 z-40 w-[280px]
|
||||
md:relative md:inset-auto md:z-auto
|
||||
flex-shrink-0 border-r border-[var(--border)]
|
||||
bg-[var(--bg-secondary)]/80 backdrop-blur-xl
|
||||
surface-1 backdrop-blur-xl
|
||||
flex flex-col transition-transform duration-250 ease-in-out
|
||||
${sidebarCollapsed ? "-translate-x-full md:-translate-x-0 md:w-0 md:border-r-0" : "translate-x-0 md:w-[280px]"}
|
||||
${sidebarCollapsed ? "md:overflow-hidden" : ""}
|
||||
@@ -31,10 +31,10 @@ export function Sidebar() {
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2"
|
||||
style={{ filter: "drop-shadow(0 0 4px rgba(77, 184, 164, 0.35))" }}>
|
||||
style={{ filter: "drop-shadow(0 0 4px rgba(46, 139, 122, 0.35))" }}>
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
<span className="typo-strong text-sm">
|
||||
对话
|
||||
</span>
|
||||
</div>
|
||||
|
||||
78
art-agent/frontend/src/components/ui/ambient-particles.tsx
Normal file
78
art-agent/frontend/src/components/ui/ambient-particles.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface Particle {
|
||||
id: number;
|
||||
left: string;
|
||||
top: string;
|
||||
size: number;
|
||||
delay: string;
|
||||
duration: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
function generateParticles(count: number): Particle[] {
|
||||
const particles: Particle[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
particles.push({
|
||||
id: i,
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: `${Math.random() * 100}%`,
|
||||
size: 2 + Math.random() * 3,
|
||||
delay: `${Math.random() * 20}s`,
|
||||
duration: `${15 + Math.random() * 25}s`,
|
||||
opacity: 0.12 + Math.random() * 0.18,
|
||||
});
|
||||
}
|
||||
return particles;
|
||||
}
|
||||
|
||||
interface AmbientParticlesProps {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export function AmbientParticles({ count = 18 }: AmbientParticlesProps) {
|
||||
const particles = useMemo(() => generateParticles(count), [count]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none z-0 overflow-hidden" aria-hidden="true">
|
||||
{/* 远景雾 */}
|
||||
<div className="fog-layer-far" />
|
||||
{/* 近景雾 */}
|
||||
<div className="fog-layer-near" />
|
||||
{/* 萤火粒子 */}
|
||||
{particles.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
left: p.left,
|
||||
top: p.top,
|
||||
width: p.size,
|
||||
height: p.size,
|
||||
backgroundColor: "var(--accent)",
|
||||
opacity: p.opacity,
|
||||
animation: `particleDrift ${p.duration} ease-in-out infinite, particleFlicker 4s ease-in-out infinite`,
|
||||
animationDelay: `${p.delay}, ${parseFloat(p.delay) + 1}s`,
|
||||
boxShadow: `0 0 ${p.size * 3}px rgba(46, 139, 122, ${p.opacity * 0.6})`,
|
||||
willChange: "transform, opacity",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<style>{`
|
||||
@keyframes particleDrift {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(${20 + Math.random() * 30}px, -${15 + Math.random() * 20}px); }
|
||||
50% { transform: translate(-${10 + Math.random() * 20}px, ${10 + Math.random() * 25}px); }
|
||||
75% { transform: translate(${15 + Math.random() * 20}px, ${5 + Math.random() * 15}px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
@keyframes particleFlicker {
|
||||
0%, 100% { opacity: var(--particle-base-opacity, 0.15); }
|
||||
50% { opacity: calc(var(--particle-base-opacity, 0.15) * 2.2); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
art-agent/frontend/src/components/ui/data-stream.tsx
Normal file
20
art-agent/frontend/src/components/ui/data-stream.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
interface DataStreamProps {
|
||||
active: boolean;
|
||||
speed?: "slow" | "normal" | "fast";
|
||||
}
|
||||
|
||||
export function DataStream({ active, speed = "normal" }: DataStreamProps) {
|
||||
if (!active) return null;
|
||||
|
||||
const speedClass =
|
||||
speed === "slow" ? "data-stream-slow" :
|
||||
speed === "fast" ? "data-stream-fast" : "";
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-hidden" aria-hidden="true">
|
||||
<div className={`data-stream ${speedClass}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
art-agent/frontend/src/components/ui/firefly-burst.tsx
Normal file
62
art-agent/frontend/src/components/ui/firefly-burst.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { sparkleAnim } from "./motion-presets";
|
||||
|
||||
interface Spark {
|
||||
id: number;
|
||||
angle: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
let _sparkId = 0;
|
||||
|
||||
export function useFireflyBurst() {
|
||||
const [sparks, setSparks] = useState<Spark[]>([]);
|
||||
|
||||
const burst = useCallback(() => {
|
||||
const count = 5 + Math.floor(Math.random() * 3);
|
||||
const newSparks: Spark[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
newSparks.push({
|
||||
id: ++_sparkId,
|
||||
angle: (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.5,
|
||||
distance: 12 + Math.random() * 18,
|
||||
});
|
||||
}
|
||||
setSparks(newSparks);
|
||||
setTimeout(() => setSparks([]), 1000);
|
||||
}, []);
|
||||
|
||||
return { sparks, burst };
|
||||
}
|
||||
|
||||
interface FireflyBurstProps {
|
||||
sparks: Spark[];
|
||||
}
|
||||
|
||||
export function FireflyBurst({ sparks }: FireflyBurstProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{sparks.map((s) => (
|
||||
<motion.div
|
||||
key={s.id}
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--gold)",
|
||||
boxShadow: "0 0 6px var(--gold)",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
marginLeft: -2,
|
||||
marginTop: -2,
|
||||
}}
|
||||
animate={sparkleAnim(s.angle, s.distance)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
113
art-agent/frontend/src/components/ui/motion-presets.ts
Normal file
113
art-agent/frontend/src/components/ui/motion-presets.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { Transition, Variants } from "framer-motion";
|
||||
|
||||
/* ===== Spring 物理参数 ===== */
|
||||
export const spring: Transition = { type: "spring", stiffness: 400, damping: 25 };
|
||||
export const springGentle: Transition = { type: "spring", stiffness: 200, damping: 20 };
|
||||
|
||||
/* ===== 通用入场变体 ===== */
|
||||
export const fadeIn: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { duration: 0.2 } },
|
||||
};
|
||||
|
||||
export const slideUp: Variants = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.2, ease: "easeOut" } },
|
||||
};
|
||||
|
||||
export const mistReveal: Variants = {
|
||||
hidden: { opacity: 0, filter: "blur(8px)" },
|
||||
visible: { opacity: 1, filter: "blur(0px)", transition: { duration: 0.4, ease: "easeOut" } },
|
||||
};
|
||||
|
||||
/* ===== 消息入场 ===== */
|
||||
export const messageEnter: Variants = {
|
||||
hidden: { opacity: 0, y: 12, scale: 0.98 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0.25, ease: "easeOut" },
|
||||
},
|
||||
};
|
||||
|
||||
export const assistantEnter: Variants = {
|
||||
hidden: { opacity: 0, filter: "blur(8px)", y: 6 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
y: 0,
|
||||
transition: { duration: 0.4, ease: "easeOut" },
|
||||
},
|
||||
};
|
||||
|
||||
/* ===== 按钮反馈 ===== */
|
||||
export const buttonTap = { scale: 0.85 };
|
||||
export const sendBounce = {
|
||||
scale: [1, 0.85, 1.08, 1],
|
||||
transition: { duration: 0.35, ease: "easeInOut" },
|
||||
};
|
||||
|
||||
/* ===== 涟漪 ===== */
|
||||
export const rippleAnim = {
|
||||
scale: [0, 2.5],
|
||||
opacity: [0.4, 0],
|
||||
transition: { duration: 0.6, ease: "easeOut" as const },
|
||||
};
|
||||
|
||||
/* ===== 萤火粒子 ===== */
|
||||
export const sparkleAnim = (angle: number, distance: number) => ({
|
||||
x: [0, Math.cos(angle) * distance],
|
||||
y: [0, Math.sin(angle) * distance],
|
||||
scale: [0, 1.2, 0],
|
||||
opacity: [0, 1, 0],
|
||||
transition: { duration: 0.7 + Math.random() * 0.3, ease: "easeOut" as const },
|
||||
});
|
||||
|
||||
/* ===== 滚动到底部按钮 — 落叶归根 ===== */
|
||||
export const scrollBtnEnter: Variants = {
|
||||
hidden: { opacity: 0, y: 20, scale: 0.8 },
|
||||
visible: { opacity: 1, y: 0, scale: 1, transition: { ...springGentle } },
|
||||
exit: { opacity: 0, y: 10, scale: 0.8, transition: { duration: 0.15 } },
|
||||
};
|
||||
export const scrollBtnClick = {
|
||||
y: [-0, -6, 20],
|
||||
opacity: [1, 1, 0],
|
||||
scale: [1, 1.1, 0.8],
|
||||
transition: { duration: 0.35, ease: "easeIn" as const },
|
||||
};
|
||||
|
||||
/* ===== 会话切换 — 翻山越岭 ===== */
|
||||
export const sessionSlide: Variants = {
|
||||
enter: (dir: number) => ({ opacity: 0, y: dir > 0 ? 30 : -30 }),
|
||||
center: { opacity: 1, y: 0, transition: { duration: 0.25, ease: "easeOut" } },
|
||||
exit: (dir: number) => ({ opacity: 0, y: dir > 0 ? -30 : 30, transition: { duration: 0.2, ease: "easeIn" } }),
|
||||
};
|
||||
|
||||
/* ===== Error 飘散消失 ===== */
|
||||
export const errorFloat: Variants = {
|
||||
visible: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -12, transition: { duration: 0.5, ease: "easeOut" } },
|
||||
};
|
||||
|
||||
/* ===== stagger 容器 ===== */
|
||||
export const staggerContainer: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: { staggerChildren: 0.06 },
|
||||
},
|
||||
};
|
||||
|
||||
export const staggerItem: Variants = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.2 } },
|
||||
};
|
||||
|
||||
/* ===== 水墨揭示 ===== */
|
||||
export const inkReveal: Variants = {
|
||||
hidden: { clipPath: "circle(0% at 50% 50%)" },
|
||||
visible: {
|
||||
clipPath: "circle(75% at 50% 50%)",
|
||||
transition: { duration: 0.6, ease: "easeOut" },
|
||||
},
|
||||
};
|
||||
53
art-agent/frontend/src/components/ui/ripple-effect.tsx
Normal file
53
art-agent/frontend/src/components/ui/ripple-effect.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { rippleAnim } from "./motion-presets";
|
||||
|
||||
interface Ripple {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
let _rippleId = 0;
|
||||
|
||||
export function useRipple() {
|
||||
const [ripples, setRipples] = useState<Ripple[]>([]);
|
||||
|
||||
const trigger = useCallback((x: number, y: number) => {
|
||||
const id = ++_rippleId;
|
||||
setRipples((prev) => [...prev, { id, x, y }]);
|
||||
setTimeout(() => {
|
||||
setRipples((prev) => prev.filter((r) => r.id !== id));
|
||||
}, 700);
|
||||
}, []);
|
||||
|
||||
return { ripples, trigger };
|
||||
}
|
||||
|
||||
interface RippleLayerProps {
|
||||
ripples: Ripple[];
|
||||
}
|
||||
|
||||
export function RippleLayer({ ripples }: RippleLayerProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{ripples.map((r) => (
|
||||
<motion.div
|
||||
key={r.id}
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: r.x - 10,
|
||||
top: r.y - 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: "50%",
|
||||
border: "2px solid var(--accent)",
|
||||
}}
|
||||
animate={rippleAnim}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -312,7 +312,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[var(--bg-primary)]">
|
||||
<div className="text-[var(--text-secondary)] text-sm">加载中...</div>
|
||||
<div className="mist-loader">
|
||||
<div className="mist-loader-ring" />
|
||||
<div className="mist-loader-ring mist-loader-ring-2" />
|
||||
<div className="mist-loader-core" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user