Compare commits

...

8 Commits

Author SHA1 Message Date
Codex a317a46e87 chore(release): promote E2E fixes to pre
Docker Image / Build and Push Docker Image (push) Successful in 2m12s
2026-07-22 16:53:48 +08:00
Codex e813333607 fix(e2e): stabilize authenticated unlock flows 2026-07-22 16:53:09 +08:00
Codex b531b02a85 chore(release): promote dev fixes to pre
Docker Image / Build and Push Docker Image (push) Successful in 4m0s
2026-07-22 15:40:23 +08:00
Codex 8bd67b83c5 fix(e2e): target production API in production smoke tests 2026-07-22 15:40:23 +08:00
Codex 91e4a074c8 chore(release): absorb production icon baseline 2026-07-22 15:40:08 +08:00
Codex adfb230908 chore(release): rename pre-release branch to pre
Docker Image / Build and Push Docker Image (push) Successful in 2m2s
2026-07-22 10:56:59 +08:00
admin 291f268cce chore(favicon): 使用预发布环境的图标
Docker Image / Build and Push Docker Image (push) Successful in 3m47s
2026-07-22 10:06:31 +08:00
admin 06af88dc44 feat(chat): add composer action menu 2026-07-22 10:05:39 +08:00
39 changed files with 788 additions and 130 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- main - main
- test - pre
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -48,7 +48,7 @@ jobs:
DEPLOY_ENV="prod" DEPLOY_ENV="prod"
NEXT_ENV_FILE=".env.production" NEXT_ENV_FILE=".env.production"
;; ;;
test) pre)
DEPLOY_ENV="test" DEPLOY_ENV="test"
NEXT_ENV_FILE=".env.local" NEXT_ENV_FILE=".env.local"
;; ;;
+2 -2
View File
@@ -72,12 +72,12 @@ copy_env_by_branch() {
START_PORT=9185 START_PORT=9185
echo "=== env: copied .env.production (main → prod) ===" >> "$LOG_FILE" echo "=== env: copied .env.production (main → prod) ===" >> "$LOG_FILE"
;; ;;
test) pre)
cp -f env-example/.env.local.example .env.local cp -f env-example/.env.local.example .env.local
ENV_FILE=".env.local" ENV_FILE=".env.local"
DEPLOY_ENV="test" DEPLOY_ENV="test"
START_PORT=9135 START_PORT=9135
echo "=== env: copied .env.local (test → test) ===" >> "$LOG_FILE" echo "=== env: copied .env.local (pre branch → test runtime) ===" >> "$LOG_FILE"
;; ;;
dev) dev)
cp -f env-example/.env.development.example .env.local cp -f env-example/.env.development.example .env.local
+8 -8
View File
@@ -12,7 +12,7 @@
| 环境 | Git remote | 推送分支 | 宿主机端口 | 容器端口 | 部署脚本 | | 环境 | Git remote | 推送分支 | 宿主机端口 | 容器端口 | 部署脚本 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 测试环境 | `test` | `test` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` | | 预发环境 | `test` | `pre` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` |
| 生产环境 | `production` | `main` | `9185` | `3000` | `scripts/deploy/deploy_web.sh` | | 生产环境 | `production` | `main` | `9185` | `3000` | `scripts/deploy/deploy_web.sh` |
当前 remote 配置示例: 当前 remote 配置示例:
@@ -26,15 +26,15 @@ production root@43.106.13.130:/root/cozsweet-repos/main
## 本地发布流程 ## 本地发布流程
测试环境发布流程(脚本迁移前): 预发环境发布流程(脚本迁移前):
1. 进入 test worktree`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test` 1. 进入 pre worktree`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-pre`
2. 确认当前分支。 2. 确认当前分支。
3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `test` 分支。 3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `pre` 分支。
4. 复制测试环境图标到 `public/` 4. 复制测试环境图标到 `public/`
5. 复制 `env-example/.env.local.example``.env.local` 5. 复制 `env-example/.env.local.example``.env.local`
6. 执行旧版 `scripts/deploy/deploy_web_test.sh` 6. 执行旧版 `scripts/deploy/deploy_web_test.sh`
7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `test` 分支。 7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `pre` 分支。
8. 推送成功后尝试清除 Cloudflare CDN 缓存。 8. 推送成功后尝试清除 Cloudflare CDN 缓存。
旧生产环境发布流程(脚本迁移前): 旧生产环境发布流程(脚本迁移前):
@@ -71,7 +71,7 @@ production root@43.106.13.130:/root/cozsweet-repos/main
| 服务端分支 | 环境变量复制 | 宿主机端口 | 容器名 | 镜像标签 | | 服务端分支 | 环境变量复制 | 宿主机端口 | 容器名 | 镜像标签 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `main` | `env-example/.env.production.example``.env.production` | `9185` | `cozsweet-web-prod` | `cozsweet-web:prod-<commit>` | | `main` | `env-example/.env.production.example``.env.production` | `9185` | `cozsweet-web-prod` | `cozsweet-web:prod-<commit>` |
| `test` | `env-example/.env.local.example``.env.local` | `9135` | `cozsweet-web-test` | `cozsweet-web:test-<commit>` | | `pre` | `env-example/.env.local.example``.env.local` | `9135` | `cozsweet-web-test` | `cozsweet-web:test-<commit>` |
| `dev` | `env-example/.env.development.example``.env.local` | `9135` | `cozsweet-web-dev` | `cozsweet-web:dev-<commit>` | | `dev` | `env-example/.env.development.example``.env.local` | `9135` | `cozsweet-web-dev` | `cozsweet-web:dev-<commit>` |
| 其他分支 | 使用默认 `.env.local` | `9135` | `cozsweet-web-<branch>` | `cozsweet-web:<branch>-<commit>` | | 其他分支 | 使用默认 `.env.local` | `9135` | `cozsweet-web-<branch>` | `cozsweet-web:<branch>-<commit>` |
@@ -264,12 +264,12 @@ ABORTED ... (docker compose up failed)
1. `scripts/deploy/*` 只负责推送代码,不负责本地构建和本地启动。 1. `scripts/deploy/*` 只负责推送代码,不负责本地构建和本地启动。
2. 自动构建和自动重启只发生在服务器端 `post-receive` hook。 2. 自动构建和自动重启只发生在服务器端 `post-receive` hook。
3. `main` 分支对应生产环境,`test` 分支对应测试环境。 3. `main` 分支对应生产环境,`pre` 分支对应预发环境。
4. 生产推送当前使用 `git push --force production main` 4. 生产推送当前使用 `git push --force production main`
5. 构建失败时不会重启容器,旧容器继续运行。 5. 构建失败时不会重启容器,旧容器继续运行。
6. 服务启动由 Docker Compose 管理,不再使用 `nohup pnpm run start` 6. 服务启动由 Docker Compose 管理,不再使用 `nohup pnpm run start`
7. 服务器需要安装 Docker,并支持 `docker compose` 或旧版 `docker-compose` 命令。 7. 服务器需要安装 Docker,并支持 `docker compose` 或旧版 `docker-compose` 命令。
8. 测试环境和生产环境都通过同一套 `post-receive` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。 8. 预发环境和生产环境都通过同一套 `post-receive` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。
## 后续优化建议 ## 后续优化建议
+7 -7
View File
@@ -8,13 +8,13 @@
| Workflow | 触发时机 | 职责 | | Workflow | 触发时机 | 职责 |
| --- | --- | --- | | --- | --- | --- |
| `.gitea/workflows/ci.yml` | `dev``main``test` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 | | `.gitea/workflows/ci.yml` | `dev``main``pre` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 |
| `.gitea/workflows/docker-image.yml` | `main``test` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 | | `.gitea/workflows/docker-image.yml` | `main``pre` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 |
这样可以避免所有开发分支都发布镜像,并确保生产镜像通过发布门禁。 这样可以避免所有开发分支都发布镜像,并确保生产镜像通过发布门禁。
`main` 分支发布前会执行 Docker workflow 内的完整质量和 bundle 门禁。`test` `main` 分支发布前会执行 Docker workflow 内的完整质量和 bundle 门禁。`pre`
分支快速部署测试环境,会跳过完整质量检查,但仍必须通过 bundle 预算;独立的 分支用于快速部署预发环境,会跳过完整质量检查,但仍必须通过 bundle 预算;独立的
`ci.yml` 会同时执行完整检查并提供测试结果。 `ci.yml` 会同时执行完整检查并提供测试结果。
## 必需 Secrets ## 必需 Secrets
@@ -39,7 +39,7 @@ Next.js 的 `NEXT_PUBLIC_*` 会在构建期固化到产物中,因此测试环
## 镜像 Tag 规则 ## 镜像 Tag 规则
`test` 分支发布: `pre` 分支发布时继续使用现有 `test-*` 运行镜像标签
```bash ```bash
REGISTRY_IMAGE:test-<short_sha> REGISTRY_IMAGE:test-<short_sha>
@@ -81,7 +81,7 @@ volumes:
## 验证方式 ## 验证方式
推送到 `test` 分支后,检查 Actions 日志中是否出现: 推送到 `pre` 分支后,检查 Actions 日志中是否出现:
```text ```text
Build Docker image Build Docker image
@@ -124,4 +124,4 @@ gitea.banlv-ai.com/admin/cozsweet-web:prod-<short_sha>
- `prod-*` 精确 tag 只保留最近 3 个,`prod-latest` 不计入也不删除。 - `prod-*` 精确 tag 只保留最近 3 个,`prod-latest` 不计入也不删除。
- 当前刚推送的 `IMAGE_VERSION_TAG` 永远不会被删除。 - 当前刚推送的 `IMAGE_VERSION_TAG` 永远不会被删除。
SSH 部署成功后,服务器本机只保留当前环境正在运行的一个精确版本镜像;测试环境清理旧 `test-*`,生产环境清理旧 `prod-*` SSH 部署成功后,服务器本机只保留当前环境正在运行的一个精确版本镜像;预发环境清理旧 `test-*`,生产环境清理旧 `prod-*`
+7 -7
View File
@@ -3,7 +3,7 @@
当前推荐部署链路: 当前推荐部署链路:
```text ```text
push test/main push pre/main
→ Gitea Actions 构建并推送 Docker 镜像 → Gitea Actions 构建并推送 Docker 镜像
→ Actions 通过 SSH 登录目标服务器 → Actions 通过 SSH 登录目标服务器
→ 目标服务器 docker pull 指定镜像 tag → 目标服务器 docker pull 指定镜像 tag
@@ -16,7 +16,7 @@ push test/main
| 分支 | 环境 | 镜像 tag | 宿主机端口 | 默认服务器目录 | 容器名 | | 分支 | 环境 | 镜像 tag | 宿主机端口 | 默认服务器目录 | 容器名 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `test` | 测试环境 | `test-<short_sha>` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` | | `pre` | 预发环境 | `test-<short_sha>` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` |
| `main` | 生产环境 | `prod-<short_sha>` | `9185` | `/opt/cozsweet-web-prod` | `cozsweet-web-prod` | | `main` | 生产环境 | `prod-<short_sha>` | `9185` | `/opt/cozsweet-web-prod` | `cozsweet-web-prod` |
## Gitea Secrets ## Gitea Secrets
@@ -75,14 +75,14 @@ prod-<short_sha> 最近 3 个
`test-latest` / `prod-latest` 不计入保留数量,也不会主动删除。 `test-latest` / `prod-latest` 不计入保留数量,也不会主动删除。
部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前环境正在运行的一个精确版本镜像:测试环境保留当前 `test-<short_sha>`,生产环境保留当前 `prod-<short_sha>` 部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前环境正在运行的一个精确版本镜像:预发环境保留当前 `test-<short_sha>`,生产环境保留当前 `prod-<short_sha>`
## 本地发布入口 ## 本地发布入口
本地发布脚本现在只负责推送 Gitea 分支,触发 Actions 本地发布脚本现在只负责推送 Gitea 分支,触发 Actions
```bash ```bash
# 测试环境 # 预发环境
./scripts/release/pre_release_web.sh ./scripts/release/pre_release_web.sh
# 生产环境 # 生产环境
@@ -92,16 +92,16 @@ prod-<short_sha> 最近 3 个
也可以直接推送: 也可以直接推送:
```bash ```bash
git push gitea test git push gitea pre
git push --force gitea main git push --force gitea main
``` ```
## 验证 ## 验证
测试环境: 预发环境:
```bash ```bash
ssh <user>@<test-host> ssh <user>@<pre-host>
cd /opt/cozsweet-web-test cd /opt/cozsweet-web-test
docker compose ps docker compose ps
curl -I http://127.0.0.1:9135/ curl -I http://127.0.0.1:9135/
+3 -3
View File
@@ -27,8 +27,8 @@ pnpm exec playwright install chromium
For local development on macOS, `pnpm test:e2e:chrome` can reuse the installed Google Chrome. For local development on macOS, `pnpm test:e2e:chrome` can reuse the installed Google Chrome.
`pnpm test:e2e:mobile-smoke` runs the critical authentication, logout, token `pnpm test:e2e:mobile-smoke` runs the critical authentication, logout, token
refresh and paid-image unlock paths with the Pixel 7 profile. CI runs this tier refresh and paid-image unlock paths with the Pixel 7 profile. CI 在所有 pull
on every pull request and `dev` / `test` / `main` push. request 以及 `dev` / `pre` / `main` push 时运行这一层测试。
## Environment overrides ## Environment overrides
@@ -40,6 +40,6 @@ pnpm test:e2e:real
```bash ```bash
PLAYWRIGHT_BASE_URL=https://cozsweet.com \ PLAYWRIGHT_BASE_URL=https://cozsweet.com \
E2E_API_BASE_URL=https://proapi.banlv-ai.com \ E2E_API_BASE_URL=https://api.banlv-ai.com \
pnpm test:e2e:prod pnpm test:e2e:prod
``` ```
+1
View File
@@ -66,6 +66,7 @@ export function createWeeklyLimitChatSendResponse(
} }
export const paidImageMessageId = "msg_photo_paywall_001"; export const paidImageMessageId = "msg_photo_paywall_001";
export const paidImageDisplayMessageId = `server:${paidImageMessageId}:assistant`;
export const paidImageUrl = export const paidImageUrl =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
export const paidImageChatSendResponse = { export const paidImageChatSendResponse = {
-2
View File
@@ -42,9 +42,7 @@ export async function dismissChatInterruptions(page: Page) {
export async function signInWithEmailAndOpenChat(page: Page) { export async function signInWithEmailAndOpenChat(page: Page) {
await seedEmailSession(page); await seedEmailSession(page);
const historyResponsePromise = page.waitForResponse("**/api/chat/history**");
await page.goto(defaultCharacterChatPath); await page.goto(defaultCharacterChatPath);
await historyResponsePromise;
await dismissChatInterruptions(page); await dismissChatInterruptions(page);
await expect(page.getByRole("textbox", { name: "Message" })).toBeEnabled({ timeout: 20_000 }); await expect(page.getByRole("textbox", { name: "Message" })).toBeEnabled({ timeout: 20_000 });
} }
@@ -7,7 +7,6 @@ import {
defaultCharacterChatUrl, defaultCharacterChatUrl,
expectInsufficientCreditsDialog, expectInsufficientCreditsDialog,
expectVipChatSubscriptionUrl, expectVipChatSubscriptionUrl,
setEmailSessionStorage,
submitEmailLogin, submitEmailLogin,
switchToEmailSignIn, switchToEmailSignIn,
} from "@e2e/fixtures/test-helpers"; } from "@e2e/fixtures/test-helpers";
@@ -36,13 +35,10 @@ test("guest unlocks a promoted image through email login and top-up", async ({
await expect(page).toHaveURL(/\/auth\?redirect=/); await expect(page).toHaveURL(/\/auth\?redirect=/);
await switchToEmailSignIn(page); await switchToEmailSignIn(page);
await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl });
await setEmailSessionStorage(page);
const unlockRequestPromise = page.waitForRequest( const unlockRequestPromise = page.waitForRequest(
"**/api/chat/unlock-private", "**/api/chat/unlock-private",
); );
await page.reload(); await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl });
const unlockRequest = await unlockRequestPromise; const unlockRequest = await unlockRequestPromise;
expect(unlockRequest.method()).toBe("POST"); expect(unlockRequest.method()).toBe("POST");
+10 -11
View File
@@ -1,7 +1,10 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks"; import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { paidImageMessageId } from "@e2e/fixtures/test-data"; import {
paidImageDisplayMessageId,
paidImageMessageId,
} from "@e2e/fixtures/test-data";
import { import {
clearBrowserState, clearBrowserState,
completeVipPayment, completeVipPayment,
@@ -10,13 +13,12 @@ import {
enterChatFromSplash, enterChatFromSplash,
expectInsufficientCreditsDialog, expectInsufficientCreditsDialog,
expectVipChatSubscriptionUrl, expectVipChatSubscriptionUrl,
setEmailSessionStorage,
submitEmailLogin, submitEmailLogin,
switchToEmailSignIn, switchToEmailSignIn,
} from "@e2e/fixtures/test-helpers"; } from "@e2e/fixtures/test-helpers";
const paidImageOverlayUrl = new RegExp( const paidImageOverlayUrl = new RegExp(
`${defaultCharacterChatPath}\\?image=${paidImageMessageId}$`, `${defaultCharacterChatPath}\\?image=${encodeURIComponent(paidImageDisplayMessageId)}$`,
); );
test.beforeEach(async ({ baseURL, context, page }) => { test.beforeEach(async ({ baseURL, context, page }) => {
@@ -63,19 +65,16 @@ test("guest can unlock a paid image after email login and subscription payment",
await expect(page).toHaveURL(/\/auth\?redirect=/); await expect(page).toHaveURL(/\/auth\?redirect=/);
expect(new URL(page.url()).searchParams.get("redirect")).toBe( expect(new URL(page.url()).searchParams.get("redirect")).toBe(
`${defaultCharacterChatPath}?image=${paidImageMessageId}`, `${defaultCharacterChatPath}?image=${encodeURIComponent(paidImageDisplayMessageId)}`,
); );
await switchToEmailSignIn(page); await switchToEmailSignIn(page);
await submitEmailLogin(page, {
expectedUrl: paidImageOverlayUrl,
});
await setEmailSessionStorage(page);
const unlockRequestPromise = page.waitForRequest( const unlockRequestPromise = page.waitForRequest(
"**/api/chat/unlock-private", "**/api/chat/unlock-private",
); );
await page.goto(`${defaultCharacterChatPath}?image=${paidImageMessageId}`); await submitEmailLogin(page, {
expectedUrl: paidImageOverlayUrl,
});
const unlockRequest = await unlockRequestPromise; const unlockRequest = await unlockRequestPromise;
expect(unlockRequest.postDataJSON()).toMatchObject({ expect(unlockRequest.postDataJSON()).toMatchObject({
@@ -92,7 +91,7 @@ test("guest can unlock a paid image after email login and subscription payment",
await expect(page).toHaveURL( await expect(page).toHaveURL(
new RegExp( new RegExp(
`${defaultCharacterChatPath}(?:\\?image=msg_photo_paywall_001)?$`, `${defaultCharacterChatPath}(?:\\?image=${encodeURIComponent(paidImageDisplayMessageId)})?$`,
), ),
); );
expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath); expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath);
@@ -1,7 +1,10 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks"; import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { paidImageMessageId } from "@e2e/fixtures/test-data"; import {
paidImageDisplayMessageId,
paidImageMessageId,
} from "@e2e/fixtures/test-data";
import { import {
clearBrowserState, clearBrowserState,
defaultCharacterChatPath, defaultCharacterChatPath,
@@ -12,7 +15,7 @@ import {
} from "@e2e/fixtures/test-helpers"; } from "@e2e/fixtures/test-helpers";
const paidImageOverlayUrl = new RegExp( const paidImageOverlayUrl = new RegExp(
`${defaultCharacterChatPath}\\?image=${paidImageMessageId}$`, `${defaultCharacterChatPath}\\?image=${encodeURIComponent(paidImageDisplayMessageId)}$`,
); );
test.beforeEach(async ({ baseURL, context, page }) => { test.beforeEach(async ({ baseURL, context, page }) => {
+1 -1
View File
@@ -27,7 +27,7 @@
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:e2e:mobile-smoke": "playwright test --project=mock-mobile-chrome e2e/specs/mock/auth/email-login-from-other-options.spec.ts e2e/specs/mock/auth/logout-from-profile.spec.ts e2e/specs/mock/chat/chat-send-token-refresh-retry.spec.ts e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts", "test:e2e:mobile-smoke": "playwright test --project=mock-mobile-chrome e2e/specs/mock/auth/email-login-from-other-options.spec.ts e2e/specs/mock/auth/logout-from-profile.spec.ts e2e/specs/mock/chat/chat-send-token-refresh-retry.spec.ts e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts",
"test:e2e:real": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://frontend-test.banlv-ai.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=real-backend", "test:e2e:real": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://frontend-test.banlv-ai.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=real-backend",
"test:e2e:prod": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://cozsweet.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=prod-smoke", "test:e2e:prod": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://cozsweet.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://api.banlv-ai.com} playwright test --project=prod-smoke",
"contract:check": "node scripts/contracts/check-openapi.mjs", "contract:check": "node scripts/contracts/check-openapi.mjs",
"test:contracts": "node --test scripts/contracts/__tests__/*.test.mjs", "test:contracts": "node --test scripts/contracts/__tests__/*.test.mjs",
"perf:bundle": "next experimental-analyze --output && node scripts/performance/report-bundle.mjs", "perf:bundle": "next experimental-analyze --output && node scripts/performance/report-bundle.mjs",
+4 -1
View File
@@ -12,7 +12,10 @@ export default defineConfig({
fullyParallel: true, fullyParallel: true,
forbidOnly: Boolean(process.env.CI), forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined, // Next.js dev + Turbopack can return incomplete module payloads when the
// local mock suite compiles many routes concurrently. Keep this tier
// deterministic locally as well as in CI.
workers: 1,
reporter: process.env.CI reporter: process.env.CI
? [["list"], ["html", { open: "never" }]] ? [["list"], ["html", { open: "never" }]]
: [["list"], ["html", { open: "never" }]], : [["list"], ["html", { open: "never" }]],
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 35 KiB

+1 -1
View File
@@ -3,7 +3,7 @@
# 推送指定分支到指定远端。 # 推送指定分支到指定远端。
# 当前发布由 Gitea Actions 构建镜像并通过 SSH 部署;本脚本只负责触发分支 push。 # 当前发布由 Gitea Actions 构建镜像并通过 SSH 部署;本脚本只负责触发分支 push。
# 调用方:deploy_web.shproduction)→ push_to_remote "gitea" "main" # 调用方:deploy_web.shproduction)→ push_to_remote "gitea" "main"
# deploy_web_test.shtest)→ push_to_remote "gitea" "test" # deploy_web_test.shpre)→ push_to_remote "gitea" "pre"
push_to_remote() { push_to_remote() {
local remote="$1" local remote="$1"
local branch="$2" local branch="$2"
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# ========================================== # ==========================================
# 测试环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型) # 预发环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型)
# 原始 Dart: scripts/deploy/deploy_web_test.sh # 原始 Dart: scripts/deploy/deploy_web_test.sh
# ========================================== # ==========================================
@@ -11,7 +11,7 @@ source "$SCRIPT_DIR/_deploy_lib.sh"
# 主函数 # 主函数
main() { main() {
push_to_remote "gitea" "test" push_to_remote "gitea" "pre"
} }
main main
+8 -8
View File
@@ -11,9 +11,9 @@ unset_git_hook_env() {
unset_git_hook_env unset_git_hook_env
# Test 分支 worktree 路径 # pre 分支 worktree 路径
WORKTREE_PATH="/Users/chase/Documents/frontend/cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test" WORKTREE_PATH="/Users/chase/Documents/frontend/cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-pre"
echo "=== 进入 test 分支 worktree ===" echo "=== 进入 pre 分支 worktree ==="
cd "$WORKTREE_PATH" || { echo "错误: 无法进入 $WORKTREE_PATH"; exit 1; } cd "$WORKTREE_PATH" || { echo "错误: 无法进入 $WORKTREE_PATH"; exit 1; }
echo "=== 检查当前分支 ===" echo "=== 检查当前分支 ==="
@@ -22,17 +22,17 @@ git branch --show-current
echo "=== 变基到 dev 分支 ===" echo "=== 变基到 dev 分支 ==="
git rebase dev git rebase dev
# 复制测试环境图标(source: $WORKTREE_PATH/icons/test → dest: $WORKTREE_PATH/public/ # 复制预发环境图标(运行资源目录仍为 icons/test
echo "=== 复制测试环境图标(icons/test → public/ ===" echo "=== 复制预发环境图标(icons/test → public/ ==="
cp -f "$WORKTREE_PATH/icons/test/favicon.ico" "$WORKTREE_PATH/public/favicon.ico" cp -f "$WORKTREE_PATH/icons/test/favicon.ico" "$WORKTREE_PATH/public/favicon.ico"
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-192.png" "$WORKTREE_PATH/public/images/icons/Icon-192.png" cp -f "$WORKTREE_PATH/icons/test/icons/Icon-192.png" "$WORKTREE_PATH/public/images/icons/Icon-192.png"
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-512.png" "$WORKTREE_PATH/public/images/icons/Icon-512.png" cp -f "$WORKTREE_PATH/icons/test/icons/Icon-512.png" "$WORKTREE_PATH/public/images/icons/Icon-512.png"
# 准备测试环境变量($WORKTREE_PATH/env-example/.env.local.example → $WORKTREE_PATH/.env.local # 准备预发环境变量(运行配置仍使用 .env.local
echo "=== 准备测试环境变量(env-example/.env.local.example → .env.local ===" echo "=== 准备预发环境变量(env-example/.env.local.example → .env.local ==="
cp -f "$WORKTREE_PATH/env-example/.env.local.example" "$WORKTREE_PATH/.env.local" cp -f "$WORKTREE_PATH/env-example/.env.local.example" "$WORKTREE_PATH/.env.local"
echo "=== 执行构建部署脚本(测试环境)===" echo "=== 执行构建部署脚本(预发环境)==="
./scripts/deploy/deploy_web_test.sh ./scripts/deploy/deploy_web_test.sh
echo "=== 预发布完成 ===" echo "=== 预发布完成 ==="
+6 -2
View File
@@ -132,7 +132,11 @@ export function ChatScreen() {
}); });
useEffect(() => { useEffect(() => {
if (!imageMessageId || !state.historyLoaded || selectedImageMessage) { if (
!imageMessageId ||
!state.networkHistoryLoaded ||
selectedImageMessage
) {
return; return;
} }
router.replace( router.replace(
@@ -145,7 +149,7 @@ export function ChatScreen() {
router, router,
searchParams, searchParams,
selectedImageMessage, selectedImageMessage,
state.historyLoaded, state.networkHistoryLoaded,
]); ]);
useEffect(() => { useEffect(() => {
@@ -0,0 +1,174 @@
/* @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
dispatch: vi.fn(),
}));
vi.mock("@/stores/chat/chat-context", () => ({
useChatDispatch: () => mocks.dispatch,
}));
vi.mock("@/providers/character-provider", () => ({
useActiveCharacter: () => ({ id: "maya-tan" }),
useActiveCharacterRoutes: () => ({
tip: "/characters/maya/tip",
}),
}));
vi.mock("../../hooks/use-chat-keyboard-avoidance", () => ({
useChatKeyboardAvoidance: () => undefined,
}));
vi.mock("../../hooks/use-chat-keyboard-diagnostics", () => ({
useChatKeyboardDiagnostics: () => undefined,
}));
import { ChatInputBar } from "../chat-input-bar";
describe("ChatInputBar", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
mocks.dispatch.mockReset();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("opens the ordered action menu, blurs input, and links to Tip", () => {
renderBar();
const textarea = getTextarea();
act(() => textarea.focus());
expect(document.activeElement).toBe(textarea);
act(() => getButton("Open chat actions").click());
expect(document.activeElement).not.toBe(textarea);
const menu = container.querySelector('[aria-label="Chat actions"]');
expect(menu).not.toBeNull();
expect(
Array.from(menu?.children ?? []).map((item) => item.textContent?.trim()),
).toEqual(["Image", "Voice", "Tip"]);
expect(getButton("Close chat actions").getAttribute("aria-expanded")).toBe(
"true",
);
expect(
menu
?.querySelector<HTMLAnchorElement>('[data-analytics-key="chat.open_tip"]')
?.getAttribute("href"),
).toBe("/characters/maya/tip");
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
});
it("injects image and voice promotions without persisting UI state", () => {
renderBar();
act(() => getButton("Open chat actions").click());
act(() => getButton("Image").click());
const imageEvent = mocks.dispatch.mock.calls[0]?.[0];
expect(imageEvent).toMatchObject({
type: "ChatPromotionInjected",
promotion: {
characterId: "maya-tan",
promotionType: "image",
lockType: "image_paywall",
},
});
expect(imageEvent.promotion.clientLockId).toMatch(/^promotion_/);
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
act(() => getButton("Open chat actions").click());
act(() => getButton("Voice").click());
const voiceEvent = mocks.dispatch.mock.calls[1]?.[0];
expect(voiceEvent).toMatchObject({
type: "ChatPromotionInjected",
promotion: {
characterId: "maya-tan",
promotionType: "voice",
lockType: "voice_message",
},
});
expect(voiceEvent.promotion.clientLockId).not.toBe(
imageEvent.promotion.clientLockId,
);
});
it("switches from actions to Send for non-whitespace input", () => {
renderBar();
act(() => getButton("Open chat actions").click());
setTextareaValue(getTextarea(), "Hello Maya");
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
expect(getButton("Send message")).not.toBeNull();
act(() => getButton("Send message").click());
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "ChatSendMessage",
content: "Hello Maya",
});
expect(getTextarea().value).toBe("");
expect(document.activeElement).toBe(getTextarea());
setTextareaValue(getTextarea(), " ");
expect(getButton("Open chat actions")).not.toBeNull();
});
it("closes the menu on outside interaction and when disabled", () => {
renderBar();
act(() => getButton("Open chat actions").click());
act(() => document.body.dispatchEvent(new Event("pointerdown", { bubbles: true })));
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
act(() => getButton("Open chat actions").click());
renderBar(true);
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
expect(getButton("Open chat actions").disabled).toBe(true);
});
function renderBar(disabled = false): void {
act(() => root.render(<ChatInputBar disabled={disabled} />));
}
function getTextarea(): HTMLTextAreaElement {
const textarea = container.querySelector("textarea");
if (!textarea) throw new Error("Missing chat textarea");
return textarea;
}
function getButton(label: string): HTMLButtonElement {
const button = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find(
(item) =>
item.getAttribute("aria-label") === label ||
item.textContent?.trim() === label,
);
if (!button) throw new Error(`Missing button: ${label}`);
return button;
}
});
function setTextareaValue(
textarea: HTMLTextAreaElement,
value: string,
): void {
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
act(() => {
setter?.call(textarea, value);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
}
@@ -126,7 +126,10 @@ describe("chat Tailwind components", () => {
<ChatSendButton <ChatSendButton
disabled={false} disabled={false}
hasContent={true} hasContent={true}
isMenuOpen={false}
menuId="chat-actions"
onClick={() => undefined} onClick={() => undefined}
onMenuToggle={() => undefined}
onPointerDownSend={() => undefined} onPointerDownSend={() => undefined}
/>, />,
); );
@@ -134,7 +137,21 @@ describe("chat Tailwind components", () => {
<ChatSendButton <ChatSendButton
disabled={false} disabled={false}
hasContent={false} hasContent={false}
isMenuOpen={false}
menuId="chat-actions"
onClick={() => undefined} onClick={() => undefined}
onMenuToggle={() => undefined}
onPointerDownSend={() => undefined}
/>,
);
const openHtml = renderToStaticMarkup(
<ChatSendButton
disabled={false}
hasContent={false}
isMenuOpen={true}
menuId="chat-actions"
onClick={() => undefined}
onMenuToggle={() => undefined}
onPointerDownSend={() => undefined} onPointerDownSend={() => undefined}
/>, />,
); );
@@ -142,7 +159,10 @@ describe("chat Tailwind components", () => {
<ChatSendButton <ChatSendButton
disabled={true} disabled={true}
hasContent={true} hasContent={true}
isMenuOpen={false}
menuId="chat-actions"
onClick={() => undefined} onClick={() => undefined}
onMenuToggle={() => undefined}
onPointerDownSend={() => undefined} onPointerDownSend={() => undefined}
/>, />,
); );
@@ -150,12 +170,17 @@ describe("chat Tailwind components", () => {
expect(activeHtml).toContain('aria-label="Send message"'); expect(activeHtml).toContain('aria-label="Send message"');
expect(activeHtml).toContain('data-analytics-ignore="true"'); expect(activeHtml).toContain('data-analytics-ignore="true"');
expect(activeHtml).not.toContain("data-analytics-key"); expect(activeHtml).not.toContain("data-analytics-key");
expect(activeHtml).toContain("size-(--chat-send-button-size,40px)"); expect(activeHtml).toContain("size-(--chat-send-button-size,42px)");
expect(activeHtml).toContain( expect(activeHtml).toContain(
"bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]", "bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]",
); );
expect(emptyHtml).toContain("bg-[#f8a8ce]"); expect(emptyHtml).toContain('aria-label="Open chat actions"');
expect(emptyHtml).toContain("text-[rgba(255,255,255,0.88)]"); expect(emptyHtml).toContain('aria-expanded="false"');
expect(emptyHtml).toContain('aria-controls="chat-actions"');
expect(emptyHtml).toContain('data-analytics-key="chat.toggle_actions"');
expect(openHtml).toContain('aria-label="Close chat actions"');
expect(openHtml).toContain('aria-expanded="true"');
expect(openHtml).toContain("bg-[#38262d]");
expect(disabledHtml).toContain("disabled"); expect(disabledHtml).toContain("disabled");
}); });
@@ -0,0 +1,148 @@
.menu {
position: absolute;
right: 0;
bottom: calc(100% + 10px);
z-index: 4;
display: grid;
width: min(100%, 348px);
box-sizing: border-box;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 9px;
border: 1px solid rgba(77, 48, 57, 0.09);
border-radius: 24px;
background: rgba(255, 255, 255, 0.95);
box-shadow:
0 22px 54px rgba(74, 45, 55, 0.16),
0 4px 14px rgba(74, 45, 55, 0.08);
backdrop-filter: blur(20px);
transform-origin: right bottom;
}
.action {
display: flex;
min-width: 0;
min-height: 76px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
padding: 8px 6px;
border: 0;
border-radius: 18px;
background: #faf7f8;
color: #543d45;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 850;
line-height: 1;
text-decoration: none;
transition:
background 0.18s ease,
color 0.18s ease,
transform 0.18s ease;
}
.action:disabled,
.action[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.48;
}
.icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 14px;
}
.imageIcon {
background: #fff0f4;
color: #d94f7c;
}
.voiceIcon {
background: #fff4e8;
color: #d87343;
}
.tipIcon {
background: #f7f0e8;
color: #986a42;
}
.action:focus-visible {
outline: 3px solid rgba(246, 87, 160, 0.28);
outline-offset: 2px;
}
@media (hover: hover) {
.action:not(:disabled):not([aria-disabled="true"]):hover {
background: #fff0f5;
color: #b83d69;
transform: translateY(-1px);
}
}
@media (max-width: 350px) {
.menu {
gap: 6px;
padding: 7px;
border-radius: 21px;
}
.action {
min-height: 70px;
border-radius: 16px;
font-size: 11px;
}
.icon {
width: 35px;
height: 35px;
}
}
@media (prefers-reduced-motion: no-preference) {
.menu {
animation: menuReveal 0.22s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.action {
animation: actionReveal 0.25s ease both;
}
.action:nth-child(2) {
animation-delay: 35ms;
}
.action:nth-child(3) {
animation-delay: 70ms;
}
}
@keyframes menuReveal {
from {
opacity: 0;
transform: translateY(7px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes actionReveal {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@@ -0,0 +1,72 @@
"use client";
import { ImagePlus, Mic2, Coffee } from "lucide-react";
import Link from "next/link";
import styles from "./chat-composer-action-menu.module.css";
export interface ChatComposerActionMenuProps {
id: string;
disabled?: boolean;
tipHref: string;
onImage: () => void;
onVoice: () => void;
onNavigate: () => void;
}
export function ChatComposerActionMenu({
id,
disabled = false,
tipHref,
onImage,
onVoice,
onNavigate,
}: ChatComposerActionMenuProps) {
return (
<div id={id} className={styles.menu} aria-label="Chat actions">
<button
type="button"
className={styles.action}
disabled={disabled}
data-analytics-key="chat.promotion_image"
onClick={onImage}
>
<span className={`${styles.icon} ${styles.imageIcon}`}>
<ImagePlus size={21} strokeWidth={2} aria-hidden="true" />
</span>
<span>Image</span>
</button>
<button
type="button"
className={styles.action}
disabled={disabled}
data-analytics-key="chat.promotion_voice"
onClick={onVoice}
>
<span className={`${styles.icon} ${styles.voiceIcon}`}>
<Mic2 size={21} strokeWidth={2} aria-hidden="true" />
</span>
<span>Voice</span>
</button>
<Link
href={tipHref}
className={styles.action}
aria-disabled={disabled}
tabIndex={disabled ? -1 : undefined}
data-analytics-key="chat.open_tip"
onClick={(event) => {
if (disabled) {
event.preventDefault();
return;
}
onNavigate();
}}
>
<span className={`${styles.icon} ${styles.tipIcon}`}>
<Coffee size={21} strokeWidth={2} aria-hidden="true" />
</span>
<span>Tip</span>
</Link>
</div>
);
}
@@ -1,10 +1,10 @@
/* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */
/* Keyboard inset is a total bottom inset, not an addition to normal spacing. */ /* Keyboard inset is a total bottom inset, not an addition to normal spacing. */
.bar { .bar {
position: relative;
z-index: 5;
flex: 0 0 auto; flex: 0 0 auto;
padding: 0 padding: clamp(7px, 1.852vw, 10px)
calc(var(--chat-inline-padding, 16px) + var(--app-safe-right, 0px)) calc(var(--chat-inline-padding, 16px) + var(--app-safe-right, 0px))
max( max(
calc(var(--spacing-lg, 16px) + var(--app-safe-bottom, 0px)), calc(var(--spacing-lg, 16px) + var(--app-safe-bottom, 0px)),
@@ -12,28 +12,43 @@
) )
calc(var(--chat-inline-padding, 16px) + var(--app-safe-left, 0px)); calc(var(--chat-inline-padding, 16px) + var(--app-safe-left, 0px));
background: transparent; background: transparent;
transition: padding-top 0.2s ease, padding-bottom 0.2s ease, transition: padding-bottom 0.2s ease;
background-color 0.2s ease, box-shadow 0.2s ease;
} }
.barFocused { .composer {
padding-top: var(--spacing-md, 12px); position: relative;
background: #feeff2; width: 100%;
box-shadow: 0 4px 12px var(--color-input-box-shadow, rgba(0, 0, 0, 0.1));
} }
/* 内层:白底 + 大圆角(Dart AppRadius.radius32+ focused 时 accent 边框 */
.row { .row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-sm, 8px); gap: 6px;
padding: var(--spacing-sm, 8px); padding: 6px;
background: #fff; border: 1px solid rgba(94, 62, 73, 0.11);
border-radius: var(--radius-full, 999px); border-radius: var(--radius-full, 999px);
border: 1px solid transparent; background: rgba(255, 255, 255, 0.94);
transition: border-color 0.2s ease; box-shadow:
0 12px 30px rgba(75, 48, 57, 0.1),
0 2px 8px rgba(75, 48, 57, 0.05);
backdrop-filter: blur(18px);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease;
} }
.rowFocused { .rowFocused {
border-color: var(--color-accent, #f84d96); border-color: rgba(246, 87, 160, 0.62);
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 0 0 3px rgba(246, 87, 160, 0.1),
0 14px 34px rgba(92, 52, 67, 0.12);
}
@media (max-width: 350px) {
.bar {
padding-right: calc(12px + var(--app-safe-right, 0px));
padding-left: calc(12px + var(--app-safe-left, 0px));
}
} }
+95 -20
View File
@@ -1,17 +1,24 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPendingChatPromotion } from "@/lib/navigation/chat_unlock_session";
import {
useActiveCharacter,
useActiveCharacterRoutes,
} from "@/providers/character-provider";
import { useChatDispatch } from "@/stores/chat/chat-context"; import { useChatDispatch } from "@/stores/chat/chat-context";
import { Logger } from "@/utils/logger"; import { Logger } from "@/utils/logger";
import { useChatKeyboardAvoidance } from "../hooks/use-chat-keyboard-avoidance"; import { useChatKeyboardAvoidance } from "../hooks/use-chat-keyboard-avoidance";
import { useChatKeyboardDiagnostics } from "../hooks/use-chat-keyboard-diagnostics"; import { useChatKeyboardDiagnostics } from "../hooks/use-chat-keyboard-diagnostics";
import { ChatComposerActionMenu } from "./chat-composer-action-menu";
import { ChatInputTextField } from "./chat-input-text-field"; import { ChatInputTextField } from "./chat-input-text-field";
import { ChatSendButton } from "./chat-send-button"; import { ChatSendButton } from "./chat-send-button";
import styles from "./chat-input-bar.module.css"; import styles from "./chat-input-bar.module.css";
const log = new Logger("AppChatComponentsChatInputBar"); const log = new Logger("AppChatComponentsChatInputBar");
const POINTER_SEND_CLICK_DEDUPE_MS = 500; const POINTER_SEND_CLICK_DEDUPE_MS = 500;
const CHAT_ACTION_MENU_ID = "chat-composer-action-menu";
export interface ChatInputBarProps { export interface ChatInputBarProps {
disabled?: boolean; disabled?: boolean;
@@ -19,22 +26,57 @@ export interface ChatInputBarProps {
export function ChatInputBar({ disabled = false }: ChatInputBarProps) { export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
const dispatch = useChatDispatch(); const dispatch = useChatDispatch();
const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes();
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [isActionMenuOpen, setIsActionMenuOpen] = useState(false);
const [previousDisabled, setPreviousDisabled] = useState(disabled);
const barRef = useRef<HTMLDivElement>(null); const barRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const lastPointerSendAtRef = useRef(0); const lastPointerSendAtRef = useRef(0);
const hasContent = input.trim().length > 0; const hasContent = input.trim().length > 0;
if (disabled !== previousDisabled) {
setPreviousDisabled(disabled);
if (disabled && isActionMenuOpen) setIsActionMenuOpen(false);
}
useChatKeyboardAvoidance({ useChatKeyboardAvoidance({
active: isFocused, active: isFocused,
containerRef: barRef, containerRef: barRef,
}); });
useChatKeyboardDiagnostics({ containerRef: barRef }); useChatKeyboardDiagnostics({ containerRef: barRef });
useEffect(() => {
if (!isActionMenuOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && barRef.current?.contains(target)) return;
setIsActionMenuOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setIsActionMenuOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isActionMenuOpen]);
const handleInputChange = (value: string) => { const handleInputChange = (value: string) => {
setInput(value); setInput(value);
if (value.trim().length > 0) setIsActionMenuOpen(false);
};
const handleFocusChange = (focused: boolean) => {
setIsFocused(focused);
if (focused) setIsActionMenuOpen(false);
}; };
const handleSend = (source: "click" | "keyboard" | "pointerdown") => { const handleSend = (source: "click" | "keyboard" | "pointerdown") => {
@@ -62,6 +104,7 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
}); });
dispatch({ type: "ChatSendMessage", content: input }); dispatch({ type: "ChatSendMessage", content: input });
setInput(""); setInput("");
setIsActionMenuOpen(false);
textareaRef.current?.focus(); textareaRef.current?.focus();
}; };
@@ -70,26 +113,58 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
handleSend("pointerdown"); handleSend("pointerdown");
}; };
const handleMenuToggle = () => {
if (disabled || hasContent) return;
if (!isActionMenuOpen) textareaRef.current?.blur();
setIsActionMenuOpen((open) => !open);
};
const handlePromotion = (promotionType: "image" | "voice") => {
if (disabled) return;
dispatch({
type: "ChatPromotionInjected",
promotion: createPendingChatPromotion(
promotionType,
character.id,
),
});
setIsActionMenuOpen(false);
};
return ( return (
<div <div ref={barRef} className={styles.bar}>
ref={barRef} <div className={styles.composer}>
className={`${styles.bar} ${isFocused ? styles.barFocused : ""}`} {isActionMenuOpen ? (
> <ChatComposerActionMenu
<div className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}> id={CHAT_ACTION_MENU_ID}
<ChatInputTextField disabled={disabled}
ref={textareaRef} tipHref={characterRoutes.tip}
value={input} onImage={() => handlePromotion("image")}
onChange={handleInputChange} onVoice={() => handlePromotion("voice")}
onSubmit={() => handleSend("keyboard")} onNavigate={() => setIsActionMenuOpen(false)}
onFocusChange={setIsFocused} />
disabled={disabled} ) : null}
/> <div
<ChatSendButton className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}
disabled={disabled} >
hasContent={hasContent} <ChatInputTextField
onClick={() => handleSend("click")} ref={textareaRef}
onPointerDownSend={handlePointerDownSend} value={input}
/> onChange={handleInputChange}
onSubmit={() => handleSend("keyboard")}
onFocusChange={handleFocusChange}
disabled={disabled}
/>
<ChatSendButton
disabled={disabled}
hasContent={hasContent}
isMenuOpen={isActionMenuOpen}
menuId={CHAT_ACTION_MENU_ID}
onClick={() => handleSend("click")}
onMenuToggle={handleMenuToggle}
onPointerDownSend={handlePointerDownSend}
/>
</div>
</div> </div>
</div> </div>
); );
@@ -59,7 +59,7 @@ export const ChatInputTextField = forwardRef<
}; };
return ( return (
<div className="flex min-w-0 flex-auto items-center rounded-full bg-white px-(--spacing-lg,16px)"> <div className="flex min-w-0 flex-auto items-center rounded-full bg-transparent px-[clamp(10px,3vw,14px)]">
<textarea <textarea
ref={innerRef} ref={innerRef}
className="min-h-(--chat-send-button-size,40px) max-h-[min(30vh,120px)] w-full min-w-0 flex-auto resize-none border-0 bg-transparent pb-0 pl-0 pr-0 pt-[clamp(5px,1.111vw,6px)] font-[inherit] text-[16px] leading-[clamp(22px,4.444vw,24px)] text-(--color-text-foreground,#000) caret-accent outline-none placeholder:text-(--color-text-hint,#757575)" className="min-h-(--chat-send-button-size,40px) max-h-[min(30vh,120px)] w-full min-w-0 flex-auto resize-none border-0 bg-transparent pb-0 pl-0 pr-0 pt-[clamp(5px,1.111vw,6px)] font-[inherit] text-[16px] leading-[clamp(22px,4.444vw,24px)] text-(--color-text-foreground,#000) caret-accent outline-none placeholder:text-(--color-text-hint,#757575)"
+33 -15
View File
@@ -1,29 +1,43 @@
"use client"; "use client";
import { ArrowUp } from "lucide-react"; import { ArrowUp, Plus, X } from "lucide-react";
export interface ChatSendButtonProps { export interface ChatSendButtonProps {
disabled: boolean; disabled: boolean;
hasContent: boolean; hasContent: boolean;
isMenuOpen: boolean;
menuId: string;
onClick: () => void; onClick: () => void;
onMenuToggle: () => void;
onPointerDownSend: () => void; onPointerDownSend: () => void;
} }
export function ChatSendButton({ export function ChatSendButton({
disabled, disabled,
hasContent, hasContent,
isMenuOpen,
menuId,
onClick, onClick,
onMenuToggle,
onPointerDownSend, onPointerDownSend,
}: ChatSendButtonProps) { }: ChatSendButtonProps) {
const isActive = hasContent && !disabled; const isSendMode = hasContent;
const label = isSendMode
? "Send message"
: isMenuOpen
? "Close chat actions"
: "Open chat actions";
return ( return (
<button <button
type="button" type="button"
data-analytics-ignore data-analytics-ignore={isSendMode ? true : undefined}
data-analytics-key={isSendMode ? undefined : "chat.toggle_actions"}
className={[ className={[
"flex aspect-square size-(--chat-send-button-size,40px) shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-(--color-button-gradient-end,#fc69df) text-white transition-[background,transform] duration-200 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]", "flex aspect-square size-(--chat-send-button-size,42px) shrink-0 cursor-pointer items-center justify-center rounded-full border transition-[background,color,transform,box-shadow] duration-200 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#f657a0]",
isActive isSendMode
? "bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]" ? "border-transparent bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] text-white shadow-[0_8px_20px_rgba(246,87,160,0.3)]"
: "bg-[#f8a8ce] text-[rgba(255,255,255,0.88)] shadow-none", : isMenuOpen
? "border-transparent bg-[#38262d] text-white shadow-[0_8px_18px_rgba(56,38,45,0.18)]"
: "border-[rgba(104,67,80,0.09)] bg-[#f8f1f4] text-[#76505f] shadow-none",
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
@@ -32,17 +46,21 @@ export function ChatSendButton({
if (event.pointerType === "mouse") return; if (event.pointerType === "mouse") return;
if (disabled) return; if (disabled) return;
event.preventDefault(); event.preventDefault();
if (!hasContent) return; if (!isSendMode) return;
onPointerDownSend(); onPointerDownSend();
}} }}
onClick={onClick} onClick={isSendMode ? onClick : onMenuToggle}
aria-label="Send message" aria-label={label}
aria-expanded={isSendMode ? undefined : isMenuOpen}
aria-controls={isSendMode ? undefined : menuId}
> >
<ArrowUp {isSendMode ? (
className="size-(--icon-size-xl,24px) text-(length:--icon-size-xl,24px) leading-none" <ArrowUp size={23} strokeWidth={2.4} aria-hidden="true" />
size={24} ) : isMenuOpen ? (
aria-hidden="true" <X size={22} strokeWidth={2.2} aria-hidden="true" />
/> ) : (
<Plus size={23} strokeWidth={2.2} aria-hidden="true" />
)}
</button> </button>
); );
} }
@@ -5,7 +5,10 @@ import memoryDriver from "unstorage/drivers/memory";
import { StorageKeys } from "@/data/storage/storage_keys"; import { StorageKeys } from "@/data/storage/storage_keys";
import { SessionAsyncUtil } from "@/utils/session-storage"; import { SessionAsyncUtil } from "@/utils/session-storage";
import { NavigationStorage } from "../navigation_storage"; import {
createPendingChatPromotion,
NavigationStorage,
} from "../navigation_storage";
const CHARACTER_ID = "elio"; const CHARACTER_ID = "elio";
const OTHER_CHARACTER_ID = "maya-tan"; const OTHER_CHARACTER_ID = "maya-tan";
@@ -105,6 +108,28 @@ describe("NavigationStorage", () => {
}); });
}); });
it("creates manual promotions without persisting them", async () => {
const image = createPendingChatPromotion("image", CHARACTER_ID);
const voice = createPendingChatPromotion("voice", CHARACTER_ID);
expect(image).toMatchObject({
characterId: CHARACTER_ID,
promotionType: "image",
lockType: "image_paywall",
});
expect(voice).toMatchObject({
characterId: CHARACTER_ID,
promotionType: "voice",
lockType: "voice_message",
});
expect(image.clientLockId).toMatch(/^promotion_/);
expect(voice.clientLockId).toMatch(/^promotion_/);
expect(voice.clientLockId).not.toBe(image.clientLockId);
await expect(
NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
).resolves.toBeNull();
});
it("saves and consumes pending chat image return sessions", async () => { it("saves and consumes pending chat image return sessions", async () => {
await NavigationStorage.savePendingChatImageReturn({ await NavigationStorage.savePendingChatImageReturn({
characterId: CHARACTER_ID, characterId: CHARACTER_ID,
@@ -69,6 +69,19 @@ export type PendingChatImageReturn = z.output<
typeof PendingChatImageReturnSchema typeof PendingChatImageReturnSchema
>; >;
export function createPendingChatPromotion(
promotionType: PendingChatPromotionType,
characterId: string,
): PendingChatPromotion {
return PendingChatPromotionSchema.parse({
characterId,
promotionType,
lockType: toPromotionLockType(promotionType),
clientLockId: `promotion_${createUuidV4()}`,
createdAt: Date.now(),
});
}
/** /**
* NavigationStorage owns short-lived cross-route sessions. * NavigationStorage owns short-lived cross-route sessions.
* *
@@ -180,13 +193,10 @@ export class NavigationStorage {
promotionType: PendingChatPromotionType, promotionType: PendingChatPromotionType,
characterId: string, characterId: string,
): Promise<PendingChatPromotion> { ): Promise<PendingChatPromotion> {
const promotion: PendingChatPromotion = { const promotion = createPendingChatPromotion(
characterId,
promotionType, promotionType,
lockType: toPromotionLockType(promotionType), characterId,
clientLockId: `promotion_${createUuidV4()}`, );
createdAt: Date.now(),
};
await SessionAsyncUtil.setJson( await SessionAsyncUtil.setJson(
StorageKeys.pendingChatPromotion, StorageKeys.pendingChatPromotion,
promotion, promotion,
@@ -1,6 +1,7 @@
"use client"; "use client";
import { import {
createPendingChatPromotion as createPromotion,
NavigationStorage, NavigationStorage,
type PendingChatUnlock, type PendingChatUnlock,
type PendingChatUnlockKind, type PendingChatUnlockKind,
@@ -17,6 +18,13 @@ export type {
PendingChatUnlockStage, PendingChatUnlockStage,
}; };
export function createPendingChatPromotion(
promotionType: PendingChatPromotionType,
characterId: string,
): PendingChatPromotion {
return createPromotion(promotionType, characterId);
}
export async function savePendingChatUnlock(input: { export async function savePendingChatUnlock(input: {
characterId: string; characterId: string;
displayMessageId?: string; displayMessageId?: string;
@@ -55,6 +55,7 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
requiredCredits: 0, requiredCredits: 0,
shortfallCredits: 0, shortfallCredits: 0,
historyLoaded: true, historyLoaded: true,
networkHistoryLoaded: true,
historyTotal: 0, historyTotal: 0,
historyLimit: 50, historyLimit: 50,
nextHistoryOffset: 0, nextHistoryOffset: 0,
@@ -267,6 +268,8 @@ describe("chat history pagination helpers", () => {
limit: 0, limit: 0,
}); });
expect(nextState.networkHistoryLoaded).toBe(true);
expect(nextState.historyLimit).toBe(50); expect(nextState.historyLimit).toBe(50);
expect(nextState.nextHistoryOffset).toBe(50); expect(nextState.nextHistoryOffset).toBe(50);
expect(nextState.historyTotal).toBe(120); expect(nextState.historyTotal).toBe(120);
+3
View File
@@ -22,6 +22,8 @@ interface ChatState {
upgradeReason: MachineContext["upgradeReason"]; upgradeReason: MachineContext["upgradeReason"];
/** True as soon as a local snapshot or the first network result is renderable. */ /** True as soon as a local snapshot or the first network result is renderable. */
historyLoaded: boolean; historyLoaded: boolean;
/** True after the network history request has completed or failed. */
networkHistoryLoaded: boolean;
hasMoreHistory: boolean; hasMoreHistory: boolean;
isLoadingMoreHistory: boolean; isLoadingMoreHistory: boolean;
unlockHistoryPromptVisible: boolean; unlockHistoryPromptVisible: boolean;
@@ -93,6 +95,7 @@ function selectChatState(state: ChatSnapshot): SelectedChatState {
upgradePromptVisible: state.context.upgradePromptVisible, upgradePromptVisible: state.context.upgradePromptVisible,
upgradeReason: state.context.upgradeReason, upgradeReason: state.context.upgradeReason,
historyLoaded: state.context.historyLoaded, historyLoaded: state.context.historyLoaded,
networkHistoryLoaded: state.context.networkHistoryLoaded,
hasMoreHistory: hasMoreHistory:
state.context.historyLoaded && state.context.historyLoaded &&
state.context.nextHistoryOffset < state.context.historyTotal, state.context.nextHistoryOffset < state.context.historyTotal,
+3
View File
@@ -46,6 +46,8 @@ export interface ChatState {
shortfallCredits: number; shortfallCredits: number;
/** history 首屏可展示标志(本地快照加载完成即可进入 ready)。 */ /** history 首屏可展示标志(本地快照加载完成即可进入 ready)。 */
historyLoaded: boolean; historyLoaded: boolean;
/** 网络历史已返回或已明确失败;用于避免过早判定深链消息不存在。 */
networkHistoryLoaded: boolean;
historyTotal: number; historyTotal: number;
historyLimit: number; historyLimit: number;
nextHistoryOffset: number; nextHistoryOffset: number;
@@ -80,6 +82,7 @@ export function createInitialChatState(
requiredCredits: 0, requiredCredits: 0,
shortfallCredits: 0, shortfallCredits: 0,
historyLoaded: false, historyLoaded: false,
networkHistoryLoaded: false,
historyTotal: 0, historyTotal: 0,
historyLimit: 50, historyLimit: 50,
nextHistoryOffset: 0, nextHistoryOffset: 0,
+2
View File
@@ -29,6 +29,7 @@ export function applyNetworkHistoryLoadedOutput(
ChatState, ChatState,
| "messages" | "messages"
| "historyLoaded" | "historyLoaded"
| "networkHistoryLoaded"
| "historyTotal" | "historyTotal"
| "historyLimit" | "historyLimit"
| "nextHistoryOffset" | "nextHistoryOffset"
@@ -47,6 +48,7 @@ export function applyNetworkHistoryLoadedOutput(
return { return {
messages: [...output.messages, ...optimisticTail], messages: [...output.messages, ...optimisticTail],
historyLoaded: true, historyLoaded: true,
networkHistoryLoaded: true,
historyTotal: Math.max(0, output.total), historyTotal: Math.max(0, output.total),
historyLimit, historyLimit,
nextHistoryOffset: historyLimit, nextHistoryOffset: historyLimit,
+1
View File
@@ -55,6 +55,7 @@ const markHistoryLoadFailedAction = baseChatMachineSetup.assign(
const characterErrorCode = getCharacterErrorCode(event.error); const characterErrorCode = getCharacterErrorCode(event.error);
return { return {
historyLoaded: true, historyLoaded: true,
networkHistoryLoaded: true,
characterErrorCode, characterErrorCode,
canSendMessage: canSendMessage:
characterErrorCode === "CHARACTER_DISABLED" || characterErrorCode === "CHARACTER_DISABLED" ||
@@ -0,0 +1,69 @@
import { StrictMode } from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
chatDispatch: vi.fn(),
getLoginToken: vi.fn(async () => ({
success: true as const,
data: "email-token",
})),
}));
vi.mock("@/data/storage/auth", () => ({
AuthStorage: {
getInstance: () => ({ getLoginToken: mocks.getLoginToken }),
},
}));
vi.mock("@/stores/auth/auth-context", () => ({
selectAuthIsLoading: () => false,
useAuthSelector: (selector: (state: unknown) => unknown) =>
selector({
context: {
hasInitialized: true,
loginStatus: "email",
},
}),
}));
vi.mock("@/stores/chat/chat-context", () => ({
useChatDispatch: () => mocks.chatDispatch,
}));
import { ChatAuthSync } from "../chat-auth-sync";
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
afterEach(() => {
if (root) act(() => root?.unmount());
container?.remove();
container = null;
root = null;
mocks.chatDispatch.mockReset();
mocks.getLoginToken.mockClear();
});
describe("ChatAuthSync", () => {
it("dispatches the authenticated session after StrictMode restarts the effect", async () => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(
<StrictMode>
<ChatAuthSync />
</StrictMode>,
);
});
expect(mocks.chatDispatch).toHaveBeenCalledTimes(1);
expect(mocks.chatDispatch).toHaveBeenCalledWith({
type: "ChatUserLogin",
token: "email-token",
});
});
});
+3 -1
View File
@@ -34,14 +34,15 @@ export function ChatAuthSync() {
const sessionKey = authState.loginStatus; const sessionKey = authState.loginStatus;
if (prevSessionKeyRef.current === sessionKey) return; if (prevSessionKeyRef.current === sessionKey) return;
prevSessionKeyRef.current = sessionKey;
if (authState.loginStatus === "notLoggedIn") { if (authState.loginStatus === "notLoggedIn") {
prevSessionKeyRef.current = sessionKey;
chatDispatch({ type: "ChatLogout" }); chatDispatch({ type: "ChatLogout" });
return; return;
} }
if (authState.loginStatus === "guest") { if (authState.loginStatus === "guest") {
prevSessionKeyRef.current = sessionKey;
chatDispatch({ type: "ChatGuestLogin" }); chatDispatch({ type: "ChatGuestLogin" });
return; return;
} }
@@ -50,6 +51,7 @@ export function ChatAuthSync() {
void (async () => { void (async () => {
const tokenR = await AuthStorage.getInstance().getLoginToken(); const tokenR = await AuthStorage.getInstance().getLoginToken();
if (!cancelled && tokenR.success && tokenR.data) { if (!cancelled && tokenR.success && tokenR.data) {
prevSessionKeyRef.current = sessionKey;
chatDispatch({ chatDispatch({
type: "ChatUserLogin", type: "ChatUserLogin",
token: tokenR.data, token: tokenR.data,
@@ -29,12 +29,13 @@ export function ChatPaymentSuccessSync() {
const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`; const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`;
if (lastPaidKeyRef.current === paidKey) return; if (lastPaidKeyRef.current === paidKey) return;
lastPaidKeyRef.current = paidKey;
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
if (await hasPendingChatUnlock(characterId)) return; const hasPendingUnlock = await hasPendingChatUnlock(characterId);
if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" }); if (cancelled) return;
lastPaidKeyRef.current = paidKey;
if (!hasPendingUnlock) chatDispatch({ type: "ChatPaymentSucceeded" });
})(); })();
return () => { return () => {