Build advertiser console with scoped page URLs and runner-based deployment tests
Advertiser console CI and test deployment / build-deploy (push) Has been cancelled
Advertiser console CI and test deployment / build-deploy (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
# Leave empty to use Vite's local API proxy / production Nginx proxy.
|
||||
VITE_API_BASE_URL=
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Advertiser console CI and test deployment
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
group: adcloud-advertiser-console-test
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
build-deploy:
|
||||
if: gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: docker:27-cli
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
SOURCE_SERVER: ${{ gitea.server_url }}
|
||||
SOURCE_REPOSITORY: ${{ gitea.repository }}
|
||||
SOURCE_REVISION: ${{ gitea.sha }}
|
||||
DEPLOY_PORT: ${{ vars.ADCLOUD_ADVERTISER_PORT || '18081' }}
|
||||
BASE_URL: ${{ vars.ADCLOUD_ADVERTISER_PUBLIC_URL || 'http://47.76.92.159:18081' }}
|
||||
API_UPSTREAM: ${{ vars.ADCLOUD_ADVERTISER_API_UPSTREAM || 'http://host.docker.internal:18080' }}
|
||||
steps:
|
||||
- name: Prepare runner tools
|
||||
run: apk add --no-cache python3 bash curl
|
||||
- name: Fetch the exact repository revision
|
||||
env:
|
||||
SOURCE_TOKEN: ${{ secrets.GITEA_TOKEN || gitea.token }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import io, os, shutil, tarfile, tempfile, urllib.request
|
||||
from pathlib import Path
|
||||
server = os.environ['SOURCE_SERVER'].rstrip('/')
|
||||
repo = os.environ['SOURCE_REPOSITORY']
|
||||
revision = os.environ['SOURCE_REVISION']
|
||||
request = urllib.request.Request(server + '/api/v1/repos/' + repo + '/archive/' + revision + '.tar.gz', headers={'Authorization': 'token ' + os.environ['SOURCE_TOKEN']})
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
data = response.read()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as archive:
|
||||
archive.extractall(directory, filter='data')
|
||||
roots = list(Path(directory).iterdir())
|
||||
if len(roots) != 1 or not roots[0].is_dir():
|
||||
raise RuntimeError('Unexpected repository archive structure')
|
||||
shutil.copytree(roots[0], Path.cwd(), dirs_exist_ok=True)
|
||||
print('Checked out exact commit:', revision)
|
||||
PY
|
||||
- name: Typecheck and build image
|
||||
run: docker build --tag "adcloud-advertiser:$SOURCE_REVISION" .
|
||||
- name: Build browser test image
|
||||
run: docker build --target browser-tests --tag "adcloud-advertiser:$SOURCE_REVISION-tests" .
|
||||
- name: Deploy test console and verify
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name != 'pull_request'
|
||||
run: bash deploy/deploy.sh
|
||||
@@ -4,3 +4,5 @@ dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM mcr.microsoft.com/playwright:v1.58.2-noble AS browser-tests
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY tests ./tests
|
||||
COPY src/navigation.ts ./src/navigation.ts
|
||||
COPY playwright.config.ts ./
|
||||
CMD ["npm", "run", "test:ci"]
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY deploy/default.conf.template /etc/nginx/templates/default.conf.template
|
||||
ENV API_UPSTREAM=http://host.docker.internal:18080
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=10s --timeout=3s CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1
|
||||
@@ -1,45 +1,45 @@
|
||||
# AdCloud · 广告商控制台
|
||||
# AdCloud · 广告主控制台
|
||||
|
||||
管理广告、投放计划与效果数据。 React + TypeScript + Vite 独立前端项目。
|
||||
React + TypeScript + Vite,独立 AdCloud 品牌的广告主网站。前后端发布与测试全部通过 Gitea Actions 执行。
|
||||
|
||||
## 启动
|
||||
## 页面和 URL
|
||||
|
||||
需要 Node.js 20.19+ 或 22.12+。
|
||||
| 页面 | URL |
|
||||
| ----------------------- | ----------------------------------------------------------------------------- |
|
||||
| Landing / 登录 / 注册 | `/`、`/login`、`/register` |
|
||||
| 工作台 | `/workbench` |
|
||||
| 全部主账户 / 指定主账户 | `/account-manage`、`/account-manage/:accountId` |
|
||||
| 新建 / 编辑主账户 | `/account-manage/new`、`/account-manage/:accountId/edit` |
|
||||
| 投放子账户 | `/account-manage/:accountId/promotion-manage/:promotionId` |
|
||||
| 新建 / 编辑子账户 | 主账户路径下的 `/promotion-manage/new`、`/promotion-manage/:promotionId/edit` |
|
||||
| 新建项目 | 子账户路径下的 `/create-project/:step` |
|
||||
| 编辑项目 | 子账户路径下的 `/projects/:projectId/edit/:step` |
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
cp .env.example .env
|
||||
npm run dev
|
||||
```
|
||||
`step` 依次为 `goal`、`product`、`audience`、`budget`、`tracking`、`name`,对应营销目标、推广产品、受众、排期预算、监测链接和项目名称。步骤切换产生浏览器历史记录。已保存项目可复制链接、在新标签页打开、刷新恢复和前进后退;未保存的表单编辑仍是临时状态。
|
||||
|
||||
开发地址:`http://localhost:5173`。通过 `VITE_API_BASE_URL` 配置共享后端地址,默认 `http://localhost:8000`。
|
||||
`VITE_` 配置会进入浏览器代码,不用于保存服务器密钥。
|
||||
`accountId` 是主账户,`promotionId` 是进入投放工作区的子账户 ID,`projectId` 是项目 ID。`promotion-manage` 是功能名,不是额外的数据实体。URL 层级会与后端归属校验相互验证;非法或无权限的 ID 不会回退到其他账户。未登录访问受保护页面会跳转登录,登录后回到原链接。
|
||||
|
||||
```sh
|
||||
npm run typecheck
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
注册创建独立广告主用户、主账户及默认子账户;所有账户和已保存项目进入后端关系数据库。终端流量用户的 `identity` 表和广告主身份完全分离。
|
||||
|
||||
## 当前范围
|
||||
## Gitea CI/CD
|
||||
|
||||
提供导航、空数据概览、内存中的广告草稿预览,以及共享后端的 `/health` 状态检查。
|
||||
草稿刷新即清空;尚未实现登录、持久化、广告发布或业务报表。界面中的 `—` 表示尚无真实数据。
|
||||
- `.gitea/workflows/deploy.yml`:main 推送或手动触发,复用 `ubuntu-latest` runner(现有 Docker 宿主机)。
|
||||
- Docker 多阶段构建:Node 22 执行 `npm ci`、TypeScript 检查和 Vite 打包;Nginx 提供页面与同源广告主 API 代理。
|
||||
- Playwright 测试镜像运行在 runner,访问真实部署的 `BASE_URL`。覆盖注册登录、主子账户 URL、六步项目创建、修改、深链接刷新、前进后退、重新登录返回、错误账户和移动端工作台。
|
||||
- 独立容器 `adcloud-advertiser-console`、并发组与默认端口 `18081`,后端继续使用 `18080`。
|
||||
- 先验证候选容器和认证 API,再替换前端;公开端口或浏览器验证失败恢复上一个前端容器。
|
||||
- 同仓库 PR 只构建;外部 fork PR 不在拥有 Docker socket 的 runner 执行。
|
||||
|
||||
后续业务范围:广告创建与审核、素材管理、预算及投放计划、投放报表。
|
||||
仓库变量:`ADCLOUD_ADVERTISER_PORT`(18081)、`ADCLOUD_ADVERTISER_API_UPSTREAM`(`http://host.docker.internal:18080`)、`ADCLOUD_ADVERTISER_PUBLIC_URL`(`http://47.76.92.159:18081`)。拉取源码优先使用现有 `GITEA_TOKEN` secret,否则使用 Actions 自动提供的短期仓库 token。无需把密码写入仓库。
|
||||
|
||||
## 项目边界
|
||||
先部署后端的 `/v1/advertiser` 接口,并在其 CORS 中允许前端公网 origin。测试 HTTP 环境使用 `COOKIE_SECURE=false`;生产需要 HTTPS 和 `COOKIE_SECURE=true`。Nginx 支持所有前端深链接 fallback,API 路径单独代理,API 404 不会伪装成 HTML。
|
||||
|
||||
- `adcloud-advertiser-console`:广告商投放、预算和效果管理。
|
||||
- `adcloud-developer-console`:开发者应用、广告位、接入和收益管理。
|
||||
- `adcloud-server`:共享后端,统一负责身份认证、角色权限、埋点、广告投放和报表 API。
|
||||
- `adcloud-react-sdk`:提供给第三方应用集成的 SDK。
|
||||
CI 浏览器验证使用带 `CI` 标记的测试广告主和项目,保留在测试环境,不能视为真实客户数据。依赖数据库的后端集成阶段不允许跳过测试。
|
||||
|
||||
两套控制台独立构建和部署。角色权限与数据隔离需要由后端校验。
|
||||
本项目目前只调用 `/health`;后端业务接口未定义,之后可按 OpenAPI 生成共用管理 API 客户端。
|
||||
## 开发配置
|
||||
|
||||
后端默认 CORS 允许 3000、5173 端口。要同时运行两套控制台,在 `adcloud-server` 目录运行:
|
||||
Node.js 22.12+。默认 `VITE_API_BASE_URL` 留空,Vite 开发代理指向 `127.0.0.1:8000`;部署使用 Nginx 同源代理。不要在 `VITE_` 配置放密钥。依据当前工作约定,测试与发布验证在 Gitea runner 和部署服务器完成,而非本机。
|
||||
|
||||
```sh
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:5174 uv run uvicorn adcloud_server.main:app --reload
|
||||
```
|
||||
## 首版边界
|
||||
|
||||
项目只有“草稿”和“已暂停”,没有实际投放、资金消耗或结算;报表展示空态。尚无成员邀请与细分角色、邮箱验证、密码找回、资质审核、素材和投放引擎。监测链接当前只保存配置,不主动请求外部地址。
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
location = /healthz { access_log off; return 200 'ok'; }
|
||||
location /v1/advertiser/ {
|
||||
proxy_pass ${API_UPSTREAM};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 30s;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
location /assets/ { try_files $uri =404; expires 1y; add_header Cache-Control "public, immutable"; }
|
||||
location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
name=adcloud-advertiser-console
|
||||
image="adcloud-advertiser:${SOURCE_REVISION:?}"
|
||||
port="${DEPLOY_PORT:-18081}"
|
||||
[[ "$port" =~ ^[0-9]+$ ]] && (( port > 0 && port < 65536 )) || { echo 'Invalid deploy port'; exit 1; }
|
||||
case "${API_UPSTREAM:?}" in http://*|https://*) ;; *) echo 'Invalid API upstream'; exit 1;; esac
|
||||
# A unique candidate verifies the proxy before replacing the published service.
|
||||
candidate="${name}-candidate-${SOURCE_REVISION:0:12}"
|
||||
cleanup() { docker rm -f "$candidate" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
docker run -d --name "$candidate" --add-host host.docker.internal:host-gateway -e API_UPSTREAM "$image"
|
||||
ready=false
|
||||
for attempt in {1..30}; do
|
||||
if docker exec "$candidate" wget -q -O /dev/null http://127.0.0.1/healthz; then ready=true; break; fi
|
||||
sleep 1
|
||||
done
|
||||
[[ "$ready" == true ]] || { echo 'Candidate failed health check'; exit 1; }
|
||||
# Auth endpoint should exist and reject unauthenticated requests, rather than 404/502.
|
||||
docker exec "$candidate" sh -c 'wget -S -O /dev/null http://127.0.0.1/v1/advertiser/me 2>&1 | grep "401 Unauthorized"'
|
||||
old=false
|
||||
if docker container inspect "$name" >/dev/null 2>&1; then
|
||||
docker stop "$name"
|
||||
docker rename "$name" "${name}-previous"
|
||||
old=true
|
||||
fi
|
||||
rollback() {
|
||||
docker rm -f "$name" >/dev/null 2>&1 || true
|
||||
if [[ "$old" == true ]]; then docker rename "${name}-previous" "$name"; docker start "$name"; fi
|
||||
}
|
||||
if ! docker run -d --name "$name" --restart unless-stopped --add-host host.docker.internal:host-gateway -p "$port:80" -e API_UPSTREAM "$image"; then rollback; exit 1; fi
|
||||
ready=false
|
||||
for attempt in {1..30}; do
|
||||
if docker exec "$name" wget -q -O /dev/null http://127.0.0.1/healthz; then ready=true; break; fi
|
||||
sleep 1
|
||||
done
|
||||
if [[ "$ready" != true ]]; then rollback; exit 1; fi
|
||||
if ! docker exec "$candidate" wget -q -O /dev/null "http://host.docker.internal:$port/healthz"; then
|
||||
echo "Published port probe failed; restoring previous deployment"
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
# Browser tests run on this runner against the actual deployed public URL.
|
||||
if ! docker run --rm --network host --ipc=host -e BASE_URL "adcloud-advertiser:$SOURCE_REVISION-tests"; then
|
||||
echo 'Deployed browser checks failed; restoring previous frontend'
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$old" == true ]]; then docker rm "${name}-previous"; fi
|
||||
echo "Advertiser console deployed on port $port, revision $SOURCE_REVISION"
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AdCloud · 广告商控制台</title>
|
||||
<title>AdCloud · 广告主控制台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+64
@@ -12,6 +12,7 @@
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
@@ -477,6 +478,22 @@
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.63.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz",
|
||||
@@ -975,6 +992,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
|
||||
+3
-1
@@ -7,13 +7,15 @@
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test:ci": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 60000,
|
||||
expect: { timeout: 10000 },
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL,
|
||||
browserName: "chromium",
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
trace: "off",
|
||||
},
|
||||
});
|
||||
+1378
-54
File diff suppressed because it is too large
Load Diff
+73
@@ -0,0 +1,73 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
export interface Account {
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
name: string;
|
||||
company: string;
|
||||
industry: string;
|
||||
}
|
||||
export interface Project {
|
||||
id?: string;
|
||||
name: string;
|
||||
objective: string;
|
||||
ad_type: string;
|
||||
scene: string;
|
||||
budget: number;
|
||||
bid: number;
|
||||
audience: string;
|
||||
region: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
landing_url: string;
|
||||
status: string;
|
||||
product_name: string;
|
||||
conversion_goal: string;
|
||||
tracking_url: string;
|
||||
}
|
||||
const base = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
||||
export async function api<T>(
|
||||
path: string,
|
||||
method = "GET",
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${base}/v1/advertiser${path}`, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: body ? { "Content-Type": "application/json" } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.status === 401 && !["/login", "/me"].includes(path))
|
||||
window.dispatchEvent(new Event("session-expired"));
|
||||
throw new Error(
|
||||
typeof data.detail === "string"
|
||||
? data.detail
|
||||
: response.status === 422
|
||||
? "请检查必填项、预算及投放日期"
|
||||
: "服务暂时不可用,请稍后重试",
|
||||
);
|
||||
}
|
||||
return response.status === 204 ? (undefined as T) : response.json();
|
||||
}
|
||||
export const emptyProject = (): Project => ({
|
||||
name: "",
|
||||
objective: "销售线索",
|
||||
ad_type: "通投广告",
|
||||
scene: "短视频与图文",
|
||||
budget: 300,
|
||||
bid: 30,
|
||||
audience: "智能推荐",
|
||||
region: "全国",
|
||||
start_date: new Date().toLocaleDateString("en-CA"),
|
||||
end_date: "",
|
||||
landing_url: "",
|
||||
status: "草稿",
|
||||
product_name: "",
|
||||
conversion_goal: "获取线索",
|
||||
tracking_url: "",
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export const steps = [
|
||||
"营销目标与场景",
|
||||
"推广产品与目标",
|
||||
"受众倾向与过滤",
|
||||
"排期与预算出价",
|
||||
"监测链接",
|
||||
"项目名称",
|
||||
];
|
||||
export const stepSlugs = [
|
||||
"goal",
|
||||
"product",
|
||||
"audience",
|
||||
"budget",
|
||||
"tracking",
|
||||
"name",
|
||||
];
|
||||
export type Route = {
|
||||
kind:
|
||||
| "landing"
|
||||
| "login"
|
||||
| "register"
|
||||
| "workbench"
|
||||
| "accounts"
|
||||
| "account-form"
|
||||
| "promotion"
|
||||
| "project"
|
||||
| "not-found";
|
||||
accountId?: string;
|
||||
promotionId?: string;
|
||||
projectId?: string;
|
||||
step?: number;
|
||||
edit?: boolean;
|
||||
};
|
||||
export function parseRoute(pathname: string): Route {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
if (!parts.length) return { kind: "landing" };
|
||||
if (
|
||||
parts.length === 1 &&
|
||||
["login", "register", "workbench"].includes(parts[0])
|
||||
)
|
||||
return { kind: parts[0] as Route["kind"] };
|
||||
if (parts[0] !== "account-manage") return { kind: "not-found" };
|
||||
if (parts.length === 1) return { kind: "accounts" };
|
||||
if (parts.length === 2 && parts[1] === "new") return { kind: "account-form" };
|
||||
const accountId = parts[1];
|
||||
if (parts.length === 2) return { kind: "accounts", accountId };
|
||||
if (parts.length === 3 && parts[2] === "edit")
|
||||
return { kind: "account-form", accountId, edit: true };
|
||||
if (parts[2] !== "promotion-manage") return { kind: "not-found" };
|
||||
if (parts.length === 4 && parts[3] === "new")
|
||||
return { kind: "account-form", accountId };
|
||||
const promotionId = parts[3];
|
||||
if (parts.length === 4) return { kind: "promotion", accountId, promotionId };
|
||||
if (parts.length === 5 && parts[4] === "edit")
|
||||
return { kind: "account-form", accountId, promotionId, edit: true };
|
||||
if (
|
||||
parts[4] === "create-project" &&
|
||||
parts.length === 6 &&
|
||||
stepSlugs.includes(parts[5])
|
||||
)
|
||||
return {
|
||||
kind: "project",
|
||||
accountId,
|
||||
promotionId,
|
||||
step: stepSlugs.indexOf(parts[5]),
|
||||
};
|
||||
if (
|
||||
parts[4] === "projects" &&
|
||||
parts[6] === "edit" &&
|
||||
parts.length === 8 &&
|
||||
stepSlugs.includes(parts[7])
|
||||
)
|
||||
return {
|
||||
kind: "project",
|
||||
accountId,
|
||||
promotionId,
|
||||
projectId: parts[5],
|
||||
step: stepSlugs.indexOf(parts[7]),
|
||||
};
|
||||
return { kind: "not-found" };
|
||||
}
|
||||
export const accountPath = (id?: string) =>
|
||||
`/account-manage${id ? `/${encodeURIComponent(id)}` : ""}`;
|
||||
export const promotionPath = (parent: string, child: string) =>
|
||||
`${accountPath(parent)}/promotion-manage/${encodeURIComponent(child)}`;
|
||||
export const projectPath = (
|
||||
parent: string,
|
||||
child: string,
|
||||
id?: string,
|
||||
step = 0,
|
||||
) =>
|
||||
`${promotionPath(parent, child)}/${id ? `projects/${encodeURIComponent(id)}/edit` : "create-project"}/${stepSlugs[step]}`;
|
||||
export function navigate(url: string, replace = false) {
|
||||
if (url === window.location.pathname + window.location.search) return;
|
||||
window.history[replace ? "replaceState" : "pushState"](null, "", url);
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
function subscribe(callback: () => void) {
|
||||
window.addEventListener("popstate", callback);
|
||||
return () => window.removeEventListener("popstate", callback);
|
||||
}
|
||||
export function useLocation() {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => window.location.pathname + window.location.search,
|
||||
);
|
||||
}
|
||||
export function safeReturnTo(search: string) {
|
||||
const target = new URLSearchParams(search).get("returnTo") || "/workbench";
|
||||
const parsed = parseRoute(target.split("?")[0]);
|
||||
return target.startsWith("/") &&
|
||||
!target.startsWith("//") &&
|
||||
!["login", "register", "landing", "not-found"].includes(parsed.kind)
|
||||
? target
|
||||
: "/workbench";
|
||||
}
|
||||
+1334
-40
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
parseRoute,
|
||||
projectPath,
|
||||
promotionPath,
|
||||
safeReturnTo,
|
||||
stepSlugs,
|
||||
} from "../src/navigation";
|
||||
|
||||
test("URL hierarchy and wizard steps resolve independently", () => {
|
||||
for (const [step] of stepSlugs.entries()) {
|
||||
expect(
|
||||
parseRoute(projectPath("main-id", "child-id", "project-id", step)),
|
||||
).toEqual({
|
||||
kind: "project",
|
||||
accountId: "main-id",
|
||||
promotionId: "child-id",
|
||||
projectId: "project-id",
|
||||
step,
|
||||
});
|
||||
expect(
|
||||
parseRoute(projectPath("main-id", "child-id", undefined, step)),
|
||||
).toEqual({
|
||||
kind: "project",
|
||||
accountId: "main-id",
|
||||
promotionId: "child-id",
|
||||
step,
|
||||
});
|
||||
}
|
||||
expect(parseRoute(promotionPath("main-id", "child-id"))).toEqual({
|
||||
kind: "promotion",
|
||||
accountId: "main-id",
|
||||
promotionId: "child-id",
|
||||
});
|
||||
expect(
|
||||
parseRoute(
|
||||
"/account-manage/main-id/promotion-manage/child-id/create-project/unknown",
|
||||
).kind,
|
||||
).toBe("not-found");
|
||||
expect(safeReturnTo("?returnTo=https://external.example")).toBe("/workbench");
|
||||
expect(safeReturnTo("?returnTo=//external.example")).toBe("/workbench");
|
||||
});
|
||||
|
||||
test("deployed console supports registration, scoped URLs, editing, refresh and login return", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const email = `ci-${Date.now()}-${Math.random().toString(16).slice(2)}@example.com`;
|
||||
const password = `CI-${crypto.randomUUID()}`;
|
||||
await page.goto("/register");
|
||||
await page.getByLabel("联系人姓名", { exact: true }).fill("CI 自动验证");
|
||||
await page
|
||||
.getByLabel("企业 / 主账户名称", { exact: true })
|
||||
.fill("CI 广告主验收");
|
||||
await page.getByLabel("邮箱", { exact: true }).fill(email);
|
||||
await page.getByLabel("密码", { exact: true }).fill(password);
|
||||
await page
|
||||
.getByRole("button", { name: "注册并进入工作台", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/workbench$/);
|
||||
await page.getByRole("button", { name: "账户管理", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/account-manage$/);
|
||||
await page.getByRole("link", { name: "CI 广告主验收", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/account-manage\/[^/]+$/);
|
||||
const parentUrl = new URL(page.url()).pathname;
|
||||
await page
|
||||
.getByRole("button", { name: "+ 新建子账户", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/promotion-manage\/new$/);
|
||||
await page.getByLabel("账户名称", { exact: true }).fill("CI 第二投放账户");
|
||||
await page.getByRole("button", { name: "保存账户", exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`${parentUrl}$`));
|
||||
await page
|
||||
.getByRole("link", { name: "CI 第二投放账户", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/promotion-manage\/[^/]+$/);
|
||||
const promotionUrl = new URL(page.url()).pathname;
|
||||
await page.getByRole("button", { name: "+ 新建项目", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/create-project\/goal$/);
|
||||
await page.getByRole("button", { name: "下一步 →", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/create-project\/product$/);
|
||||
await page.getByLabel("推广产品或服务", { exact: true }).fill("秋季新品");
|
||||
await page.getByRole("button", { name: "下一步 →", exact: true }).click();
|
||||
await page.getByLabel("投放地域", { exact: true }).fill("上海");
|
||||
await page.getByRole("button", { name: "下一步 →", exact: true }).click();
|
||||
await page.getByLabel("日预算(元,最低 100)", { exact: true }).fill("500");
|
||||
await page.getByRole("button", { name: "下一步 →", exact: true }).click();
|
||||
await page
|
||||
.getByLabel("落地页地址(可选)", { exact: true })
|
||||
.fill("https://example.com/product");
|
||||
await page.getByRole("button", { name: "下一步 →", exact: true }).click();
|
||||
await page.getByLabel("项目名称", { exact: true }).fill("CI 六步创建项目");
|
||||
await page.getByRole("button", { name: "保存项目", exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`${promotionUrl}$`));
|
||||
await page
|
||||
.getByRole("link", { name: "CI 六步创建项目", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/projects\/[^/]+\/edit\/goal$/);
|
||||
const editRoot = new URL(page.url()).pathname.replace(/goal$/, "");
|
||||
await page.goto(editRoot + "name");
|
||||
await expect(page.getByLabel("项目名称", { exact: true })).toHaveValue(
|
||||
"CI 六步创建项目",
|
||||
);
|
||||
await page.getByLabel("项目名称", { exact: true }).fill("CI 已修改项目");
|
||||
await page.getByRole("button", { name: "保存项目", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "CI 已修改项目", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.goto(editRoot + "budget");
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByLabel("日预算(元,最低 100)", { exact: true }),
|
||||
).toHaveValue("500");
|
||||
await page.getByRole("button", { name: "下一步 →", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/edit\/tracking$/);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/edit\/budget$/);
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(/\/edit\/tracking$/);
|
||||
await page.getByRole("button", { name: "退出", exact: true }).click();
|
||||
await page.goto(editRoot + "name");
|
||||
await expect(page).toHaveURL(/\/login\?returnTo=/);
|
||||
await page.getByLabel("邮箱", { exact: true }).fill(email);
|
||||
await page.getByLabel("密码", { exact: true }).fill(password);
|
||||
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`${editRoot}name$`));
|
||||
await expect(page.getByLabel("项目名称", { exact: true })).toHaveValue(
|
||||
"CI 已修改项目",
|
||||
);
|
||||
await page.goto(promotionUrl.replace(/[^/]+$/, "invalid-id"));
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "页面或账户不存在" }),
|
||||
).toBeVisible();
|
||||
await page.goto(promotionUrl);
|
||||
await page
|
||||
.getByRole("combobox", { name: "当前投放子账户" })
|
||||
.selectOption({ label: "CI 广告主验收 · 默认投放账户" });
|
||||
await expect(page).not.toHaveURL(new RegExp(`${promotionUrl}$`));
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "还没有推广项目" }),
|
||||
).toBeVisible();
|
||||
// A second tab can open the exact project and restore its account context.
|
||||
const other = await context.newPage();
|
||||
await other.goto(editRoot + "name");
|
||||
await expect(other.getByLabel("项目名称", { exact: true })).toHaveValue(
|
||||
"CI 已修改项目",
|
||||
);
|
||||
await other.close();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/workbench");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "你好,CI 自动验证" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({ server: { port: 5173, strictPort: true } });
|
||||
export default defineConfig({
|
||||
server: { proxy: { "/v1": "http://127.0.0.1:8000" } },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user