diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..28daa0e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +.env +.env.* +!.env.example diff --git a/.env.example b/.env.example index c2058b5..507a4da 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..21b371d --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore index b3c85c8..feeb1d4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ dist/ .env .env.* !.env.example +playwright-report/ +test-results/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7e181dd --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 94066bf..17b6d6b 100644 --- a/README.md +++ b/README.md @@ -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 -``` +## 首版边界 + +项目只有“草稿”和“已暂停”,没有实际投放、资金消耗或结算;报表展示空态。尚无成员邀请与细分角色、邮箱验证、密码找回、资质审核、素材和投放引擎。监测链接当前只保存配置,不主动请求外部地址。 diff --git a/deploy/default.conf.template b/deploy/default.conf.template new file mode 100644 index 0000000..a590283 --- /dev/null +++ b/deploy/default.conf.template @@ -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"; } +} diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..38103a9 --- /dev/null +++ b/deploy/deploy.sh @@ -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" diff --git a/index.html b/index.html index 1cd02cd..14a58be 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - AdCloud · 广告商控制台 + AdCloud · 广告主控制台
diff --git a/package-lock.json b/package-lock.json index 882a8fd..9dd840c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 627ab79..ff0ccfd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..71a4e79 --- /dev/null +++ b/playwright.config.ts @@ -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", + }, +}); diff --git a/src/App.tsx b/src/App.tsx index e5b1df2..c305b97 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,60 +1,1384 @@ -import { useEffect, useState, type FormEvent } from "react"; +import { + useEffect, + useState, + useRef, + type FormEvent, + type ReactNode, +} from "react"; +import { + api, + emptyProject, + type User, + type Account, + type Project, +} from "./api"; -const tabs = ["总览", "广告管理", "投放数据"]; -const metrics = ["广告消耗", "展示次数", "点击次数", "转化次数"]; -const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL || "http://localhost:8000").replace(/\/+$/, ""); - -export default function App() { - const [tab, setTab] = useState(tabs[0]); - const [connection, setConnection] = useState("检查中"); - const [name, setName] = useState(""); - const [drafts, setDrafts] = useState([]); - - useEffect(() => { - const controller = new AbortController(); - fetch(`${apiBaseUrl}/health`, { signal: controller.signal }) - .then(async (response) => { - if (!response.ok) throw new Error("Backend unavailable"); - const body = await response.json(); - if (body.status !== "ok") throw new Error("Invalid health response"); - if (!controller.signal.aborted) setConnection("已连接"); - }) - .catch(() => { if (!controller.signal.aborted) setConnection("未连接"); }); - return () => controller.abort(); - }, []); - - function saveDraft(event: FormEvent) { - event.preventDefault(); - const trimmed = name.trim(); - if (!trimmed) return; - setDrafts((current) => [...current, trimmed]); - setName(""); - } +import { + accountPath, + promotionPath, + projectPath, + parseRoute, + useLocation, + navigate, + safeReturnTo, + steps, +} from "./navigation"; +const icons = ["▦", "▤", "◈"]; +function Brand() { return ( -
- -
-
广告商控制台 / {tab}开发预览 · 登录待接入
-

ADCLOUD WORKSPACE

{tab}

管理广告、投放计划与效果数据。

-

当前为前端脚手架。业务 API、账号登录和真实数据尚未接入。后端健康检查:{connection}

- {tab === tabs[0] && <> -
{metrics.map((metric) =>

{metric}

—等待业务数据接入
)}
-

开始使用

在{tabs[1]}中创建本地广告草稿,预览工作流程。

- } - {tab === tabs[1] &&
-

广告草稿

仅保存在当前页面内存中,刷新后清空,不会发布或提交到服务器。

-
setName(event.target.value)} maxLength={100} required placeholder="输入名称" />
- {drafts.length === 0 ?

暂无草稿

:
    {drafts.map((draft, index) =>
  • {draft}本地草稿
  • )}
} -
} - {tab === tabs[2] &&

暂无数据

连接业务数据接口后,将在这里展示投放效果与转化数据。

} -
+ + A + AdCloud + 广告平台 + + ); +} +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + ); +} +function Metrics() { + return ( +
+ {["消耗(元)", "展示数", "点击数", "转化数", "平均转化成本(元)"].map( + (name) => ( +
+ {name} + — + 暂无投放数据 +
+ ), + )}
); } +export default function App() { + const [user, setUser] = useState(null); + const [ready, setReady] = useState(false); + const location = useLocation(); + const route = parseRoute(location.split("?")[0]); + const auth = + route.kind === "login" || route.kind === "register" ? route.kind : null; + const page = ["accounts", "account-form"].includes(route.kind) + ? "账户管理" + : ["promotion", "project"].includes(route.kind) + ? "项目管理" + : "工作台"; + const step = route.step || 0; + const [accountsReady, setAccountsReady] = useState(false); + const draftKey = useRef(""); + function setAuth(next: "login" | "register" | null) { + navigate(next ? `/${next}${window.location.search}` : "/"); + } + function setPage(next: string) { + navigate( + next === "账户管理" + ? accountPath() + : next === "项目管理" && selected?.parent_id + ? promotionPath(selected.parent_id, selected.id) + : next === "项目管理" + ? accountPath() + : "/workbench", + ); + } + function setStep(next: number) { + if (route.accountId && route.promotionId) + navigate( + projectPath(route.accountId, route.promotionId, route.projectId, next), + ); + } + function openAccount(value: Partial | null) { + if (!value) { + navigate(accountPath(route.accountId)); + return; + } + navigate( + value.id + ? value.parent_id + ? `${promotionPath(value.parent_id, value.id)}/edit` + : `${accountPath(value.id)}/edit` + : value.parent_id + ? `${accountPath(value.parent_id)}/promotion-manage/new` + : "/account-manage/new", + ); + } + function selectAccount(id: string) { + const account = accounts.find((a) => a.id === id); + if (account?.parent_id) + navigate(promotionPath(account.parent_id, account.id)); + } + const [accounts, setAccounts] = useState([]); + const [active, setActive] = useState(""); + const [projects, setProjects] = useState([]); + const [draft, setDraft] = useState(null); + const [accountDraft, setAccountDraft] = useState | null>( + null, + ); + const [search, setSearch] = useState(""); + const [status, setStatus] = useState("全部状态"); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [busy, setBusy] = useState(false); + const [loading, setLoading] = useState(false); + const selected = accounts.find((a) => a.id === active); + const parents = accounts.filter((a) => !a.parent_id); + const children = accounts.filter((a) => a.parent_id); + useEffect(() => { + api("/me") + .then(setUser) + .catch(() => {}) + .finally(() => setReady(true)); + const expired = () => { + setUser(null); + navigate( + `/login?returnTo=${encodeURIComponent(window.location.pathname + window.location.search)}`, + true, + ); + setError("登录已过期,请重新登录"); + }; + window.addEventListener("session-expired", expired); + return () => window.removeEventListener("session-expired", expired); + }, []); + useEffect(() => { + if (!user) { + setAccounts([]); + setProjects([]); + setActive(""); + setAccountDraft(null); + setDraft(null); + setNotice(""); + setAccountsReady(false); + return; + } + let live = true; + setAccountsReady(false); + api("/accounts") + .then((data) => { + if (live) { + setAccounts(data); + setActive((current) => + data.some((a) => a.id === current) + ? current + : data.find((a) => a.parent_id)?.id || "", + ); + setAccountsReady(true); + } + }) + .catch((e) => { + if (live) setError(e.message); + }); + return () => { + live = false; + }; + }, [user]); + useEffect(() => { + setProjects([]); + if (!active || !user) return; + let live = true; + setLoading(true); + api(`/accounts/${active}/projects`) + .then((data) => { + if (live) setProjects(data); + }) + .catch((e) => { + if (live) setError(e.message); + }) + .finally(() => { + if (live) setLoading(false); + }); + return () => { + live = false; + }; + }, [active, user]); + const invalidScope = + accountsReady && + !!route.accountId && + (!accounts.some((a) => a.id === route.accountId && !a.parent_id) || + (!!route.promotionId && + !accounts.some( + (a) => a.id === route.promotionId && a.parent_id === route.accountId, + ))); + const [routeError, setRouteError] = useState(""); + useEffect(() => { + if (!ready) return; + if ( + !user && + !["landing", "login", "register", "not-found"].includes(route.kind) + ) { + navigate(`/login?returnTo=${encodeURIComponent(location)}`, true); + } else if (user && ["landing", "login", "register"].includes(route.kind)) { + navigate(safeReturnTo(window.location.search), true); + } + }, [ready, user, location]); + useEffect(() => { + setRouteError(""); + if (!user || !accountsReady || invalidScope) return; + if (route.promotionId) setActive(route.promotionId); + if (route.kind === "account-form") { + const target = route.promotionId || route.accountId; + setAccountDraft( + route.edit + ? accounts.find((a) => a.id === target)! + : route.accountId + ? { + parent_id: route.accountId, + company: accounts.find((a) => a.id === route.accountId) + ?.company, + } + : { parent_id: null }, + ); + } else setAccountDraft(null); + }, [location, user, accountsReady, invalidScope, accounts]); + useEffect(() => { + if (route.kind !== "project") { + setDraft(null); + draftKey.current = ""; + return; + } + if (!user || !accountsReady || invalidScope) return; + const key = `${user.id}/${route.promotionId}/${route.projectId || "new"}`; + if (draftKey.current === key && draft) return; + let live = true; + setDraft(null); + draftKey.current = ""; + setRouteError(""); + if (!route.projectId) { + draftKey.current = key; + setDraft(emptyProject()); + } else { + api(`/accounts/${route.promotionId}/projects/${route.projectId}`) + .then((data) => { + if (live) { + draftKey.current = key; + setDraft({ ...emptyProject(), ...data }); + } + }) + .catch((e) => { + if (live) setRouteError(e.message); + }); + } + return () => { + live = false; + }; + }, [ + route.kind, + route.promotionId, + route.projectId, + user, + accountsReady, + invalidScope, + ]); + async function authenticate(e: FormEvent) { + e.preventDefault(); + const data = Object.fromEntries(new FormData(e.currentTarget)); + setBusy(true); + setError(""); + try { + setUser(await api(`/${auth}`, "POST", data)); + navigate(safeReturnTo(window.location.search), true); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + } + function newProject() { + setError(""); + if (selected?.parent_id) + navigate(projectPath(selected.parent_id, selected.id)); + } + async function saveProject(e: FormEvent) { + e.preventDefault(); + if (!draft || !active) return; + setBusy(true); + setError(""); + const { id, ...body } = draft; + try { + const saved = await api( + `/accounts/${active}/projects${id ? `/${id}` : ""}`, + id ? "PUT" : "POST", + body, + ); + setProjects((prev) => + id ? prev.map((p) => (p.id === id ? saved : p)) : [...prev, saved], + ); + draftKey.current = ""; + navigate(promotionPath(route.accountId!, active)); + setDraft(null); + setNotice(id ? "项目修改已保存" : "项目创建成功,已保存为草稿"); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + } + async function saveAccount(e: FormEvent) { + e.preventDefault(); + if (!accountDraft) return; + setBusy(true); + setError(""); + const body = Object.fromEntries(new FormData(e.currentTarget)); + try { + await api( + `/accounts${accountDraft.id ? `/${accountDraft.id}` : ""}`, + accountDraft.id ? "PUT" : "POST", + accountDraft.id + ? body + : { ...body, parent_id: accountDraft.parent_id || null }, + ); + const data = await api("/accounts"); + setAccounts(data); + if (!active) setActive(data.find((a) => a.parent_id)?.id || ""); + openAccount(null); + setNotice("账户信息已保存"); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + } + const choices = ( + key: keyof Project, + values: string[], + descriptions?: string[], + ) => ( +
+ {values.map((value, i) => ( + + ))} +
+ ); + if (route.kind === "not-found" || (user && invalidScope)) + return ( +
+

页面或账户不存在

+

请检查链接,或返回你有权限访问的账户。

+ 返回账户管理 +
+ ); + if (!ready) return
正在载入 AdCloud…
; + return ( + <> + {!user && !auth ? ( +
+
+ + + + +
+
+
+
+ + ADCLOUD · 每一次连接,都创造价值 + +

+ 让好产品, +
+ 遇见对的人。 +

+

+ 连接多元流量场景,让营销从一次触达走向持续增长。 +
+ 从账户管理到项目配置,在一个工作台轻松完成。 +

+
+ + +
+
+ ◎ 多账户协同 + ◈ 灵活项目配置 + ↗ 全链路管理 +
+
+
+
+
+ 为不同目标,找到适合的路径 +

从业务出发,让营销更简单

+
+
+ {[ + [ + "◎", + "获取销售线索", + "围绕你的产品与服务,配置专属获客项目。", + ], + ["◈", "推广产品与应用", "统一管理营销场景、人群与预算策略。"], + [ + "▦", + "主子账户协同", + "按品牌和业务划分账户,让项目各有所属。", + ], + ].map(([icon, title, copy]) => ( +
+ {icon} +

{title}

+

{copy}

+
+ ))} +
+
+
+

三步,开始你的营销旅程

+

01 注册广告主 → 02 配置投放子账户 → 03 创建营销项目

+ +
+
+
+ AdCloud 广告平台 连接产品与用户,让价值被看见。 +
+
+ ) : user ? ( +
+
+ + +
+ {user.name.slice(0, 1)} + {user.name} + +
+
+ +
+
+ 广告主中心{" "} + + / {page} + {draft ? " / " + (draft.id ? "编辑项目" : "新建项目") : ""} + +
+
+
+

+ {draft + ? draft.id + ? "编辑项目" + : "新建项目" + : page === "工作台" + ? `你好,${user.name}` + : page} +

+

+ {draft + ? "围绕业务目标,配置你的营销项目" + : "从这里开始,管理你的每一次增长。"} +

+
+ +
+ {error && ( +
+ {error} + +
+ )} + {notice && ( +
+ {notice} + +
+ )} + {page === "工作台" && ( + <> +
+
+ + YOUR NEXT GROWTH STARTS HERE + +

好的生意,从一次精准连接开始

+

完善账户,创建项目,为下一次增长做好准备。

+ +
+
+ ↗GROW WITH ADCLOUD +
+
+
+
+

+ 投放概览{" "} + + {selected?.name || "未选择子账户"} + +

+ 广告投放尚未启用 +
+ +
+ ▥ + 你的增长曲线,即将从这里开始 +

接入投放与报表服务后,这里将展示真实效果数据。

+
+
+
+
+

快速开始

+
+ + +
+
+
+

项目进度

+
+ {projects.length} + 当前子账户的项目 + +
+

+ 项目保存后为草稿,不会自动产生广告消耗。 +

+
+
+ + )} + {page === "账户管理" && route.kind !== "account-form" && ( + <> +
+ 主账户统一管理业务;子账户承载具体投放项目。广告主身份与终端流量用户完全独立。 +
+
+
+

+ 我的广告账户{" "} + {accounts.length} +

+ +
+ {parents.length === 0 && ( +
暂无账户,请新建主账户。
+ )} + {parents + .filter( + (parent) => + !route.accountId || parent.id === route.accountId, + ) + .map((parent) => ( +
+
+ ▦ +
+ + {parent.name} + + 主账户 · {parent.company} +
+ + +
+
+ + + + + + + + + + + + {children + .filter((a) => a.parent_id === parent.id) + .map((a) => ( + + + + + + + + ))} + +
子账户信息行业账户状态所属主账户操作
+ + {a.name} + + ID: {a.id} + {a.industry} + + 已创建 + {parent.name} + + +
+ {!children.some((a) => a.parent_id === parent.id) && ( +

+ 暂无子账户,创建后即可配置项目。 +

+ )} +
+
+ ))} +
+
+
+

登录信息

+

+ {user.name} · {user.email} +

+
+ 邮箱尚未验证 · 资质审核待接入 +
+ + )} + {page === "项目管理" && route.kind !== "project" && ( + <> +
+ 当前项目仅保存配置。广告审核、实际投放与结算尚未启用。 +
+
+
+

推广表现

+ 通投广告 / 搜索广告 +
+ +
+
+
+

+ 项目列表 {projects.length} +

+ +
+
+ setSearch(e.target.value)} + /> + + 日预算单位:元 +
+
+ + + + {[ + "项目名称", + "营销目标", + "项目状态", + "日预算", + "出价", + "投放日期", + "操作", + ].map((h) => ( + + ))} + + + + {projects + .filter( + (p) => + (status === "全部状态" || p.status === status) && + `${p.name}${p.id}` + .toLowerCase() + .includes(search.toLowerCase()), + ) + .map((p) => ( + + + + + + + + + + ))} + +
{h}
+ + {p.name} + + ID: {p.id} + + {p.objective} + {p.ad_type} + + {p.status} + ¥ {p.budget.toLocaleString()}¥ {p.bid} + {p.start_date} + + {p.end_date ? `至 ${p.end_date}` : "持续投放"} + + + +
+
+ {loading ? ( +
正在加载项目…
+ ) : ( + !projects.some( + (p) => + (status === "全部状态" || p.status === status) && + `${p.name}${p.id}` + .toLowerCase() + .includes(search.toLowerCase()), + ) && ( +
+ ▤ +

+ {search || status !== "全部状态" + ? "没有符合条件的项目" + : "还没有推广项目"} +

+

从一个营销目标开始,创建你的第一个项目。

+
+ ) + )} +
+ 共 {projects.length} 个项目 · 当前子账户 +
+
+ + )} + {route.kind === "project" && !draft && ( +
+ {routeError || "正在加载项目…"} +
+ )} + {draft && page === "项目管理" && ( +
+ +
+

{steps[step]}

+ + + + + + +
+ + + {step > 0 && ( + + )} + {step < steps.length - 1 ? ( + + ) : ( + + )} +
+
+
+ )} + {route.kind === "account-form" && + accountDraft && + user && + !invalidScope && ( +
+
+ +

+ {accountDraft.id + ? "编辑账户" + : accountDraft.parent_id + ? "新建子账户" + : "新建主账户"} +

+
+ + + + + + + + + + {error && ( +

+ {error} +

+ )} + +
+
+
+ )} +
+
+ ) : null} + {auth && !user && ( +
+
+ + WELCOME TO ADCLOUD +

+ {auth === "login" ? "欢迎回到广告工作台" : "开启你的营销旅程"} +

+

+ {auth === "login" + ? "登录广告主账户,继续管理你的业务" + : "注册后自动创建主账户与默认投放子账户"} +

+
+ {auth === "register" && ( + <> + + + + + + + + )} + + + + + + + {error && ( +

+ {error} +

+ )} + +
+

+ {auth === "login" ? "还没有账户?" : "已有账户?"} + +

+
+
+ )} + + ); +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..a301720 --- /dev/null +++ b/src/api.ts @@ -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( + path: string, + method = "GET", + body?: unknown, +): Promise { + 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: "", +}); diff --git a/src/navigation.ts b/src/navigation.ts new file mode 100644 index 0000000..8ac14d5 --- /dev/null +++ b/src/navigation.ts @@ -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"; +} diff --git a/src/styles.css b/src/styles.css index ff2a4b0..beed8b1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,40 +1,1334 @@ -* { box-sizing: border-box; } -body { margin: 0; font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #17243c; background: #f5f7fb; } -button, input { font: inherit; } -button { cursor: pointer; } -button:focus-visible, a:focus-visible { outline: 3px solid #739cff; outline-offset: 3px; } -.layout { min-height: 100vh; display: grid; grid-template-columns: 240px minmax(0, 1fr); } -aside { background: #11203b; color: #dce5f7; padding: 34px 24px; display: flex; flex-direction: column; } -.brand { color: white; text-decoration: none; font-size: 29px; font-weight: 750; letter-spacing: -1px; } -.brand span { display: block; font-size: 13px; font-weight: 400; letter-spacing: 1px; color: #a9bad8; margin-top: 8px; } -nav { display: grid; gap: 10px; margin-top: 48px; } -nav button { border: 0; text-align: left; border-radius: 8px; padding: 14px 16px; background: transparent; color: #b8c7df; } -nav button.active, nav button:hover { background: #263b60; color: white; } -.aside-note { margin-top: auto; padding-top: 60px; font-size: 12px; line-height: 1.8; color: #91a5c6; } -main { padding: 0 42px 48px; max-width: 1500px; width: 100%; } -header { min-height: 80px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #e0e5ed; gap: 16px; color: #6e7b90; font-size: 13px; } -.badge { background: #e9eef6; color: #62748e; font-size: 12px; padding: 7px 10px; border-radius: 6px; white-space: nowrap; } -.intro { margin: 38px 0 24px; } -.eyebrow { color: #3c6ce3; letter-spacing: 2px; font-size: 11px; font-weight: 700; } -h1 { font-size: 32px; margin: 12px 0; letter-spacing: -1px; } -p { line-height: 1.7; } -.intro > p:last-child, .panel p { color: #738097; } -.notice { background: #edf2ff; border: 1px solid #dce5fc; padding: 14px 18px; border-radius: 10px; font-size: 13px; color: #50638b; } -.metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; margin: 26px 0; } -.metrics article, .panel { background: white; border: 1px solid #e2e7ef; border-radius: 12px; padding: 24px; } -.metrics p { color: #738097; margin: 0 0 20px; font-size: 13px; } -.metrics strong { display: block; font-size: 32px; margin-bottom: 14px; } -.metrics small { color: #96a2b4; font-size: 11px; } -.panel { margin-top: 26px; } -h2 { font-size: 18px; margin: 0 0 12px; } -.primary { background: #386be9; color: white; border: 0; border-radius: 7px; padding: 12px 18px; } -.primary:hover { background: #2856c6; } -form { margin: 28px 0; } -label { display: block; margin-bottom: 10px; font-size: 14px; } -.form-row { display: flex; gap: 12px; } -input { border: 1px solid #ccd5e4; border-radius: 7px; padding: 12px; flex: 1; min-width: 0; } -.empty { padding: 46px 24px; text-align: center; color: #96a2b4; } -.drafts { list-style: none; padding: 0; } -.drafts li { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 0; border-top: 1px solid #edf0f5; overflow-wrap: anywhere; } -@media (max-width: 1000px) { .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } main { padding: 0 24px 32px; } } -@media (max-width: 640px) { .layout { grid-template-columns: 1fr; } aside { padding: 20px; } nav { grid-template-columns: repeat(3, 1fr); margin-top: 20px; } nav button { text-align: center; padding: 12px 6px; } .aside-note { display: none; } header { flex-wrap: wrap; padding: 16px 0; } .form-row { flex-direction: column; } } +:root { + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + "PingFang SC", + "Microsoft YaHei", + sans-serif; + color: #202c43; + background: #f2f6fc; + font-synthesis: none; + font-size: 14px; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +button, +input, +select { + font: inherit; +} +button, +a, +input, +select { + -webkit-tap-highlight-color: transparent; +} +button { + cursor: pointer; + border: 1px solid #dfe5ef; + background: #fff; + color: #45516a; + border-radius: 6px; + padding: 9px 16px; + transition: 0.16s; +} +button:hover { + border-color: #8eaff7; + color: #2164ee; +} +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +button:focus-visible, +a:focus-visible { + outline: 3px solid #8fb4ff; + outline-offset: 3px; +} +input, +select { + border: 1px solid #dce3ee; + background: #fff; + border-radius: 6px; + padding: 11px 12px; + outline: none; + color: #26344a; + min-width: 0; +} +input:focus, +select:focus { + border-color: #3379ff; + box-shadow: 0 0 0 3px #edf3ff; +} +a { + color: inherit; + text-decoration: none; +} +h1, +h2, +h3, +p { + margin-top: 0; +} +h2 { + font-size: 19px; + font-weight: 650; +} +h3 { + font-size: 14px; +} +p { + line-height: 1.8; +} +small { + font-size: 12px; +} +.primary { + background: #1768f5; + border-color: #1768f5; + color: white; + box-shadow: 0 3px 8px #1768f51a; +} +.primary:hover { + background: #0858df; + color: #fff; +} +.large { + padding: 14px 23px; + font-size: 15px; +} +.link { + border: 0; + background: transparent; + color: #246aeb; + padding: 5px 9px; +} +.muted { + color: #8993a4; + font-size: 12px; +} +.brand { + display: flex; + align-items: center; + gap: 10px; + white-space: nowrap; +} +.brand b { + font-size: 24px; + letter-spacing: -0.8px; + color: #213453; +} +.brand-mark { + background: linear-gradient(145deg, #43dbdd, #2975f4 68%, #725fec); + font-size: 26px; + font-weight: 850; + color: white; + width: 38px; + height: 38px; + display: inline-grid; + place-items: center; + clip-path: polygon(50% 0, 100% 100%, 0 100%); + padding-top: 7px; +} +.brand-label { + border-left: 1px solid #d1d9e8; + padding-left: 12px; + color: #576982; + font-size: 15px; +} +.eyebrow { + font-size: 11px; + letter-spacing: 1.7px; + color: #3478e5; + font-weight: 600; +} +.public { + background: #fff; +} +.public-header { + height: 88px; + display: flex; + align-items: center; + gap: 18px; + max-width: 1440px; + margin: auto; + padding: 0 6%; +} +.public-header nav { + display: flex; + gap: 34px; + margin-left: auto; + margin-right: 35px; + color: #546279; +} +.landing { + background: + radial-gradient(ellipse at 82% 10%, #e5edff 0, transparent 46%), + linear-gradient(#f8fbff, #fff 68%); +} +.hero { + max-width: 1440px; + margin: auto; + min-height: 610px; + display: grid; + grid-template-columns: 1.05fr 1fr; + align-items: center; + padding: 60px 6%; + overflow: hidden; +} +.hero h1 { + font-size: 60px; + font-weight: 700; + line-height: 1.3; + letter-spacing: -2px; + margin: 26px 0; +} +.hero h1 span { + color: #226df0; +} +.hero p { + font-size: 15px; + color: #7b879c; + line-height: 2; +} +.actions { + display: flex; + gap: 12px; + margin-top: 28px; +} +.hero-tags { + display: flex; + gap: 25px; + margin-top: 35px; + color: #8b98ad; + font-size: 12px; +} +.hero-art { + position: relative; + height: 425px; + display: grid; + place-items: center; + perspective: 900px; +} +.orbit { + position: absolute; + border: 1px solid #bed4ff99; + border-radius: 50%; + transform: rotate(-25deg); + width: 570px; + height: 300px; +} +.orbit.two { + width: 440px; + height: 440px; + border: 45px solid #dce8ff50; +} +.preview { + background: #ffffffed; + width: 410px; + max-width: 90%; + border: 1px solid white; + border-radius: 15px; + padding: 24px; + box-shadow: 0 25px 70px #4075c92b; + transform: rotateY(-9deg) rotateZ(-5deg); + position: relative; +} +.preview-head { + display: flex; + align-items: center; + gap: 9px; + font-weight: 650; +} +.preview-head .brand-mark { + width: 23px; + height: 23px; + font-size: 15px; +} +.preview p { + font-size: 12px; + margin-top: 22px; +} +.preview-cards { + display: flex; + gap: 12px; +} +.preview-cards > div { + padding: 13px; + background: #f2f6ff; + width: 50%; + font-size: 11px; + color: #8a9bb8; + border-radius: 5px; +} +.preview-cards strong { + display: block; + color: #3b77e7; + font-size: 22px; + margin-top: 7px; +} +.chart { + height: 145px; + display: flex; + align-items: flex-end; + gap: 10px; + border-bottom: 1px solid #e6edfa; + padding: 10px 8px 0; +} +.chart i { + flex: 1; + border-radius: 3px 3px 0 0; + background: linear-gradient(#4385ff, #c9ddff); +} +.preview-footer { + display: flex; + justify-content: space-between; + margin-top: 17px; + font-size: 10px; + color: #9aa7bd; +} +.floating { + position: absolute; + background: #fff; + border: 1px solid #edf3ff; + box-shadow: 0 12px 35px #477be41c; + padding: 16px 22px; + border-radius: 11px; + color: #4a80dc; + z-index: 2; +} +.tag-a { + top: 15px; + right: 0; +} +.tag-b { + bottom: 0; + left: 12px; +} +.solutions { + max-width: 1260px; + padding: 60px 40px; + margin: auto; +} +.section-title { + text-align: center; +} +.section-title h2 { + font-size: 28px; + margin-top: 16px; +} +.feature-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 25px; + margin-top: 40px; +} +.feature-grid article { + border: 1px solid #e7edf5; + border-radius: 12px; + padding: 30px; + background: linear-gradient(135deg, #f8fbff, #fff); +} +.feature-grid i { + font-style: normal; + font-size: 30px; + color: #4984f6; +} +.feature-grid h3 { + font-size: 18px; + margin-top: 25px; +} +.feature-grid p { + color: #8b96a7; + font-size: 13px; +} +.workflow { + text-align: center; + background: #f5f9ff; + padding: 50px 20px; + margin-top: 35px; +} +.workflow h2 { + font-size: 26px; +} +.workflow p { + color: #7e8da6; + margin: 25px 0; +} +.public footer { + max-width: 1260px; + margin: auto; + padding: 32px 40px; + color: #7890b0; + font-size: 13px; + display: flex; + justify-content: space-between; +} +.console { + min-height: 100vh; + background: + radial-gradient(ellipse at 85% 0, #e1edff, transparent 40%), #f2f6fc; +} +.console-header { + height: 78px; + display: flex; + align-items: center; + padding: 0 30px; + gap: 48px; + background: #ffffffb3; + border-bottom: 1px solid #e7edf7; +} +.console-header nav { + display: flex; + gap: 22px; + height: 100%; + align-items: stretch; +} +.console-header nav button { + border: 0; + background: none; + border-radius: 0; + padding: 0 12px; + position: relative; +} +.console-header nav button.active { + color: #1768f5; + font-weight: 650; +} +.console-header nav button.active:after { + content: ""; + position: absolute; + bottom: 13px; + left: 40%; + width: 22%; + height: 3px; + background: #1768f5; + border-radius: 2px; +} +.user-area { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto; + font-size: 12px; +} +.user-area button { + border: 0; + background: transparent; + font-size: 12px; + padding: 6px; +} +.avatar { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 50%; + color: #3478eb; + background: #e2ecff; +} +.sidebar { + position: absolute; + width: 204px; + top: 78px; + bottom: 0; + min-height: 750px; + padding: 35px 18px; + display: flex; + flex-direction: column; + gap: 10px; +} +.aside-caption { + color: #9ba8bc; + font-size: 11px; + letter-spacing: 2px; + margin: 0 15px 20px; +} +.sidebar > button { + border: 0; + background: transparent; + text-align: left; + padding: 14px 18px; + display: flex; + gap: 14px; + color: #66758b; +} +.sidebar > button.active { + background: #fff; + color: #1768f5; + box-shadow: 0 5px 14px #4b7fc409; + font-weight: 600; +} +.aside-bottom { + margin-top: auto; + padding: 30px 15px; + color: #798da9; +} +.mini-mark { + display: block; + font-size: 35px; + color: #9abbf8; + margin-bottom: 20px; +} +.aside-bottom b { + line-height: 1.9; + font-size: 14px; +} +.aside-bottom p { + font-size: 10px; + margin-top: 15px; +} +.workspace { + margin-left: 204px; + padding: 23px 32px 60px; + max-width: 1800px; +} +.breadcrumb { + font-size: 11px; + color: #96a3b7; + margin-bottom: 27px; +} +.breadcrumb span { + margin-left: 8px; + color: #61728b; +} +.page-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + margin-bottom: 25px; +} +.page-heading h1 { + font-size: 25px; + font-weight: 650; + margin: 0 0 10px; +} +.page-heading p { + font-size: 12px; + color: #8a98ad; + margin: 0; +} +.account-selector { + display: flex; + flex-direction: column; + gap: 7px; + font-size: 10px; + color: #8c9ab0; +} +.account-selector select { + font-size: 12px; + min-width: 250px; + max-width: 350px; + padding: 10px; +} +.welcome-banner { + border: 1px solid #dfeaff; + background: linear-gradient(105deg, #e8f1ff, #f5f9ff 60%, #dbe9ff); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 30px 35px; + margin-bottom: 23px; + overflow: hidden; +} +.welcome-banner h2 { + font-size: 25px; + margin: 16px 0 10px; + font-weight: 650; +} +.welcome-banner p { + color: #7d93b5; + font-size: 12px; + margin-bottom: 20px; +} +.banner-art { + font-size: 100px; + line-height: 1; + background: linear-gradient(120deg, #69b8ff, #4373f1); + color: transparent; + background-clip: text; + padding: 0 40px; + transform: rotate(-6deg); + text-align: center; +} +.banner-art small { + display: block; + font-size: 9px; + letter-spacing: 2px; + margin-top: 10px; + color: #93b4e6; +} +.panel { + background: #fff; + border: 1px solid #e9eef6; + border-radius: 10px; + padding: 25px; + margin-bottom: 22px; + min-width: 0; +} +.panel-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px; + margin-bottom: 22px; +} +.panel-heading h2 { + margin: 0; + font-size: 16px; + display: flex; + align-items: center; + gap: 12px; +} +.pill { + background: #eff4fd; + color: #6d87b3; + padding: 5px 9px; + border-radius: 4px; + font-size: 10px; + font-weight: 400; +} +.metrics { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 14px; +} +.metrics > div { + background: #f7f9fc; + border-radius: 5px; + padding: 18px; + position: relative; +} +.metrics > div:after { + content: ""; + position: absolute; + right: 18px; + top: 40px; + width: 30px; + height: 2px; + background: #3b80f6; +} +.metrics span { + font-size: 11px; + color: #738097; + display: block; +} +.metrics strong { + display: block; + font-size: 26px; + margin: 17px 0 7px; + font-weight: 500; +} +.metrics small { + font-size: 10px; + color: #a8b2c3; +} +.chart-empty { + height: 180px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #a5b3c8; + background: repeating-linear-gradient( + to bottom, + transparent 0, + transparent 43px, + #f4f7fb 44px + ); + margin-top: 20px; +} +.chart-empty > span { + font-size: 30px; + color: #bcd0ef; + margin-bottom: 13px; +} +.chart-empty b { + font-size: 12px; + font-weight: 400; +} +.chart-empty p { + font-size: 11px; + margin-top: 9px; +} +.dashboard-bottom { + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 22px; +} +.dashboard-bottom h2 { + font-size: 15px; +} +.quick-actions { + display: flex; + gap: 15px; + margin-top: 24px; +} +.quick-actions button { + flex: 1; + border: 0; + text-align: left; + background: #f8faff; + padding: 15px; +} +.quick-actions button span { + display: block; + color: #5c8de8; + font-size: 23px; + margin-bottom: 13px; +} +.quick-actions button b { + display: block; + font-size: 12px; + font-weight: 500; + margin-bottom: 8px; +} +.quick-actions button small { + font-size: 10px; + color: #97a5bb; +} +.progress-line { + display: flex; + align-items: center; + gap: 15px; + margin: 25px 0; +} +.progress-line strong { + font-size: 40px; + font-weight: 500; + color: #397aeb; +} +.progress-line > span { + color: #8b99ad; + font-size: 12px; +} +.progress-line button { + margin-left: auto; + font-size: 12px; +} +.info-strip { + padding: 13px 17px; + background: #eaf2ff; + border: 1px solid #dce9fc; + border-radius: 6px; + color: #6b87b0; + font-size: 12px; + margin-bottom: 22px; + line-height: 1.8; +} +.account-group { + border: 1px solid #e7edf5; + border-radius: 8px; + margin-top: 20px; + overflow: hidden; +} +.parent-row { + display: flex; + gap: 15px; + align-items: center; + background: #f8faff; + padding: 20px; +} +.parent-row > div { + flex: 1; +} +.parent-row small, +td small { + display: block; + color: #9aa5b7; + font-size: 10px; + margin-top: 7px; +} +.account-icon { + background: #e9f0ff; + color: #4d84ee; + font-size: 23px; + padding: 10px; + border-radius: 8px; +} +.table-wrap { + overflow: auto; +} +table { + border-collapse: collapse; + width: 100%; + font-size: 12px; + white-space: nowrap; + text-align: left; +} +th { + background: #f7f9fc; + color: #8590a2; + font-weight: 400; + padding: 15px; +} +td { + padding: 20px 15px; + border-bottom: 1px solid #eef1f6; + color: #69778d; +} +td b { + font-weight: 500; + color: #3a506f; +} +tbody tr:hover { + background: #f8fbff; +} +.status-dot { + display: inline-block; + width: 6px; + height: 6px; + background: #43bf86; + border-radius: 50%; + margin-right: 7px; +} +.table-toolbar { + display: flex; + gap: 13px; + align-items: center; + margin-bottom: 20px; +} +.table-toolbar input { + width: 300px; + font-size: 12px; +} +.table-toolbar select { + font-size: 12px; +} +.table-toolbar .muted { + margin-left: auto; +} +.table-footer { + padding-top: 20px; + font-size: 11px; + color: #9ba8bb; + text-align: right; +} +.empty { + text-align: center; + padding: 45px 20px; + color: #a5b1c3; +} +.empty h3 { + color: #7d8fa9; + font-weight: 500; + margin: 14px 0 8px; +} +.empty p { + font-size: 12px; +} +.empty-symbol { + font-size: 35px; + color: #bed0ed; +} +.profile { + display: flex; + justify-content: space-between; + align-items: center; +} +.profile h2 { + font-size: 15px; +} +.profile p { + margin: 0; + font-size: 12px; + color: #7388a5; +} +.editor { + display: grid; + grid-template-columns: 205px minmax(0, 1fr); + gap: 20px; +} +.steps { + background: #ffffffa6; + border: 1px solid white; + padding: 20px 12px; + border-radius: 10px; + align-self: start; +} +.steps button { + display: flex; + align-items: center; + gap: 10px; + font-size: 12px; + padding: 16px 10px; + background: none; + border: 0; + width: 100%; + text-align: left; +} +.steps button span { + display: grid; + place-items: center; + background: #eaf0fa; + border-radius: 50%; + width: 22px; + height: 22px; + font-size: 11px; +} +.steps button.active { + color: #1768f5; +} +.steps button.active span { + background: #1768f5; + color: #fff; +} +.steps p { + color: #a0aec1; + font-size: 10px; + padding: 30px 12px 5px; + margin: 0; +} +.project-form { + padding: 30px; + min-height: 540px; +} +.project-form h2 { + font-size: 17px; + margin-bottom: 35px; +} +.project-form h3 { + margin: 28px 0 13px; + font-size: 13px; + font-weight: 500; +} +.choice-cards { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} +.choice-cards button { + text-align: left; + min-height: 92px; + border-color: #eef1f7; + background: #f7f9fc; + font-weight: 600; + font-size: 13px; + padding: 17px 14px; +} +.choice-cards small { + display: block; + font-size: 10px; + line-height: 1.8; + color: #9aa7ba; + margin-top: 12px; + font-weight: 400; +} +.choice-cards button.chosen, +.segments button.chosen { + border-color: #3579ed; + background: #f6f9ff; + color: #2569df; +} +.segments { + display: flex; + gap: 8px; +} +.segments button { + font-size: 12px; + min-width: 130px; + background: #f7f9fc; + border-color: #f0f2f7; +} +.project-form .info-strip { + margin-top: 35px; +} +.field { + display: flex; + flex-direction: column; + gap: 10px; + margin: 22px 0; + color: #5d6d85; + font-size: 12px; +} +.field input, +.field select { + width: 100%; +} +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} +.summary { + background: #f7faff; + border: 1px solid #e4edff; + border-radius: 7px; + padding: 20px; + color: #8194b0; + font-size: 12px; + margin: 25px 0; +} +.summary b { + color: #5676a4; +} +.summary p { + margin: 12px 0; +} +.summary small { + font-size: 11px; +} +.editor-footer { + display: flex; + gap: 10px; + border-top: 1px solid #edf1f7; + padding-top: 25px; + margin-top: 40px; +} +.editor-footer > span { + flex: 1; +} +.modal-backdrop { + position: fixed; + inset: 0; + background: #172e5866; + backdrop-filter: blur(6px); + z-index: 50; + display: flex; + justify-content: center; + align-items: center; + padding: 25px; + overflow: auto; +} +.modal { + position: relative; + background: white; + width: 460px; + max-width: 100%; + padding: 36px; + border-radius: 15px; + box-shadow: 0 30px 100px #162d5244; + max-height: calc(100dvh - 50px); + overflow: auto; +} +.modal h2 { + margin: 15px 0; + font-size: 24px; +} +.modal .close { + position: absolute; + right: 13px; + top: 10px; + border: 0; + background: none; + font-size: 25px; + color: #9aa8bd; + padding: 2px 8px; +} +.full { + width: 100%; + margin-top: 10px; + padding: 12px; +} +.auth-switch { + text-align: center; + font-size: 12px; + color: #8998ad; + margin: 22px 0 0; +} +.alert { + border: 1px solid; + padding: 13px 17px; + margin-bottom: 20px; + border-radius: 6px; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; +} +.alert button { + padding: 2px 8px; + border: 0; + background: none; + color: inherit; +} +.error { + background: #fff2f2; + color: #c25050; + border-color: #f3d9d9; +} +.success { + background: #f0fbf5; + color: #34845d; + border-color: #d5efdf; +} +.form-error { + color: #c25050; + font-size: 12px; +} +.boot { + display: grid; + place-items: center; + height: 100vh; + color: #7692ba; +} +[hidden] { + display: none !important; +} +@media (min-width: 1600px) { + .workspace { + padding-right: 50px; + padding-left: 40px; + } + .hero { + min-height: 690px; + } + .hero h1 { + font-size: 68px; + } +} +@media (max-width: 1100px) { + .console-header { + gap: 20px; + padding: 0 20px; + } + .brand-label { + display: none; + } + .sidebar { + width: 168px; + } + .workspace { + margin-left: 168px; + padding: 22px; + } + .metrics { + gap: 8px; + } + .metrics > div { + padding: 14px; + } + .metrics > div:after { + display: none; + } + .hero h1 { + font-size: 46px; + } + .preview { + width: 350px; + } + .hero-art { + height: 380px; + } + .hero-tags { + gap: 12px; + } + .dashboard-bottom { + grid-template-columns: 1fr; + } + .editor { + grid-template-columns: 160px 1fr; + } + .choice-cards { + grid-template-columns: repeat(2, 1fr); + } + .banner-art { + padding: 0 15px; + } + .welcome-banner h2 { + font-size: 21px; + } +} +@media (max-width: 760px) { + .public-header { + height: 72px; + padding: 0 20px; + gap: 10px; + } + .public-header nav { + display: none; + } + .public-header > .brand { + margin-right: auto; + } + .public-header button { + padding: 8px; + } + .brand b { + font-size: 21px; + } + .hero { + grid-template-columns: 1fr; + padding: 50px 25px 30px; + } + .hero h1 { + font-size: 45px; + } + .hero p { + font-size: 13px; + } + .hero-art { + margin-top: 40px; + height: 370px; + } + .hero-tags { + flex-wrap: wrap; + } + .feature-grid { + grid-template-columns: 1fr; + } + .solutions { + padding: 45px 25px; + } + .section-title h2 { + font-size: 23px; + } + .workflow p { + font-size: 11px; + } + .public footer { + font-size: 10px; + padding: 25px; + gap: 20px; + } + .console-header { + height: auto; + min-height: 70px; + flex-wrap: wrap; + gap: 8px; + padding: 15px; + } + .console-header nav { + order: 3; + width: 100%; + height: 42px; + gap: 20px; + } + .console-header nav button.active:after { + bottom: 0; + } + .user-area { + font-size: 11px; + } + .sidebar { + display: none; + } + .workspace { + margin-left: 0; + padding: 20px 15px; + } + .page-heading { + align-items: flex-start; + flex-direction: column; + } + .account-selector { + width: 100%; + } + .account-selector select { + max-width: none; + width: 100%; + } + .welcome-banner { + padding: 25px; + } + .welcome-banner h2 { + font-size: 19px; + } + .banner-art { + display: none; + } + .metrics { + grid-template-columns: repeat(2, 1fr); + } + .metrics > div:last-child { + grid-column: 1/-1; + } + .panel { + padding: 18px; + } + .panel-heading { + align-items: flex-start; + } + .panel-heading h2 { + flex-wrap: wrap; + line-height: 1.6; + } + .panel-heading .muted { + max-width: 100px; + text-align: right; + } + .editor { + grid-template-columns: 1fr; + } + .steps { + display: flex; + padding: 10px; + } + .steps p { + display: none; + } + .steps button { + font-size: 10px; + padding: 8px 5px; + gap: 5px; + } + .steps button span { + flex-shrink: 0; + } + .choice-cards { + grid-template-columns: 1fr 1fr; + } + .form-grid { + grid-template-columns: 1fr; + gap: 0; + } + .table-toolbar { + flex-wrap: wrap; + } + .table-toolbar input { + width: 100%; + } + .table-toolbar .muted { + margin-left: 0; + } + .parent-row { + padding: 15px; + flex-wrap: wrap; + } + .parent-row > div { + min-width: 60%; + } + .profile { + align-items: flex-start; + gap: 15px; + flex-direction: column; + } + .modal { + padding: 28px; + } + .actions { + flex-wrap: wrap; + } + .quick-actions { + flex-direction: column; + } +} + +.standalone-page { + min-height: 70vh; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 50px 20px; + background: #f2f6fc; +} +.standalone-page .modal { + max-height: none; + overflow: visible; +} +.route-message { + max-width: 700px; + margin: 80px auto; + padding: 30px; +} +.route-message a { + color: #1768f5; +} diff --git a/tests/navigation.spec.ts b/tests/navigation.spec.ts new file mode 100644 index 0000000..0f6740a --- /dev/null +++ b/tests/navigation.spec.ts @@ -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(); +}); diff --git a/vite.config.ts b/vite.config.ts index 4d1b532..d09e6a4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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" } }, +});