Add TKMind platform extensions, H5/MindSpace stack, and deployment tooling.
Deploy Documentation / deploy (push) Has been cancelled
Canary / Prepare Version (push) Has been cancelled
Canary / build-cli (push) Has been cancelled
Canary / Upload Install Script (push) Has been cancelled
Canary / bundle-desktop (push) Has been cancelled
Canary / bundle-desktop-intel (push) Has been cancelled
Canary / bundle-desktop-linux (push) Has been cancelled
Canary / bundle-desktop-windows (push) Has been cancelled
Canary / bundle-desktop-windows-cuda (push) Has been cancelled
Canary / Release (push) Has been cancelled
Unused Dependencies / machete (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / Check Rust Code Format (push) Has been cancelled
CI / Build and Test Rust Project (push) Has been cancelled
CI / Build Rust Project on Windows (push) Has been cancelled
CI / Check MSRV (push) Has been cancelled
CI / Lint Rust Code (push) Has been cancelled
CI / Check Generated Schemas are Up-to-Date (push) Has been cancelled
CI / Test and Lint Electron Desktop App (push) Has been cancelled
CI / H5 Plaza Tests and Build (push) Has been cancelled
Live Provider Tests / check-fork (push) Has been cancelled
Live Provider Tests / changes (push) Has been cancelled
Live Provider Tests / Build Binary (push) Has been cancelled
Live Provider Tests / Smoke Tests (push) Has been cancelled
Live Provider Tests / Smoke Tests (Code Execution) (push) Has been cancelled
Live Provider Tests / Compaction Tests (push) Has been cancelled
Live Provider Tests / goose server HTTP integration tests (push) Has been cancelled
Publish Ask AI Bot Docker Image / docker (push) Has been cancelled
Publish Docker Image / docker (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled

Fork goose with custom MCP widgets, platform extensions (aider, git, web, search),
MindSpace H5 backend/frontend, Plaza/Ops UIs, and deploy scripts for tkmind.cn.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-14 21:30:20 +08:00
parent e5fd568e01
commit 4e21ca937a
359 changed files with 70658 additions and 56 deletions
+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>Plaza Ops</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "ops",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --port 3002",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --port 3002"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.8.2",
"vite": "^6.2.0"
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { OpsLayout } from './components/OpsLayout';
import { RequireOps } from './components/RequireOps';
import { AnalyticsPage } from './pages/AnalyticsPage';
import { CreatorsPage } from './pages/CreatorsPage';
import { FeaturedPage } from './pages/FeaturedPage';
import { ReportsPage } from './pages/ReportsPage';
import { ReviewPage } from './pages/ReviewPage';
export function App() {
return (
<RequireOps>
<Routes>
<Route element={<OpsLayout />}>
<Route index element={<ReviewPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="featured" element={<FeaturedPage />} />
<Route path="creators" element={<CreatorsPage />} />
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</RequireOps>
);
}
+132
View File
@@ -0,0 +1,132 @@
type ApiPayload<T> = { data: T; error?: { code: string; message: string } };
async function opsFetch<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
credentials: 'include',
headers: {
Accept: 'application/json',
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...(init?.headers ?? {}),
},
});
const payload = (await response.json().catch(() => ({}))) as ApiPayload<T> & {
message?: string;
};
if (!response.ok) {
throw new Error(payload?.error?.message ?? payload?.message ?? `请求失败 (${response.status})`);
}
return payload.data;
}
export type ReviewPost = {
id: string;
title: string;
summary: string;
cover_url: string;
author: { display_name: string; slug: string };
category: { name: string; slug: string; icon: string };
published_at: string;
sla_warning: boolean;
};
export async function fetchReviewQueue(status = 'pending_review') {
return opsFetch<{ posts: ReviewPost[]; next_cursor: string | null; has_more: boolean }>(
`/api/ops/v1/review/queue?status=${encodeURIComponent(status)}`,
);
}
export async function reviewPost(id: string, action: 'approve' | 'reject' | 'hide', reason?: string) {
return opsFetch<{ post: { id: string; status: string } }>(`/api/ops/v1/review/posts/${id}`, {
method: 'POST',
body: JSON.stringify({ action, reason }),
});
}
export async function batchReviewPosts(postIds: string[], action: 'approve' | 'reject' | 'hide') {
return opsFetch<{ posts: Array<{ id: string; status: string }> }>('/api/ops/v1/review/batch', {
method: 'POST',
body: JSON.stringify({ post_ids: postIds, action }),
});
}
export async function fetchReports() {
return opsFetch<{
reports: Array<{
id: string;
target_type: string;
target_id: string;
reason: string;
detail: string;
target_report_count: number;
created_at: string;
}>;
}>('/api/ops/v1/reports');
}
export async function processReport(id: string, action: 'dismiss' | 'hide_post', actionTaken = '') {
return opsFetch<{ report: { id: string; status: string } }>(`/api/ops/v1/reports/${id}/process`, {
method: 'POST',
body: JSON.stringify({ action, action_taken: actionTaken }),
});
}
export async function fetchFeatured() {
return opsFetch<{
items: Array<{ id: string; post_id: string; position: string; title: string; author: string }>;
}>('/api/ops/v1/featured');
}
export async function setFeatured(body: {
post_id: string;
position: string;
sort_order?: number;
expires_at?: string | null;
}) {
return opsFetch('/api/ops/v1/featured', { method: 'POST', body: JSON.stringify(body) });
}
export async function removeFeatured(id: string) {
return opsFetch(`/api/ops/v1/featured/${id}`, { method: 'DELETE' });
}
export async function fetchAnalytics() {
return opsFetch<{
today: { new_posts: number; plaza_signups: number; pending_review: number };
yesterday: { new_posts: number };
categories: Array<{ name: string; count: number }>;
top_creators: Array<{ slug: string; display_name: string; post_count: number; total_likes: number }>;
daily_posts: Array<{ day: string; count: number }>;
}>('/api/ops/v1/analytics/overview');
}
export async function fetchCreators(keyword = '') {
const query = keyword ? `?keyword=${encodeURIComponent(keyword)}` : '';
return opsFetch<{
creators: Array<{
user_id: string;
slug: string;
display_name: string;
post_count: number;
follower_count: number;
verified: boolean;
post_banned: boolean;
comment_banned: boolean;
}>;
}>(`/api/ops/v1/creators${query}`);
}
export async function updateCreator(
userId: string,
patch: { verified?: boolean; post_banned?: boolean; comment_banned?: boolean },
) {
return opsFetch(`/api/ops/v1/creators/${userId}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
export async function fetchAuthStatus() {
const response = await fetch('/auth/status', { credentials: 'include' });
return response.json() as Promise<{ authenticated: boolean; user?: { displayName: string } }>;
}
+33
View File
@@ -0,0 +1,33 @@
import { NavLink, Outlet } from 'react-router-dom';
const links = [
{ to: '/', label: '审核队列' },
{ to: '/reports', label: '举报处理' },
{ to: '/featured', label: '精选管理' },
{ to: '/creators', label: '创作者' },
{ to: '/analytics', label: '数据看板' },
];
export function OpsLayout() {
return (
<div className="layout">
<header>
<h1>Plaza 运营后台</h1>
<p style={{ color: '#68716c' }}>内容审核、精选与数据概览</p>
</header>
<nav className="nav">
{links.map((link) => (
<NavLink
key={link.to}
to={link.to}
end={link.to === '/'}
className={({ isActive }) => (isActive ? 'active' : undefined)}
>
{link.label}
</NavLink>
))}
</nav>
<Outlet />
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react';
import { fetchAuthStatus } from '../api/client';
export function RequireOps({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<'loading' | 'ok' | 'denied'>('loading');
useEffect(() => {
void fetchAuthStatus()
.then((payload) => setState(payload.authenticated ? 'ok' : 'denied'))
.catch(() => setState('denied'));
}, []);
if (state === 'loading') return <p>检查登录态…</p>;
if (state === 'denied') {
return (
<div className="card">
<h2>需要登录</h2>
<p>请先在 MindSpace 登录,并确保账号已分配 ops_role(reviewer / editor / ops_admin)。</p>
<a className="btn" href="/auth/login">
前往登录
</a>
</div>
);
}
return children;
}
+90
View File
@@ -0,0 +1,90 @@
:root {
color: #17221d;
background: #f5f0e5;
font-family: ui-sans-serif, system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select,
textarea {
font: inherit;
}
.card {
border: 1px solid #d6d0c3;
border-radius: 16px;
background: #fffdf7;
padding: 16px;
}
.btn {
border-radius: 999px;
border: 1px solid #2f6f57;
background: #2f6f57;
color: white;
padding: 8px 16px;
cursor: pointer;
}
.btn.secondary {
background: transparent;
color: #2f6f57;
}
.btn.danger {
border-color: #b42318;
background: #b42318;
}
.grid {
display: grid;
gap: 12px;
}
.warn {
color: #b54708;
font-size: 12px;
}
.alert {
color: #b42318;
font-size: 12px;
}
.layout {
max-width: 1100px;
margin: 0 auto;
padding: 24px 16px 48px;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 24px;
}
.nav a {
padding: 8px 14px;
border-radius: 999px;
background: #ebe4d6;
}
.nav a.active {
background: #2f6f57;
color: white;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter basename="/ops">
<App />
</BrowserRouter>
</StrictMode>,
);
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react';
import { fetchAnalytics } from '../api/client';
export function AnalyticsPage() {
const [data, setData] = useState<Awaited<ReturnType<typeof fetchAnalytics>> | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void fetchAnalytics()
.then(setData)
.catch((err) => setError(err instanceof Error ? err.message : '加载失败'));
}, []);
if (error) return <p className="alert">{error}</p>;
if (!data) return <p>加载中…</p>;
return (
<div className="grid">
<div className="card" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(160px,1fr))', gap: 12 }}>
<div>
<p>今日新帖</p>
<strong style={{ fontSize: 28 }}>{data.today.new_posts}</strong>
<p style={{ color: '#68716c', fontSize: 12 }}>昨日 {data.yesterday.new_posts}</p>
</div>
<div>
<p>广场注册</p>
<strong style={{ fontSize: 28 }}>{data.today.plaza_signups}</strong>
</div>
<div>
<p>待审核</p>
<strong style={{ fontSize: 28 }}>{data.today.pending_review}</strong>
</div>
</div>
<div className="card">
<h3>分类分布</h3>
<ul>
{data.categories.map((item) => (
<li key={item.name}>
{item.name}: {item.count}
</li>
))}
</ul>
</div>
<div className="card">
<h3>TOP 创作者</h3>
<ul>
{data.top_creators.map((creator) => (
<li key={creator.slug}>
{creator.display_name} · {creator.post_count} 篇 · {creator.total_likes} 赞
</li>
))}
</ul>
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { fetchCreators, updateCreator } from '../api/client';
export function CreatorsPage() {
const [creators, setCreators] = useState<Awaited<ReturnType<typeof fetchCreators>>['creators']>([]);
const [keyword, setKeyword] = useState('');
const [error, setError] = useState<string | null>(null);
const load = async () => {
try {
const data = await fetchCreators(keyword);
setCreators(data.creators);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
}
};
useEffect(() => {
void load();
}, []);
return (
<div className="grid">
<div className="card" style={{ display: 'flex', gap: 8 }}>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索创作者" />
<button type="button" className="btn secondary" onClick={() => void load()}>
搜索
</button>
</div>
{error ? <p className="alert">{error}</p> : null}
{creators.map((creator) => (
<div key={creator.user_id} className="card" style={{ display: 'flex', justifyContent: 'space-between' }}>
<div>
<strong>{creator.display_name}</strong> @{creator.slug}
<p style={{ color: '#68716c', fontSize: 12 }}>
{creator.post_count} 篇 · {creator.follower_count} 粉丝
{creator.verified ? ' · 已认证' : ''}
</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn secondary"
onClick={() =>
void updateCreator(creator.user_id, { verified: !creator.verified }).then(load)
}
>
{creator.verified ? '取消认证' : '认证'}
</button>
<button
type="button"
className="btn secondary"
onClick={() =>
void updateCreator(creator.user_id, { post_banned: !creator.post_banned }).then(load)
}
>
{creator.post_banned ? '解除禁发' : '禁发'}
</button>
</div>
</div>
))}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import { fetchFeatured, removeFeatured, setFeatured } from '../api/client';
export function FeaturedPage() {
const [items, setItems] = useState<Awaited<ReturnType<typeof fetchFeatured>>['items']>([]);
const [postId, setPostId] = useState('');
const [position, setPosition] = useState('trending');
const [error, setError] = useState<string | null>(null);
const load = async () => {
try {
const data = await fetchFeatured();
setItems(data.items);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
}
};
useEffect(() => {
void load();
}, []);
return (
<div className="grid">
<div className="card grid">
<h3>添加精选</h3>
<input value={postId} onChange={(e) => setPostId(e.target.value)} placeholder="帖子 ID" />
<select value={position} onChange={(e) => setPosition(e.target.value)}>
<option value="homepage_banner">首页轮播</option>
<option value="trending">热门趋势</option>
<option value="category_top">分类置顶</option>
</select>
<button
type="button"
className="btn"
onClick={() =>
void setFeatured({ post_id: postId.trim(), position })
.then(() => {
setPostId('');
return load();
})
.catch((err) => setError(err instanceof Error ? err.message : '添加失败'))
}
>
保存
</button>
</div>
{error ? <p className="alert">{error}</p> : null}
{items.map((item) => (
<div key={item.id} className="card" style={{ display: 'flex', justifyContent: 'space-between' }}>
<div>
<strong>{item.position}</strong>
<p>{item.title}</p>
<p style={{ color: '#68716c', fontSize: 12 }}>{item.author}</p>
</div>
<button type="button" className="btn secondary" onClick={() => void removeFeatured(item.id).then(load)}>
移除
</button>
</div>
))}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react';
import { fetchReports, processReport } from '../api/client';
export function ReportsPage() {
const [reports, setReports] = useState<Awaited<ReturnType<typeof fetchReports>>['reports']>([]);
const [error, setError] = useState<string | null>(null);
const load = async () => {
try {
const data = await fetchReports();
setReports(data.reports);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
}
};
useEffect(() => {
void load();
}, []);
return (
<div className="grid">
{error ? <p className="alert">{error}</p> : null}
{reports.map((report) => (
<div key={report.id} className="card">
<h3>
{report.target_type} · {report.reason}
{report.target_report_count > 3 ? (
<span className="alert"> · 高频举报 ({report.target_report_count})</span>
) : null}
</h3>
<p>{report.detail || '无补充说明'}</p>
<p style={{ color: '#68716c', fontSize: 12 }}>目标 ID:{report.target_id}</p>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button
type="button"
className="btn danger"
onClick={() =>
void processReport(report.id, 'hide_post', 'hide from report queue').then(load)
}
>
隐藏内容
</button>
<button
type="button"
className="btn secondary"
onClick={() => void processReport(report.id, 'dismiss').then(load)}
>
驳回举报
</button>
</div>
</div>
))}
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
import { useEffect, useMemo, useState } from 'react';
import { fetchReviewQueue, reviewPost, batchReviewPosts, type ReviewPost } from '../api/client';
const TABS = [
{ key: 'pending_review', label: '待审核' },
{ key: 'published', label: '已通过' },
{ key: 'rejected', label: '已拒绝' },
] as const;
type TabKey = (typeof TABS)[number]['key'];
export function ReviewPage() {
const [tab, setTab] = useState<TabKey>('pending_review');
const [posts, setPosts] = useState<ReviewPost[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [batchBusy, setBatchBusy] = useState(false);
const load = async (status: TabKey = tab) => {
setError(null);
try {
const data = await fetchReviewQueue(status);
setPosts(data.posts);
setSelected(new Set());
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
}
};
useEffect(() => {
void load(tab);
}, [tab]);
const allSelected = useMemo(
() => posts.length > 0 && posts.every((post) => selected.has(post.id)),
[posts, selected],
);
const toggleSelect = (id: string) => {
setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (allSelected) {
setSelected(new Set());
return;
}
setSelected(new Set(posts.map((post) => post.id)));
};
const handleReview = async (id: string, action: 'approve' | 'reject') => {
setBusyId(id);
try {
const reason =
action === 'reject'
? window.prompt('拒绝原因(必填)', '低质内容') ?? undefined
: undefined;
if (action === 'reject' && !reason) return;
await reviewPost(id, action, reason);
await load(tab);
} catch (err) {
setError(err instanceof Error ? err.message : '操作失败');
} finally {
setBusyId(null);
}
};
const handleBatchApprove = async () => {
const ids = [...selected];
if (ids.length === 0) return;
if (!window.confirm(`确认批量通过 ${ids.length} 条帖子?`)) return;
setBatchBusy(true);
setError(null);
try {
await batchReviewPosts(ids, 'approve');
await load(tab);
} catch (err) {
setError(err instanceof Error ? err.message : '批量操作失败');
} finally {
setBatchBusy(false);
}
};
return (
<div className="grid">
<div className="card" style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
{TABS.map((item) => (
<button
key={item.key}
type="button"
className={tab === item.key ? 'btn' : 'btn secondary'}
onClick={() => setTab(item.key)}
>
{item.label}
</button>
))}
{tab === 'pending_review' && posts.length > 0 ? (
<>
<button type="button" className="btn secondary" onClick={toggleSelectAll}>
{allSelected ? '取消全选' : '全选'}
</button>
<button
type="button"
className="btn"
disabled={batchBusy || selected.size === 0}
onClick={() => void handleBatchApprove()}
>
{batchBusy ? '处理中…' : `批量通过 (${selected.size})`}
</button>
</>
) : null}
</div>
{error ? <p className="alert">{error}</p> : null}
{posts.length === 0 ? <div className="card">暂无{tab === 'pending_review' ? '待审核' : ''}帖子</div> : null}
{posts.map((post) => (
<div key={post.id} className="card grid">
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'start' }}>
{tab === 'pending_review' ? (
<input
type="checkbox"
checked={selected.has(post.id)}
onChange={() => toggleSelect(post.id)}
aria-label={`选择 ${post.title}`}
/>
) : null}
<div>
<h3>{post.title}</h3>
<p style={{ color: '#68716c' }}>
{post.category.icon} {post.category.name} · @{post.author.slug}
</p>
{post.sla_warning ? <p className="warn">已超过 2 小时未审核</p> : null}
</div>
</div>
{tab === 'pending_review' ? (
<div style={{ display: 'flex', gap: 8, alignItems: 'start' }}>
<button
type="button"
className="btn"
disabled={busyId === post.id}
onClick={() => void handleReview(post.id, 'approve')}
>
通过
</button>
<button
type="button"
className="btn secondary"
disabled={busyId === post.id}
onClick={() => void handleReview(post.id, 'reject')}
>
拒绝
</button>
<a
className="btn secondary"
href={`/plaza/p/${post.id}`}
target="_blank"
rel="noreferrer"
>
预览
</a>
</div>
) : (
<a
className="btn secondary"
href={`/plaza/p/${post.id}`}
target="_blank"
rel="noreferrer"
>
查看
</a>
)}
</div>
{post.summary ? <p>{post.summary}</p> : null}
</div>
))}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const apiProxy = process.env.OPS_API_PROXY ?? 'http://127.0.0.1:8080';
export default defineConfig({
base: '/ops/',
plugins: [react()],
server: {
port: 3002,
proxy: {
'/api': apiProxy,
'/auth': apiProxy,
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
});