Initialize AdCloud console with React and TypeScript

This commit is contained in:
2026-09-15 20:30:32 +08:00
commit b9687b42ea
11 changed files with 1416 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8000
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.DS_Store
.env
.env.*
!.env.example
+45
View File
@@ -0,0 +1,45 @@
# AdCloud · 开发者控制台
管理应用接入、广告位与流量收益。 React + TypeScript + Vite 独立前端项目。
## 启动
需要 Node.js 20.19+ 或 22.12+。
```sh
npm ci
cp .env.example .env
npm run dev
```
开发地址:`http://localhost:5174`。通过 `VITE_API_BASE_URL` 配置共享后端地址,默认 `http://localhost:8000`。
`VITE_` 配置会进入浏览器代码,不用于保存服务器密钥。
```sh
npm run typecheck
npm run build
npm run preview
```
## 当前范围
提供导航、空数据概览、内存中的应用草稿预览,以及共享后端的 `/health` 状态检查。
草稿刷新即清空;尚未实现登录、持久化、广告发布或业务报表。界面中的 `—` 表示尚无真实数据。
后续业务范围:应用与广告位管理、SDK / coding agent 接入文档、流量及收益报表。
## 项目边界
- `adcloud-advertiser-console`:广告商投放、预算和效果管理。
- `adcloud-developer-console`:开发者应用、广告位、接入和收益管理。
- `adcloud-server`:共享后端,统一负责身份认证、角色权限、埋点、广告投放和报表 API。
- `adcloud-react-sdk`:提供给第三方应用集成的 SDK。
两套控制台独立构建和部署。角色权限与数据隔离需要由后端校验。
本项目目前只调用 `/health`;后端业务接口未定义,之后可按 OpenAPI 生成共用管理 API 客户端。
后端默认 CORS 允许 3000、5173 端口。要同时运行两套控制台,在 `adcloud-server` 目录运行:
```sh
CORS_ORIGINS=http://localhost:5173,http://localhost:5174 uv run uvicorn adcloud_server.main:app --reload
```
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AdCloud · 开发者控制台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1197
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@entroia/adcloud-developer-console",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"typecheck": "tsc --noEmit",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.0.0",
"vite": "^7.0.0"
}
}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useState, type FormEvent } from "react";
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<string[]>([]);
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<HTMLFormElement>) {
event.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setDrafts((current) => [...current, trimmed]);
setName("");
}
return (
<div className="layout">
<aside>
<a className="brand" href="/">AdCloud<span>开发者控制台</span></a>
<nav aria-label="主导航">
{tabs.map((item) => <button key={item} className={tab === item ? "active" : ""} aria-current={tab === item ? "page" : undefined} onClick={() => setTab(item)}>{item}</button>)}
</nav>
<p className="aside-note">面向开发者与 coding agent 的广告云</p>
</aside>
<main>
<header><span>开发者控制台 / {tab}</span><span className="badge">开发预览 · 登录待接入</span></header>
<section className="intro"><p className="eyebrow">ADCLOUD WORKSPACE</p><h1>{tab}</h1><p>管理应用接入、广告位与流量收益。</p></section>
<p className="notice">当前为前端脚手架。业务 API、账号登录和真实数据尚未接入。后端健康检查:<strong role="status">{connection}</strong></p>
{tab === tabs[0] && <>
<section className="metrics" aria-label="数据概览">{metrics.map((metric) => <article key={metric}><p>{metric}</p><strong>—</strong><small>等待业务数据接入</small></article>)}</section>
<section className="panel"><h2>开始使用</h2><p>在{tabs[1]}中创建本地应用草稿,预览工作流程。</p><button className="primary" onClick={() => setTab(tabs[1])}>进入{tabs[1]}</button></section>
</>}
{tab === tabs[1] && <section className="panel">
<h2>应用草稿</h2><p>仅保存在当前页面内存中,刷新后清空,不会发布或提交到服务器。</p>
<form onSubmit={saveDraft}><label htmlFor="draft-name">应用名称</label><div className="form-row"><input id="draft-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={100} required placeholder="输入名称" /><button className="primary" type="submit">创建本地草稿</button></div></form>
{drafts.length === 0 ? <p className="empty">暂无草稿</p> : <ul className="drafts">{drafts.map((draft, index) => <li key={index}><span>{draft}</span><span className="badge">本地草稿</span></li>)}</ul>}
</section>}
{tab === tabs[2] && <section className="panel empty"><h2>暂无数据</h2><p>连接业务数据接口后,将在这里展示应用请求、广告展示与收益数据。</p></section>}
</main>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
+40
View File
@@ -0,0 +1,40 @@
* { 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; } }
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"types": [
"vite/client"
]
},
"include": [
"src",
"vite.config.ts"
]
}
+3
View File
@@ -0,0 +1,3 @@
import { defineConfig } from "vite";
export default defineConfig({ server: { port: 5174, strictPort: true } });