V0.1全模块完成: 仓库写入(POST)+回执展开+搜索+草稿管理

This commit is contained in:
淑婷陈 2026-07-28 01:19:53 +08:00
parent 59d1161244
commit 17c9230410
6 changed files with 165 additions and 26 deletions

View File

@ -7,8 +7,8 @@ import { ReceiptPage } from "./pages/ReceiptPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
const NAV: { id: Page; label: string; icon: string }[] = [ const NAV: { id: Page; label: string; icon: string }[] = [
{ id: "home", label: "任务首页", icon: "📋" }, { id: "home", label: "频道首页", icon: "🌊" },
{ id: "plan", label: "工单面板", icon: "📐" }, { id: "plan", label: "工单面板", icon: "📋" },
{ id: "authorize", label: "授权管理", icon: "🔑" }, { id: "authorize", label: "授权管理", icon: "🔑" },
{ id: "receipt", label: "回执记录", icon: "📝" }, { id: "receipt", label: "回执记录", icon: "📝" },
{ id: "settings", label: "系统设置", icon: "⚙️" }, { id: "settings", label: "系统设置", icon: "⚙️" },
@ -31,7 +31,7 @@ export function App() {
{page === "home" && <HomePage onNavigate={setPage} />} {page === "home" && <HomePage onNavigate={setPage} />}
{page === "plan" && <WorkOrderPage />} {page === "plan" && <WorkOrderPage />}
{page === "authorize" && <AuthorizePage />} {page === "authorize" && <AuthorizePage />}
{page === "receipt" && <ReceiptPage />} {page === "receipt" && <ReceiptPage onNavigate={setPage} />}
{page === "settings" && <SettingsPage />} {page === "settings" && <SettingsPage />}
</main> </main>
</div> </div>

View File

@ -158,4 +158,53 @@ export function createDraftWorkOrder(summary: string, scope: string[]): WorkOrde
operator: "妈妈 · ICE-GL∞", risk: "low", scope, operator: "妈妈 · ICE-GL∞", risk: "low", scope,
source: "draft", source: "draft",
}; };
}
// 写入 HLDP 回执到 yeye/receipts/ — 通过 Vite 代理走 Gitea API PUT
const WRITE_BASE = "/gitea-api/repos/bingshuo/yeye/contents";
export async function writeReceiptToRepo(receipt: {
receipt_id: string; operation: string; operator: string;
timestamp: string; result: string; status: string; scope?: string[];
}): Promise<boolean> {
const date = receipt.timestamp.split("T")[0];
const filename = `receipts/${date}-${receipt.receipt_id.toLowerCase().replace(/\s+/g, "-")}.yml`;
const scopeList = receipt.scope?.map(s => ` - "${s}"`).join("\n") || " - \"全部\"";
const yml = `receipt_id: "${receipt.receipt_id}"
operation: "${receipt.operation}"
operator: "${receipt.operator}"
timestamp: "${receipt.timestamp}"
result: "${receipt.result}"
status: "${receipt.status}"
scope:
${scopeList}
`;
try {
const body = {
content: btoa(unescape(encodeURIComponent(yml))),
message: `HoloLake V0.1: ${receipt.operation}`,
branch: "main",
};
let res = await fetch(`${WRITE_BASE}/${filename}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.status === 409) {
const existing = await fetchJson(`${WRITE_BASE}/${filename}?ref=main`);
res = await fetch(`${WRITE_BASE}/${filename}`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...body, sha: existing.sha }),
});
}
if (!res.ok) {
const errText = await res.text();
console.error("写入失败:", res.status, errText);
return false;
}
return true;
} catch (e) {
console.error("写入异常:", e);
return false;
}
} }

View File

@ -1,3 +1,3 @@
export interface WorkOrder { id: string; summary: string; target: string; scope: string[]; risk: "low"|"medium"|"high"|"critical"; status: "draft"|"planned"|"authorized"|"executing"|"completed"|"closed"; acceptanceCriteria: string[]; createdBy: string; createdAt: string; updatedAt: string; } export interface WorkOrder { id: string; summary: string; target: string; scope: string[]; risk: "low"|"medium"|"high"|"critical"; status: "draft"|"planned"|"authorized"|"executing"|"completed"|"closed"; acceptanceCriteria: string[]; createdBy: string; createdAt: string; updatedAt: string; }
export interface Receipt { id: string; workOrderId: string; actions: string[]; executor: string; evidence: string[]; result: "success"|"partial"|"failed"; limitations: string[]; completedAt: string; } export interface Receipt { id: string; workOrderId: string; actions: string[]; executor: string; evidence: string[]; result: "success"|"partial"|"failed"; limitations: string[]; completedAt: string; }
export type Page = "home"|"plan"|"authorize"|"receipt"|"settings"; export type Page = "home"|"plan"|"authorize"|"receipt"|"module"|"settings";

View File

@ -51,7 +51,7 @@ export function HomePage({ onNavigate }: Props) {
<div style={{fontWeight:600,fontSize:"0.95rem"}}>工单面板</div> <div style={{fontWeight:600,fontSize:"0.95rem"}}>工单面板</div>
<div style={{fontSize:"0.8rem",color:"var(--t2)"}}>创建 · 追踪 · 闭环</div> <div style={{fontSize:"0.8rem",color:"var(--t2)"}}>创建 · 追踪 · 闭环</div>
</div> </div>
<div className="card" style={{cursor:"pointer"}} onClick={() => onNavigate("settings")}> <div className="card" style={{cursor:"pointer"}} onClick={() => onNavigate("module")}>
<div style={{fontSize:"1.2rem",marginBottom:"0.25rem"}}>📦</div> <div style={{fontSize:"1.2rem",marginBottom:"0.25rem"}}>📦</div>
<div style={{fontWeight:600,fontSize:"0.95rem"}}>模块商店</div> <div style={{fontWeight:600,fontSize:"0.95rem"}}>模块商店</div>
<div style={{fontSize:"0.8rem",color:"var(--t2)"}}>浏览 · 安装 · 管理</div> <div style={{fontSize:"0.8rem",color:"var(--t2)"}}>浏览 · 安装 · 管理</div>

View File

@ -1,29 +1,87 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { fetchReceipts, ApiReceipt } from "../lib/api"; import { fetchReceipts, ApiReceipt } from "../lib/api";
import { Page } from "../lib/types";
export function ReceiptPage() { interface Props { onNavigate?: (page: Page) => void; }
export function ReceiptPage({ onNavigate }: Props) {
const [receipts, setReceipts] = useState<ApiReceipt[]>([]); const [receipts, setReceipts] = useState<ApiReceipt[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<string | null>(null);
const [search, setSearch] = useState("");
useEffect(() => { async function load() {
fetchReceipts().then(data => { setReceipts(data); setLoading(false); }).catch(() => setLoading(false)); setLoading(true);
}, []); try { const data = await fetchReceipts(); setReceipts(data); } catch {}
setLoading(false);
}
useEffect(() => { load(); }, []);
const filtered = search
? receipts.filter(r => r.id.toLowerCase().includes(search.toLowerCase()) || r.operation.toLowerCase().includes(search.toLowerCase()))
: receipts;
const counts = { all: receipts.length, success: receipts.filter(r => r.result === "success").length, failed: receipts.filter(r => r.result !== "success").length };
return <div>
<h1 className="page-title">📝 回执记录</h1>
<div className="stats" style={{gridTemplateColumns:"repeat(3,1fr)"}}>
<div className="stat-card"><div className="stat-value">{counts.all}</div><div className="stat-label">全部</div></div>
<div className="stat-card"><div className="stat-value">{counts.success}</div><div className="stat-label">已成功</div></div>
<div className="stat-card"><div className="stat-value">{counts.failed}</div><div className="stat-label">待处理</div></div>
</div>
<div style={{marginBottom:"1rem"}}>
<input className="form-input" placeholder="🔍 搜索回执编号或描述..." value={search} onChange={e => setSearch(e.target.value)} />
</div>
return <div><h1 className="page-title">📝 回执记录</h1>
{loading && <p style={{color:"#999"}}>加载中...</p>} {loading && <p style={{color:"#999"}}>加载中...</p>}
{!loading && receipts.length === 0 && <p style={{color:"#999"}}>暂无回执记录</p>} {!loading && filtered.length === 0 && <p style={{color:"#999"}}>{search ? "无匹配回执" : "暂无回执记录"}</p>}
{receipts.map(rc => <div className="card" key={rc.id}>
<div className="card-header"> {filtered.map(rc => {
<div><div className="card-title">{rc.id}</div><div className="card-subtitle">{rc.operation}</div></div> const isExpanded = expanded === rc.id;
<span className={`badge ${rc.result==="success"?"badge-success":"badge-warning"}`}>{rc.result==="success"?"✅":"⚠️"}</span> return <div className="card" key={rc.id} style={{cursor:"pointer"}} onClick={() => setExpanded(isExpanded ? null : rc.id)}>
</div> <div className="card-header">
<table><tbody> <div>
<tr><th style={{width:"100px"}}>执行者</th><td>{rc.operator}</td></tr> <div className="card-title">{rc.id}</div>
<tr><th>时间</th><td>{rc.timestamp}</td></tr> <div className="card-subtitle">{rc.operation}</div>
</tbody></table> </div>
</div>)} <span className={`badge ${rc.result==="success"?"badge-success":"badge-warning"}`}>
<div style={{marginTop:"1rem",fontSize:"0.8rem",color:"#999"}}> {rc.result==="success"?"✅ 已成功":"⚠️ 待验证"}
数据源:guanghutansuohe.com/code/bingshuo/yeye/receipts/ </span>
</div>
{isExpanded && <div style={{marginTop:"0.75rem",paddingTop:"0.75rem",borderTop:"1px solid var(--bd)"}}>
<table><tbody>
<tr><th style={{width:"100px"}}>执行者</th><td>{rc.operator}</td></tr>
<tr><th>完成时间</th><td>{rc.timestamp}</td></tr>
<tr><th>状态</th><td>{rc.result === "success" ? "成功闭环 ✅" : "需跟进 ⚠️"}</td></tr>
<tr><th>关联工单</th><td>
{onNavigate && <button className="btn" style={{fontSize:"0.75rem",padding:"0.2rem 0.5rem"}} onClick={e => { e.stopPropagation(); onNavigate("plan"); }}>
查看关联工单 →
</button>}
<span style={{fontSize:"0.75rem",color:"var(--t2)",marginLeft:"0.5rem"}}>{rc.id.replace("RC-","WO-")}</span>
</td></tr>
<tr><th>证据链</th><td>
<span className="badge badge-info" style={{fontSize:"0.7rem"}}>yeye 仓库 receipts/ 目录</span>
<span style={{fontSize:"0.75rem",color:"var(--t2)",marginLeft:"0.25rem"}}>Git 历史可追溯</span>
</td></tr>
</tbody></table>
<div style={{marginTop:"0.75rem",padding:"0.5rem",background:"var(--bg2)",borderRadius:"8px",fontSize:"0.8rem",color:"var(--t2)"}}>
<strong>HLDP 回执链</strong> · 工单 → 执行 → 结果 → 证据 · 不可静默覆盖
</div>
</div>}
<div style={{fontSize:"0.7rem",color:"var(--t2)",marginTop:"0.4rem"}}>
{isExpanded ? "▲ 收起" : "▼ 展开详情"} · {rc.timestamp?.split("T")[0] || "—"}
</div>
</div>;
})}
<div style={{marginTop:"1rem",fontSize:"0.75rem",color:"#999"}}>
数据源:guanghutansuohe.com/code/bingshuo/yeye/receipts/ · 最后同步 {new Date().toISOString().split("T")[0]}
</div> </div>
</div>; </div>;
} }

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { WorkOrder } from "../lib/types"; import { WorkOrder } from "../lib/types";
import { fetchWorkOrders, loadDrafts, saveDrafts, createDraftWorkOrder } from "../lib/api"; import { fetchWorkOrders, loadDrafts, saveDrafts, createDraftWorkOrder, writeReceiptToRepo } from "../lib/api";
export function WorkOrderPage() { export function WorkOrderPage() {
const [orders, setOrders] = useState<WorkOrder[]>([]); const [orders, setOrders] = useState<WorkOrder[]>([]);
@ -30,8 +30,34 @@ export function WorkOrderPage() {
await load(); await load();
} }
function handleAuthorize(id: string, decision: "approved" | "rejected") { async function handleAuthorize(id: string, decision: "approved" | "rejected") {
setAuthorizations(prev => ({ ...prev, [id]: decision })); setAuthorizations(prev => ({ ...prev, [id]: decision }));
const order = orders.find(o => o.id === id);
if (decision === "approved" && order) {
const success = await writeReceiptToRepo({
receipt_id: order.id, operation: order.summary,
operator: "小线 · PER-YE-LN-001", timestamp: new Date().toISOString(),
result: "success", status: "completed", scope: order.scope,
});
if (success) {
const drafts = loadDrafts().filter(d => d.id !== id);
saveDrafts(drafts);
await load();
}
}
}
function handleDeleteDraft(id: string) {
if (!confirm("确认删除这条本地草稿?")) return;
const drafts = loadDrafts().filter(d => d.id !== id);
saveDrafts(drafts);
load();
}
function handleClearAllDrafts() {
if (!confirm("确认清空所有本地草稿?")) return;
saveDrafts([]);
load();
} }
const filtered = filter === "all" ? orders : orders.filter(o => o.status === filter); const filtered = filter === "all" ? orders : orders.filter(o => o.status === filter);
@ -73,6 +99,7 @@ export function WorkOrderPage() {
{f==="all"?"全部":f==="draft"?"草稿":f==="executing"?"执行中":"已完成"} ({counts[f]}) {f==="all"?"全部":f==="draft"?"草稿":f==="executing"?"执行中":"已完成"} ({counts[f]})
</button> </button>
))} ))}
{counts.draft > 0 && <button className="btn" style={{marginLeft:"auto",fontSize:"0.75rem"}} onClick={handleClearAllDrafts}>🗑️ 清空草稿</button>}
</div> </div>
{loading && <p style={{color:"#999"}}>加载中...</p>} {loading && <p style={{color:"#999"}}>加载中...</p>}
@ -125,6 +152,11 @@ export function WorkOrderPage() {
{needsAuth && authState !== "pending" && <div style={{marginTop:"0.75rem",padding:"0.5rem",background: authState==="approved"?"#dcfce7":"#f3f4f6",borderRadius:"8px",fontSize:"0.85rem"}}> {needsAuth && authState !== "pending" && <div style={{marginTop:"0.75rem",padding:"0.5rem",background: authState==="approved"?"#dcfce7":"#f3f4f6",borderRadius:"8px",fontSize:"0.85rem"}}>
{authState==="approved" ? "✅ 已批准 · 单次执行 · 有效期 24 小时" : "⛔ 已拒绝"} {authState==="approved" ? "✅ 已批准 · 单次执行 · 有效期 24 小时" : "⛔ 已拒绝"}
</div>} </div>}
{o.source === "draft" && <div style={{marginTop:"0.75rem"}}
onClick={e => e.stopPropagation()}>
<button className="btn btn-danger" style={{fontSize:"0.75rem"}} onClick={() => handleDeleteDraft(o.id)}>🗑️ 删除草稿</button>
</div>}
</div>} </div>}
<div style={{fontSize:"0.7rem",color:"var(--t2)",marginTop:"0.5rem"}}> <div style={{fontSize:"0.7rem",color:"var(--t2)",marginTop:"0.5rem"}}>