Merge branch 'master' into feat/workbench-saas-polish-and-reset
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ExperimentOutlined,
|
||||
FileSearchOutlined,
|
||||
LoginOutlined,
|
||||
ReloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { betaApplicationClient, type BetaApplicationItem, type BetaApplicationStatus } from "../../api/betaApplicationClient";
|
||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
import type { WebUserSession } from "../../types";
|
||||
import "../../styles/pages/beta-applications.css";
|
||||
|
||||
interface BetaApplicationsPageProps {
|
||||
session: WebUserSession | null;
|
||||
onOpenLogin: () => void;
|
||||
}
|
||||
|
||||
type StatusFilter = BetaApplicationStatus | "";
|
||||
|
||||
const STATUS_OPTIONS: Array<{ value: StatusFilter; label: string }> = [
|
||||
{ value: "pending", label: "待审核" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "rejected", label: "已驳回" },
|
||||
{ value: "", label: "全部" },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<BetaApplicationStatus, string> = {
|
||||
pending: "待审核",
|
||||
approved: "已通过",
|
||||
rejected: "已驳回",
|
||||
};
|
||||
|
||||
function canReviewBetaApplications(session: WebUserSession | null): boolean {
|
||||
const role = String(session?.user.role || "").trim().toLowerCase();
|
||||
const username = String(session?.user.username || "").trim().toLowerCase();
|
||||
return role === "admin" || username === "xqy1912";
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return "暂无时间";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function valueOrEmpty(value?: string | null): string {
|
||||
return value?.trim() || "未填写";
|
||||
}
|
||||
|
||||
function joinValues(values: string[]): string {
|
||||
return values.length ? values.join("、") : "未选择";
|
||||
}
|
||||
|
||||
function DetailField({ label, value, wide }: { label: string; value: string; wide?: boolean }) {
|
||||
return (
|
||||
<div className={`beta-admin-field${wide ? " beta-admin-field--wide" : ""}`}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BetaApplicationsPage({ session, onOpenLogin }: BetaApplicationsPageProps) {
|
||||
const allowed = canReviewBetaApplications(session);
|
||||
const [status, setStatus] = useState<StatusFilter>("pending");
|
||||
const [applications, setApplications] = useState<BetaApplicationItem[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [reviewNote, setReviewNote] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedApplication = useMemo(
|
||||
() => applications.find((item) => item.id === selectedId) ?? applications[0] ?? null,
|
||||
[applications, selectedId],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!allowed) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const items = await betaApplicationClient.listAdminApplications(status);
|
||||
setApplications(items);
|
||||
setSelectedId((current) =>
|
||||
current && items.some((item) => item.id === current) ? current : (items[0]?.id ?? null),
|
||||
);
|
||||
} catch (loadError) {
|
||||
setApplications([]);
|
||||
setError(loadError instanceof Error ? loadError.message : "内测申请列表加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [allowed, status]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const handleDecision = async (action: "approve" | "reject") => {
|
||||
if (!selectedApplication || selectedApplication.status !== "pending" || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await betaApplicationClient.reviewApplication(selectedApplication.id, action, reviewNote.trim());
|
||||
setReviewNote("");
|
||||
await load();
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "审核操作失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
|
||||
<section className="beta-admin-access">
|
||||
<LoginOutlined />
|
||||
<h1>请登录审核账号</h1>
|
||||
<p>内测申请审核仅开放给管理员和 xqy1912。</p>
|
||||
<button type="button" onClick={onOpenLogin}>登录 / 注册</button>
|
||||
</section>
|
||||
</WorkspacePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return (
|
||||
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
|
||||
<section className="beta-admin-access">
|
||||
<FileSearchOutlined />
|
||||
<h1>当前账号没有审核权限</h1>
|
||||
<p>请切换到 admin 或 xqy1912 后再进入内测审核台。</p>
|
||||
</section>
|
||||
</WorkspacePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
|
||||
<div className="beta-admin-page__inner">
|
||||
<section className="beta-admin-toolbar">
|
||||
<div>
|
||||
<span>内部审核台</span>
|
||||
<h1>内测申请表</h1>
|
||||
<p>查看用户提交的完整申请资料,通过后发放内测码,驳回后向用户发送未通过通知。</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void load()} disabled={loading}>
|
||||
<ReloadOutlined />
|
||||
刷新
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="beta-admin-status-tabs" role="tablist" aria-label="内测申请状态">
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value || "all"}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={status === option.value}
|
||||
className={status === option.value ? "is-active" : ""}
|
||||
onClick={() => setStatus(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? <p className="beta-admin-error">{error}</p> : null}
|
||||
|
||||
<section className="beta-admin-layout">
|
||||
<aside className="beta-admin-list" aria-label="内测申请列表">
|
||||
{loading ? <div className="beta-admin-list__empty">正在加载申请...</div> : null}
|
||||
{!loading && applications.length === 0 ? (
|
||||
<div className="beta-admin-list__empty">暂无需要显示的申请</div>
|
||||
) : null}
|
||||
{applications.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`beta-admin-list__item${item.id === selectedApplication?.id ? " is-active" : ""}`}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
>
|
||||
<span className={`beta-admin-status beta-admin-status--${item.status}`}>{STATUS_LABEL[item.status]}</span>
|
||||
<strong>{item.name || item.username || `申请 #${item.id}`}</strong>
|
||||
<small>{item.industry || "未填写行业"} · {formatDate(item.createdAt)}</small>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
{selectedApplication ? (
|
||||
<article className="beta-admin-detail">
|
||||
<header className="beta-admin-detail__header">
|
||||
<div>
|
||||
<span><ExperimentOutlined /> {STATUS_LABEL[selectedApplication.status]}</span>
|
||||
<h2>{selectedApplication.name || "未填写姓名"}</h2>
|
||||
<p>{selectedApplication.selfStatement || "申请人未填写自述。"}</p>
|
||||
</div>
|
||||
{selectedApplication.inviteCode ? (
|
||||
<strong className="beta-admin-code">内测码:{selectedApplication.inviteCode}</strong>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>一、个人基础信息</h3>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="姓名 / 常用昵称" value={valueOrEmpty(selectedApplication.name)} />
|
||||
<DetailField label="联系手机号码" value={valueOrEmpty(selectedApplication.phone)} />
|
||||
<DetailField label="微信账号" value={valueOrEmpty(selectedApplication.wechat)} />
|
||||
<DetailField label="所在行业 / 职业" value={valueOrEmpty(selectedApplication.industry)} />
|
||||
<DetailField label="所属公司 / 机构" value={valueOrEmpty(selectedApplication.company)} />
|
||||
<DetailField label="所在城市" value={valueOrEmpty(selectedApplication.city)} />
|
||||
<DetailField label="关联账号" value={selectedApplication.username || `UID ${selectedApplication.userId ?? "未登录提交"}`} />
|
||||
<DetailField label="提交时间" value={formatDate(selectedApplication.createdAt)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>二、AI 从业与使用经历</h3>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="常用 AI 创作工具" value={valueOrEmpty(selectedApplication.aiTools)} wide />
|
||||
<DetailField label="AI 内容创作从业时长" value={valueOrEmpty(selectedApplication.aiDuration)} />
|
||||
<DetailField label="是否深耕相关赛道" value={valueOrEmpty(selectedApplication.aiTrack)} />
|
||||
<DetailField label="日常主要创作方向" value={joinValues(selectedApplication.aiDirection)} wide />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>三、内测使用意向调研</h3>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="每周稳定使用次数" value={valueOrEmpty(selectedApplication.weeklyUsage)} />
|
||||
<DetailField label="反馈意愿" value={valueOrEmpty(selectedApplication.feedbackWilling)} />
|
||||
<DetailField label="最想体验功能" value={joinValues(selectedApplication.wantFeature)} wide />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>四、申请自述与确认</h3>
|
||||
<p className="beta-admin-statement">{selectedApplication.selfStatement || "未填写"}</p>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="申请人确认签字" value={valueOrEmpty(selectedApplication.signature)} />
|
||||
<DetailField label="同意规则" value={selectedApplication.agreeRules ? "已同意" : "未同意"} />
|
||||
<DetailField label="IP" value={valueOrEmpty(selectedApplication.ipAddress)} />
|
||||
<DetailField label="客户端" value={valueOrEmpty(selectedApplication.userAgent)} wide />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{selectedApplication.status !== "pending" ? (
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>审核结果</h3>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="审核人" value={selectedApplication.reviewerUsername || `UID ${selectedApplication.reviewedBy ?? "-"}`} />
|
||||
<DetailField label="审核时间" value={formatDate(selectedApplication.reviewedAt)} />
|
||||
<DetailField label="审核备注" value={valueOrEmpty(selectedApplication.reviewNote)} wide />
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="beta-admin-review-box">
|
||||
<label>
|
||||
<span>审核备注</span>
|
||||
<textarea
|
||||
value={reviewNote}
|
||||
onChange={(event) => setReviewNote(event.target.value)}
|
||||
placeholder="填写通过说明或驳回原因;驳回时该备注会作为用户通知内容。"
|
||||
/>
|
||||
</label>
|
||||
<div className="beta-admin-actions">
|
||||
<button type="button" disabled={submitting} onClick={() => void handleDecision("reject")}>
|
||||
<CloseCircleOutlined />
|
||||
驳回并通知
|
||||
</button>
|
||||
<button type="button" disabled={submitting} onClick={() => void handleDecision("approve")}>
|
||||
<CheckCircleOutlined />
|
||||
通过并发放内测码
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
) : (
|
||||
<div className="beta-admin-detail beta-admin-detail--empty">选择左侧申请查看表单详情</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</WorkspacePageShell>
|
||||
);
|
||||
}
|
||||
@@ -58,13 +58,13 @@ export const defaultTextModelId = textModelOptions[0].id;
|
||||
|
||||
// --- Image model options ---
|
||||
export const imageModelOptions: CanvasOption[] = [
|
||||
{ value: "wan2.7-image", label: "wan 2.7 · 0.20 积分" },
|
||||
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro · 0.20 积分" },
|
||||
{ value: "gpt-image-2", label: "GPT-Image-2 · 0.20 积分" },
|
||||
{ value: "gpt-image-2-vip", label: "GPT-Image-2 VIP · 0.20 积分" },
|
||||
{ value: "nano-banana-pro", label: "Nano Banana Pro · 0.20 积分" },
|
||||
{ value: "nano-banana-2", label: "Nano Banana 2 · 0.20 积分" },
|
||||
{ value: "nano-banana-fast", label: "Nano Banana · 0.20 积分" },
|
||||
{ value: "wan2.7-image", label: "wan 2.7" },
|
||||
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro" },
|
||||
{ value: "gpt-image-2", label: "omni-GPT" },
|
||||
{ value: "gpt-image-2-vip", label: "omni-GPT VIP" },
|
||||
{ value: "nano-banana-pro", label: "omni-水果 Pro" },
|
||||
{ value: "nano-banana-2", label: "omni-水果 2" },
|
||||
{ value: "nano-banana-fast", label: "omni-水果" },
|
||||
];
|
||||
|
||||
export const imageRatioOptions: CanvasOption[] = [
|
||||
|
||||
@@ -842,6 +842,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const skipInitialCloneAutoSaveRef = useRef(true);
|
||||
const skipNextCloneAutoSaveRef = useRef(false);
|
||||
const [activeTool, setActiveTool] = useState<ProductKitToolKey>("clone");
|
||||
useEffect(() => { setPreviewZoom(1); }, [activeTool]);
|
||||
const [setImages, setSetImages] = useState<CloneImageItem[]>([]);
|
||||
const [productSetPlatform, setProductSetPlatform] = useState(platformOptions[0]);
|
||||
const [productSetMarket, setProductSetMarket] = useState(marketOptions[0]);
|
||||
@@ -882,6 +883,30 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const [videoOutfitRefFile, setVideoOutfitRefFile] = useState<File | null>(null);
|
||||
const [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false);
|
||||
const [previewZoom, setPreviewZoom] = useState(1);
|
||||
|
||||
const handlePreviewWheel = (event: React.WheelEvent<HTMLElement>) => {
|
||||
if (!event.currentTarget) return;
|
||||
event.preventDefault();
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const cursorX = event.clientX - rect.left;
|
||||
const cursorY = event.clientY - rect.top;
|
||||
const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92;
|
||||
|
||||
const nextZoom = Math.min(2, Math.max(0.25, previewZoom * zoomDelta));
|
||||
if (nextZoom === previewZoom) return;
|
||||
|
||||
const contentX = (cursorX + container.scrollLeft) / previewZoom;
|
||||
const contentY = (cursorY + container.scrollTop) / previewZoom;
|
||||
|
||||
setPreviewZoom(nextZoom);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
container.scrollLeft = contentX * nextZoom - cursorX;
|
||||
container.scrollTop = contentY * nextZoom - cursorY;
|
||||
});
|
||||
};
|
||||
|
||||
const [requirement, setRequirement] = useState("");
|
||||
const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState<string | null>(null);
|
||||
const [cloneSettingName, setCloneSettingName] = useState("新建创作");
|
||||
@@ -2332,7 +2357,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
);
|
||||
|
||||
const setPreview = (
|
||||
<main className="product-clone-preview product-clone-preview--set" aria-label="AI商品套图预览">
|
||||
<main className="product-clone-preview product-clone-preview--set" aria-label="AI商品套图预览" onWheel={handlePreviewWheel}>
|
||||
<div className="product-clone-preview__headline">
|
||||
<h1>预览</h1>
|
||||
<p>
|
||||
@@ -2400,7 +2425,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
);
|
||||
|
||||
const clonePreview = (
|
||||
<main className="product-clone-preview clone-ai-preview" aria-label="电商AI作图预览">
|
||||
<main className="product-clone-preview clone-ai-preview" aria-label="电商AI作图预览" onWheel={handlePreviewWheel}>
|
||||
<header className="clone-ai-preview-header">
|
||||
<strong>预览</strong>
|
||||
<span>
|
||||
@@ -2610,7 +2635,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
);
|
||||
|
||||
const detailPreview = (
|
||||
<main className="product-clone-preview product-clone-preview--detail" aria-label="A+详情预览">
|
||||
<main className="product-clone-preview product-clone-preview--detail" aria-label="A+详情预览" onWheel={handlePreviewWheel}>
|
||||
<div className="product-clone-preview__headline">
|
||||
<h1>A+/详情页</h1>
|
||||
<p>
|
||||
@@ -2647,7 +2672,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
);
|
||||
|
||||
const tryOnPreview = (
|
||||
<main className="product-clone-preview product-clone-preview--try-on" aria-label="服饰穿戴预览">
|
||||
<main className="product-clone-preview product-clone-preview--try-on" aria-label="服饰穿戴预览" onWheel={handlePreviewWheel}>
|
||||
<div className="product-clone-preview__headline">
|
||||
<h1>AI服饰穿戴</h1>
|
||||
<p>上传服装图,定制专属模特,即刻生成多种场景不同姿势套图。</p>
|
||||
|
||||
@@ -233,11 +233,11 @@ export const MODE_OPTIONS: WorkbenchOption[] = (Object.keys(MODE_META) as Workbe
|
||||
export const IMAGE_MODEL_OPTIONS: WorkbenchOption[] = [
|
||||
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro 4K" },
|
||||
{ value: "wan2.7-image", label: "wan 2.7" },
|
||||
{ value: "gpt-image-2", label: "GPT-Image-2" },
|
||||
{ value: "gpt-image-2-vip", label: "GPT-Image-2 VIP" },
|
||||
{ value: "nano-banana-pro", label: "Nano Banana Pro" },
|
||||
{ value: "nano-banana-2", label: "Nano Banana 2" },
|
||||
{ value: "nano-banana-fast", label: "Nano Banana" },
|
||||
{ value: "gpt-image-2", label: "omni-GPT" },
|
||||
{ value: "gpt-image-2-vip", label: "omni-GPT VIP" },
|
||||
{ value: "nano-banana-pro", label: "omni-水果 Pro" },
|
||||
{ value: "nano-banana-2", label: "omni-水果 2" },
|
||||
{ value: "nano-banana-fast", label: "omni-水果" },
|
||||
];
|
||||
|
||||
export const VIDEO_MODEL_OPTIONS: WorkbenchOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({ ...option }));
|
||||
|
||||
Reference in New Issue
Block a user