feat: add new folder

This commit is contained in:
Daniel
2026-03-30 20:49:40 +08:00
commit c7788fdd92
64 changed files with 19910 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
FROM docker.m.daocloud.io/library/node:22-alpine AS builder
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
RUN npm config set registry https://registry.npmmirror.com
WORKDIR /app
COPY apps/web/package.json /app/package.json
COPY apps/web/tsconfig.json /app/tsconfig.json
COPY apps/web/tsconfig.app.json /app/tsconfig.app.json
COPY apps/web/vite.config.ts /app/vite.config.ts
COPY apps/web/index.html /app/index.html
COPY apps/web/src /app/src
RUN npm install && npm run build
FROM docker.m.daocloud.io/library/nginx:1.27-alpine
COPY infrastructure/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html

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>Gig POC Console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,23 @@
{
"name": "gig-poc-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0 --port 4173"
},
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.30.1"
},
"devDependencies": {
"@types/react": "18.3.20",
"@types/react-dom": "18.3.6",
"@vitejs/plugin-react": "4.3.4",
"typescript": "5.8.3",
"vite": "5.4.18"
}
}

View File

@@ -0,0 +1,41 @@
import { NavLink, Route, Routes } from "react-router-dom";
import { JobPage } from "./pages/JobPage";
import { WorkerPage } from "./pages/WorkerPage";
import { DataBrowserPage } from "./pages/DataBrowserPage";
import { StatusPage } from "./pages/StatusPage";
const navItems = [
{ to: "/", label: "岗位测试" },
{ to: "/workers", label: "工人测试" },
{ to: "/browse", label: "数据浏览" },
{ to: "/status", label: "系统状态" }
];
export default function App() {
return (
<div className="layout-shell">
<aside className="side-nav">
<div>
<p className="eyebrow">Gig POC</p>
<h1></h1>
<p className="side-copy">LightRAG </p>
</div>
<nav className="nav-list">
{navItems.map((item) => (
<NavLink key={item.to} to={item.to} end={item.to === "/"} className="nav-link">
{item.label}
</NavLink>
))}
</nav>
</aside>
<main className="content-area">
<Routes>
<Route path="/" element={<JobPage />} />
<Route path="/workers" element={<WorkerPage />} />
<Route path="/browse" element={<DataBrowserPage />} />
<Route path="/status" element={<StatusPage />} />
</Routes>
</main>
</div>
);
}

View File

@@ -0,0 +1,33 @@
const API_BASE = import.meta.env.VITE_API_BASE ?? "/api";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {})
},
...init
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
export const api = {
health: () => request("/health"),
extractJob: (text: string) => request("/poc/extract/job", { method: "POST", body: JSON.stringify({ text }) }),
extractWorker: (text: string) => request("/poc/extract/worker", { method: "POST", body: JSON.stringify({ text }) }),
ingestJob: (job: unknown) => request("/poc/ingest/job", { method: "POST", body: JSON.stringify({ job }) }),
ingestWorker: (worker: unknown) => request("/poc/ingest/worker", { method: "POST", body: JSON.stringify({ worker }) }),
bootstrap: () => request("/poc/ingest/bootstrap", { method: "POST" }),
matchWorkers: (job: unknown, top_n = 10) =>
request("/poc/match/workers", { method: "POST", body: JSON.stringify({ job, top_n }) }),
matchJobs: (worker: unknown, top_n = 10) =>
request("/poc/match/jobs", { method: "POST", body: JSON.stringify({ worker, top_n }) }),
jobs: () => request("/poc/jobs"),
workers: () => request("/poc/workers"),
job: (jobId: string) => request(`/poc/jobs/${jobId}`),
worker: (workerId: string) => request(`/poc/workers/${workerId}`)
};

View File

@@ -0,0 +1,15 @@
type JsonPanelProps = {
title: string;
data: unknown;
};
export function JsonPanel({ title, data }: JsonPanelProps) {
return (
<section className="panel">
<div className="panel-head">
<h3>{title}</h3>
</div>
<pre className="code-block">{JSON.stringify(data, null, 2)}</pre>
</section>
);
}

View File

