fix(merge): PR 集成 - fix merge 上传状态与易支付校验
在 #544、#545、#546 的原作者 merge commit 之后独立修复:保留上传 Promise、处理重复上传与复制失败、格式对齐,以及易支付网关、订单证据、签名、通知幂等与安全关单校验。同步重建六平台插件产物和包。 验证:前端 1832 tests、build/typecheck、lint、相关格式检查通过;财务 SQLite、支付、插件 RPC 与产物专项通过。后端全量出现一项未改动的素材删除 SQLite 表锁失败,单独复跑 3 次通过,保留全量未通过记录。浏览器与真实商户验收待完成。#543 因迁移历史与能力限制回退暂缓。
This commit is contained in:
@@ -10,14 +10,7 @@ import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer,
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
import { ListToolbar, PaginationBar, AdminDataTable, AdminExportButton, AdminFilterChip, AdminStatusBadge, AdminTableEmpty, type AdminStatusTone } from "./admin-ui";
|
||||
import {
|
||||
exportAdminAnalytics,
|
||||
getAdminAnalytics,
|
||||
listAdminUsers,
|
||||
type AdminReferenceData,
|
||||
type AdminAnalytics,
|
||||
type AnalyticsFilters,
|
||||
} from "@/services/api/auth";
|
||||
import { exportAdminAnalytics, getAdminAnalytics, listAdminUsers, type AdminReferenceData, type AdminAnalytics, type AnalyticsFilters } from "@/services/api/auth";
|
||||
import { analyticsFinanceColumns, formatCredits, formatFinanceCost, formatFinanceMargin } from "./analytics-finance";
|
||||
|
||||
type Props = {
|
||||
@@ -300,11 +293,26 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
<AnalyticsHealthCard
|
||||
icon={<CircleDollarSign className="size-4" />}
|
||||
label="费用统计(积分)"
|
||||
value={finance ? <dl className="admin-analytics-finance-values">
|
||||
<div><dt>收入</dt><dd>{formatCredits(finance.revenueMicrocredits)}</dd></div>
|
||||
<div><dt>成本</dt><dd>{formatFinanceCost(finance)}</dd></div>
|
||||
<div><dt>利润</dt><dd>{formatCredits(finance.profitMicrocredits)}</dd></div>
|
||||
</dl> : "--"}
|
||||
value={
|
||||
finance ? (
|
||||
<dl className="admin-analytics-finance-values">
|
||||
<div>
|
||||
<dt>收入</dt>
|
||||
<dd>{formatCredits(finance.revenueMicrocredits)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>成本</dt>
|
||||
<dd>{formatFinanceCost(finance)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>利润</dt>
|
||||
<dd>{formatCredits(finance.profitMicrocredits)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
"--"
|
||||
)
|
||||
}
|
||||
detail={finance ? `已结算 ${finance.settledOrders} 笔 · 利润率 ${formatFinanceMargin(finance.profitMargin)}` : undefined}
|
||||
tone={finance && (finance.costedOrders < finance.settledOrders || (finance.profitMicrocredits ?? 0) < 0) ? "warning" : "neutral"}
|
||||
/>
|
||||
@@ -390,7 +398,15 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
icon={<CircleDollarSign className="size-4" />}
|
||||
label="成本覆盖订单"
|
||||
value={finance ? `${finance.costedOrders}/${finance.settledOrders}` : "--"}
|
||||
description={!finance ? "财务统计尚未返回" : finance.settledOrders ? (finance.costedOrders === finance.settledOrders ? "已结算订单成本完整" : `${finance.settledOrders - finance.costedOrders} 笔缺少成本快照或有效用量`) : "当前范围暂无已结算订单"}
|
||||
description={
|
||||
!finance
|
||||
? "财务统计尚未返回"
|
||||
: finance.settledOrders
|
||||
? finance.costedOrders === finance.settledOrders
|
||||
? "已结算订单成本完整"
|
||||
: `${finance.settledOrders - finance.costedOrders} 笔缺少成本快照或有效用量`
|
||||
: "当前范围暂无已结算订单"
|
||||
}
|
||||
tone={finance && finance.costedOrders < finance.settledOrders ? "warning" : "neutral"}
|
||||
onClick={() => openAnalysis("models")}
|
||||
/>
|
||||
@@ -449,7 +465,6 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ export default function AdminPluginsPage() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<UploadPluginModal open={uploadOpen} onClose={() => setUploadOpen(false)} onUpload={(file) => void upload(file)} />
|
||||
<UploadPluginModal open={uploadOpen} onClose={() => setUploadOpen(false)} onUpload={upload} />
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Modal, Upload } from "antd";
|
||||
import { App, Modal, Upload } from "antd";
|
||||
import { CloudUpload, FileText, ShieldCheck } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { RegisteredPlugin } from "@/lib/plugins/plugin-types";
|
||||
|
||||
@@ -15,26 +15,27 @@ type UploadPluginModalProps = {
|
||||
onUpload: (file: File) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const MINIMAL_MANIFEST_SNIPPET = `{
|
||||
"apiVersion": "yingce.plugin/v1",
|
||||
"id": "my-custom-plugin",
|
||||
"name": "我的自定义插件",
|
||||
"version": "1.0.0",
|
||||
"author": "Developer",
|
||||
"description": "通过统一清单扩展平台功能",
|
||||
"permissions": ["generation.run", "media.read"],
|
||||
"contributes": {
|
||||
"providers": [],
|
||||
"workflows": []
|
||||
}
|
||||
}`;
|
||||
const MANIFEST_EXAMPLE = pluginDevelopmentGuideMarkdown.match(/```json\s*([\s\S]*?)```/)?.[1].trim();
|
||||
|
||||
export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModalProps) {
|
||||
const { message } = App.useApp();
|
||||
const [activeTab, setActiveTab] = useState<"install" | "guide">("install");
|
||||
const [isDraggingPlugin, setIsDraggingPlugin] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const dragDepth = useRef(0);
|
||||
const uploadInFlight = useRef(false);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setActiveTab("install");
|
||||
setIsDraggingPlugin(false);
|
||||
setCopied(false);
|
||||
dragDepth.current = 0;
|
||||
}
|
||||
return () => clearTimeout(copyTimer.current);
|
||||
}, [open]);
|
||||
|
||||
const handlePluginDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -58,19 +59,30 @@ export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModal
|
||||
};
|
||||
|
||||
const handleExecuteUpload = async (file: File) => {
|
||||
if (uploadInFlight.current) return;
|
||||
uploadInFlight.current = true;
|
||||
setUploading(true);
|
||||
try {
|
||||
await onUpload(file);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "安装插件失败");
|
||||
} finally {
|
||||
uploadInFlight.current = false;
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copySnippet = () => {
|
||||
void navigator.clipboard?.writeText(MINIMAL_MANIFEST_SNIPPET).then(() => {
|
||||
const copySnippet = async () => {
|
||||
try {
|
||||
if (!MANIFEST_EXAMPLE) throw new Error("开发规范中缺少清单示例");
|
||||
if (!navigator.clipboard) throw new Error("当前环境不支持剪贴板,请从开发规范中手动复制");
|
||||
await navigator.clipboard.writeText(MANIFEST_EXAMPLE);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
clearTimeout(copyTimer.current);
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "复制失败,请手动复制");
|
||||
}
|
||||
};
|
||||
|
||||
const isCompact = activeTab === "install";
|
||||
@@ -83,37 +95,25 @@ export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModal
|
||||
centered
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
closable={!uploading}
|
||||
mask={{ closable: !uploading }}
|
||||
onCancel={uploading ? undefined : onClose}
|
||||
styles={{ body: { maxHeight: "min(84vh, 900px)", overflowY: "auto", overscrollBehavior: "contain", padding: "16px 20px 24px" } }}
|
||||
>
|
||||
<div className="plugin-upload-tabs-bar">
|
||||
<div className="plugin-upload-tabs-nav">
|
||||
<button
|
||||
type="button"
|
||||
className={`plugin-upload-tab-btn ${activeTab === "install" ? "is-active" : ""}`}
|
||||
onClick={() => setActiveTab("install")}
|
||||
>
|
||||
<button type="button" className={`plugin-upload-tab-btn ${activeTab === "install" ? "is-active" : ""}`} aria-pressed={activeTab === "install"} onClick={() => setActiveTab("install")}>
|
||||
<CloudUpload className="size-4" />
|
||||
<span>安装插件包</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`plugin-upload-tab-btn ${activeTab === "guide" ? "is-active" : ""}`}
|
||||
onClick={() => setActiveTab("guide")}
|
||||
>
|
||||
<button type="button" className={`plugin-upload-tab-btn ${activeTab === "guide" ? "is-active" : ""}`} aria-pressed={activeTab === "guide"} onClick={() => setActiveTab("guide")}>
|
||||
<FileText className="size-4" />
|
||||
<span>开发规范与示例</span>
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === "guide" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="plugin-upload-copy-btn"
|
||||
onClick={copySnippet}
|
||||
title="复制最小 manifest.json 配置模板"
|
||||
>
|
||||
<span>{copied ? "已复制清单" : "复制最小清单"}</span>
|
||||
<button type="button" className="plugin-upload-copy-btn" onClick={() => void copySnippet()} title="复制开发规范中的 manifest.json 配置示例">
|
||||
<span>{copied ? "已复制清单" : "复制清单示例"}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="plugin-upload-badge">
|
||||
@@ -126,7 +126,9 @@ export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModal
|
||||
{activeTab === "install" ? (
|
||||
<div className="plugin-upload-install-view">
|
||||
<div className="plugin-upload-panel-heading">
|
||||
<span className="plugin-upload-panel-icon"><CloudUpload className="size-5" /></span>
|
||||
<span className="plugin-upload-panel-icon">
|
||||
<CloudUpload className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h2>安装插件包</h2>
|
||||
<p>选择统一站点插件包,安装后会立即进入插件中心。</p>
|
||||
@@ -204,13 +206,15 @@ export function PluginDetailsModal({ plugin, restoreFocus, onClose }: PluginDeta
|
||||
return (
|
||||
<Modal
|
||||
className="workspace-modal workspace-modal-wide plugin-details-modal"
|
||||
title={plugin ? (
|
||||
<div className="plugin-details-title">
|
||||
<FileText className="size-4" />
|
||||
<span>{plugin.manifest.name}</span>
|
||||
<span className="plugin-version">v{plugin.manifest.version}</span>
|
||||
</div>
|
||||
) : null}
|
||||
title={
|
||||
plugin ? (
|
||||
<div className="plugin-details-title">
|
||||
<FileText className="size-4" />
|
||||
<span>{plugin.manifest.name}</span>
|
||||
<span className="plugin-version">v{plugin.manifest.version}</span>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
open={Boolean(plugin)}
|
||||
centered
|
||||
footer={null}
|
||||
|
||||
@@ -4,12 +4,24 @@ import { analyticsFinanceColumns } from "../src/pages/admin/components/analytics
|
||||
|
||||
function renderFinanceCells(finance?: AnalyticsFinance | null) {
|
||||
const row: AdminAnalytics["models"][number] = {
|
||||
model: "example", capability: "text", tasks: 1, requests: 1, uniqueUsers: 1,
|
||||
taskSuccessRate: 100, requestSuccessRate: 100, p50DurationMs: 100, p95DurationMs: 100,
|
||||
inputTokens: 10, outputTokens: 10, cachedTokens: 0, usageAvailable: true,
|
||||
mediaCount: 0, videoSeconds: 0, ...(finance === undefined ? {} : { finance }),
|
||||
model: "example",
|
||||
capability: "text",
|
||||
tasks: 1,
|
||||
requests: 1,
|
||||
uniqueUsers: 1,
|
||||
taskSuccessRate: 100,
|
||||
requestSuccessRate: 100,
|
||||
p50DurationMs: 100,
|
||||
p95DurationMs: 100,
|
||||
inputTokens: 10,
|
||||
outputTokens: 10,
|
||||
cachedTokens: 0,
|
||||
usageAvailable: true,
|
||||
mediaCount: 0,
|
||||
videoSeconds: 0,
|
||||
...(finance === undefined ? {} : { finance }),
|
||||
};
|
||||
return analyticsFinanceColumns.map((column) => "render" in column ? column.render?.(undefined, row, 0) : undefined);
|
||||
return analyticsFinanceColumns.map((column) => ("render" in column ? column.render?.(undefined, row, 0) : undefined));
|
||||
}
|
||||
|
||||
test("model finance cells tolerate missing and null backend finance", () => {
|
||||
@@ -18,16 +30,13 @@ test("model finance cells tolerate missing and null backend finance", () => {
|
||||
});
|
||||
|
||||
test("model finance cells preserve real zero values and unknown margins", () => {
|
||||
expect(renderFinanceCells({ settledOrders: 1, costedOrders: 1, revenueMicrocredits: 0, costMicrocredits: 0, profitMicrocredits: 0, profitMargin: null }))
|
||||
.toEqual(["0", "0", "0", "--"]);
|
||||
expect(renderFinanceCells({ settledOrders: 1, costedOrders: 1, revenueMicrocredits: 0, costMicrocredits: 0, profitMicrocredits: 0, profitMargin: null })).toEqual(["0", "0", "0", "--"]);
|
||||
});
|
||||
|
||||
test("model finance cells show partial costs without inventing profits", () => {
|
||||
expect(renderFinanceCells({ settledOrders: 2, costedOrders: 1, revenueMicrocredits: 1500000, costMicrocredits: 250000, profitMicrocredits: null, profitMargin: null }))
|
||||
.toEqual(["1.5", "0.25(部分)", "--", "--"]);
|
||||
expect(renderFinanceCells({ settledOrders: 2, costedOrders: 1, revenueMicrocredits: 1500000, costMicrocredits: 250000, profitMicrocredits: null, profitMargin: null })).toEqual(["1.5", "0.25(部分)", "--", "--"]);
|
||||
});
|
||||
|
||||
test("model finance cells display known losses", () => {
|
||||
expect(renderFinanceCells({ settledOrders: 1, costedOrders: 1, revenueMicrocredits: 1000000, costMicrocredits: 1500000, profitMicrocredits: -500000, profitMargin: -50 }))
|
||||
.toEqual(["1", "1.5", "-0.5", "-50.0%"]);
|
||||
expect(renderFinanceCells({ settledOrders: 1, costedOrders: 1, revenueMicrocredits: 1000000, costMicrocredits: 1500000, profitMicrocredits: -500000, profitMargin: -50 })).toEqual(["1", "1.5", "-0.5", "-50.0%"]);
|
||||
});
|
||||
|
||||
43
web/test/plugin-upload-merge.test.ts
Normal file
43
web/test/plugin-upload-merge.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { registerPlugin, unregisterPlugin } from "../src/lib/plugins/plugin-registry";
|
||||
import type { PluginManifest } from "../src/lib/plugins/plugin-types";
|
||||
|
||||
test("plugin upload caller preserves the installation promise", async () => {
|
||||
const page = await Bun.file(new URL("../src/pages/admin/plugins/plugins-page.tsx", import.meta.url)).text();
|
||||
expect(page).toContain("onUpload={upload}");
|
||||
expect(page).not.toContain("onUpload={(file) => void upload(file)}");
|
||||
});
|
||||
|
||||
test("plugin upload guards concurrent events and exposes installation failures", async () => {
|
||||
const modal = await Bun.file(new URL("../src/pages/plugins/plugin-documentation-modals.tsx", import.meta.url)).text();
|
||||
expect(modal).toContain("if (uploadInFlight.current) return;");
|
||||
expect(modal.indexOf("uploadInFlight.current = true")).toBeLessThan(modal.indexOf("await onUpload(file)"));
|
||||
expect(modal).toContain("uploadInFlight.current = false");
|
||||
expect(modal).toContain("closable={!uploading}");
|
||||
expect(modal).toContain('message.error(error instanceof Error ? error.message : "安装插件失败")');
|
||||
expect(modal).toContain('aria-pressed={activeTab === "install"}');
|
||||
expect(modal).toContain('aria-pressed={activeTab === "guide"}');
|
||||
expect(modal).toContain("if (!navigator.clipboard) throw new Error");
|
||||
expect(modal).toContain("clearTimeout(copyTimer.current)");
|
||||
});
|
||||
|
||||
test("copied guide manifest is accepted by the real plugin registry", async () => {
|
||||
const guide = await Bun.file(new URL("../src/pages/plugins/plugin-development-guide.md", import.meta.url)).text();
|
||||
const example = guide.match(/```json\s*([\s\S]*?)```/)?.[1].trim();
|
||||
expect(example).toBeTruthy();
|
||||
const manifest = { ...(JSON.parse(example!) as PluginManifest), id: "merge-test-manifest-example" };
|
||||
try {
|
||||
expect(() => registerPlugin({ manifest })).not.toThrow();
|
||||
} finally {
|
||||
unregisterPlugin(manifest.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("reduced motion preserves modal completion events", async () => {
|
||||
const css = await Bun.file(new URL("../src/pages/admin/theme/admin-chrome.css", import.meta.url)).text();
|
||||
const reducedMotion = css.slice(css.lastIndexOf("@media (prefers-reduced-motion: reduce)"));
|
||||
expect(reducedMotion).toContain("animation-duration: 0.01ms !important");
|
||||
expect(reducedMotion).toContain("transition-duration: 0.01ms !important");
|
||||
expect(reducedMotion).not.toContain("animation: none");
|
||||
expect(reducedMotion).not.toContain("transition: none");
|
||||
});
|
||||
Reference in New Issue
Block a user