@@ -0,0 +1,34 @@
type MatchItem = {
match_id: string;
target_id: string;
match_score: number;
reasons: string[];
breakdown: Record<string, number>;
};
export function MatchList({ title, items }: { title: string; items: MatchItem[] }) {
return (
<section className="panel">
<div className="panel-head">
<h3>{title}</h3>
<span className="badge">{items.length} </span>
</div>
<div className="match-grid">
{items.map((item) => (
<article key={item.match_id} className="match-card">
<div className="match-topline">
<strong>{item.target_id}</strong>
<span>{item.match_score}</span>
</div>
<div className="reason-list">
{item.reasons.map((reason) => (
<p key={reason}>{reason}</p>
))}
</div>
<pre className="mini-code">{JSON.stringify(item.breakdown, null, 2)}</pre>
</article>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles/global.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,28 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import { JsonPanel } from "../components/JsonPanel";
export function DataBrowserPage() {
const [jobs, setJobs] = useState<any[]>([]);
const [workers, setWorkers] = useState<any[]>([]);
useEffect(() => {
void (async () => {
const [jobResult, workerResult] = await Promise.all([api.jobs(), api.workers()]);
setJobs(jobResult.items.slice(0, 12));
setWorkers(workerResult.items.slice(0, 12));
})();
}, []);
return (
<div className="page-grid">
<section className="hero-card">
<p className="eyebrow">Page C</p>
<h2></h2>
<p>便 bootstrap </p>
</section>
<JsonPanel title="Jobs" data={jobs} />
<JsonPanel title="Workers" data={workers} />
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { useState } from "react";
import { api } from "../api/client";
import { JsonPanel } from "../components/JsonPanel";
import { MatchList } from "../components/MatchList";
const DEFAULT_TEXT = "明天下午南山会展中心需要2个签到协助5小时150/人,女生优先,需要会签到、引导和登记。";
export function JobPage() {
const [text, setText] = useState(DEFAULT_TEXT);
const [jobCard, setJobCard] = useState<unknown>(null);
const [matches, setMatches] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const handleExtract = async () => {
setLoading(true);
try {
const result = await api.extractJob(text);
setJobCard(result.data);
} finally {
setLoading(false);
}
};
const handleIngestAndMatch = async () => {
if (!jobCard) {
return;
}
setLoading(true);
try {
await api.ingestJob(jobCard);
const result = await api.matchWorkers(jobCard, 10);
setMatches(result.items);
} finally {
setLoading(false);
}
};
return (
<div className="page-grid">
<section className="hero-card">
<p className="eyebrow">Page A</p>
<h2></h2>
<textarea value={text} onChange={(event) => setText(event.target.value)} rows={8} />
<div className="button-row">
<button onClick={handleExtract} disabled={loading}></button>
<button className="ghost" onClick={handleIngestAndMatch} disabled={loading || !jobCard}></button>
</div>
</section>
{jobCard ? <JsonPanel title="JobCard" data={jobCard} /> : null}
{matches.length ? <MatchList title="匹配工人结果" items={matches} /> : null}
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { useState } from "react";
import { api } from "../api/client";
import { JsonPanel } from "../components/JsonPanel";
export function StatusPage() {
const [health, setHealth] = useState<unknown>(null);
const [bootstrapResult, setBootstrapResult] = useState<unknown>(null);
const [loading, setLoading] = useState(false);
const handleHealth = async () => {
setLoading(true);
try {
setHealth(await api.health());
} finally {
setLoading(false);
}
};
const handleBootstrap = async () => {
setLoading(true);
try {
setBootstrapResult(await api.bootstrap());
} finally {
setLoading(false);
}
};
return (
<div className="page-grid">
<section className="hero-card">
<p className="eyebrow">Page D</p>
<h2></h2>
<p> APIPostgreSQLQdrant bootstrap</p>
<div className="button-row">
<button onClick={handleHealth} disabled={loading}></button>
<button className="ghost" onClick={handleBootstrap} disabled={loading}></button>
</div>
</section>
{health ? <JsonPanel title="Health" data={health} /> : null}
{bootstrapResult ? <JsonPanel title="Bootstrap" data={bootstrapResult} /> : null}
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { useState } from "react";
import { api } from "../api/client";
import { JsonPanel } from "../components/JsonPanel";
import { MatchList } from "../components/MatchList";
const DEFAULT_TEXT = "我做过商场促销和活动签到,也能做登记和引导,周末都能接,福田南山都方便。";
export function WorkerPage() {
const [text, setText] = useState(DEFAULT_TEXT);
const [workerCard, setWorkerCard] = useState<unknown>(null);
const [matches, setMatches] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const handleExtract = async () => {
setLoading(true);
try {
const result = await api.extractWorker(text);
setWorkerCard(result.data);
} finally {
setLoading(false);
}
};
const handleIngestAndMatch = async () => {
if (!workerCard) {
return;
}
setLoading(true);
try {
await api.ingestWorker(workerCard);
const result = await api.matchJobs(workerCard, 10);
setMatches(result.items);
} finally {
setLoading(false);
}
};
return (
<div className="page-grid">
<section className="hero-card">
<p className="eyebrow">Page B</p>
<h2></h2>
<textarea value={text} onChange={(event) => setText(event.target.value)} rows={8} />
<div className="button-row">
<button onClick={handleExtract} disabled={loading}></button>
<button className="ghost" onClick={handleIngestAndMatch} disabled={loading || !workerCard}></button>
</div>
</section>
{workerCard ? <JsonPanel title="WorkerCard" data={workerCard} /> : null}
{matches.length ? <MatchList title="匹配岗位结果" items={matches} /> : null}
</div>
);
}

View File

@@ -0,0 +1,176 @@
:root {
font-family: "PingFang SC", "Noto Sans SC", sans-serif;
color: #0f172a;
background:
radial-gradient(circle at top left, rgba(34, 197, 94, 0.18), transparent 25%),
radial-gradient(circle at 80% 20%, rgba(14, 165, 233, 0.16), transparent 22%),
linear-gradient(135deg, #f8fafc 0%, #eefbf3 45%, #f3f7fb 100%);
line-height: 1.5;
font-weight: 400;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
button,
textarea {
font: inherit;
}
.layout-shell {
display: grid;
grid-template-columns: 320px 1fr;
min-height: 100vh;
}
.side-nav {
padding: 32px 24px;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.96) 0%, rgba(21, 44, 79, 0.92) 100%);
color: #e2e8f0;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.eyebrow {
margin: 0 0 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
font-size: 12px;
color: #38bdf8;
}
.side-copy {
color: #bfd6ea;
max-width: 220px;
}
.nav-list {
display: grid;
gap: 12px;
}
.nav-link {
color: #dbeafe;
text-decoration: none;
padding: 12px 14px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
transition: transform 160ms ease, background 160ms ease;
}
.nav-link.active,
.nav-link:hover {
background: rgba(56, 189, 248, 0.18);
transform: translateX(3px);
}
.content-area {
padding: 28px;
}
.page-grid {
display: grid;
gap: 20px;
}
.hero-card,
.panel,
.match-card {
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(10px);
border-radius: 24px;
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
}
.hero-card,
.panel {
padding: 22px;
}
.panel-head,
.match-topline,
.button-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
textarea {
width: 100%;
margin: 12px 0 0;
border: 1px solid #cbd5e1;
border-radius: 18px;
padding: 16px;
background: #f8fafc;
resize: vertical;
}
button {
border: 0;
border-radius: 999px;
padding: 12px 18px;
background: linear-gradient(135deg, #0f766e 0%, #0284c7 100%);
color: white;
cursor: pointer;
}
button.ghost {
background: #e2e8f0;
color: #0f172a;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.badge {
background: #dcfce7;
color: #166534;
border-radius: 999px;
padding: 4px 12px;
font-size: 12px;
}
.code-block,
.mini-code {
margin: 0;
overflow: auto;
background: #0f172a;
color: #dbeafe;
border-radius: 18px;
padding: 16px;
}
.match-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.match-card {
padding: 18px;
}
.reason-list p {
margin: 10px 0;
}
@media (max-width: 920px) {
.layout-shell {
grid-template-columns: 1fr;
}
.side-copy {
max-width: none;
}
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

View File

@@ -0,0 +1,6 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" }
]
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: "0.0.0.0"
}
});