feat: add admin accounts and image templates

This commit is contained in:
inman
2026-07-03 11:25:25 +08:00
parent d98e58adfa
commit c3ea9f0eb1
61 changed files with 7016 additions and 199 deletions

View File

@@ -0,0 +1,640 @@
"use client";
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { CirclePause, KeyRound, Loader2, Pencil, Plus, RefreshCw, Save, Search, Trash2, UserCheck, UserPlus, UserX } from "lucide-react";
import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
type OrganizationInfo = {
organizationId: string;
organizationName: string;
};
type OrganizationGroupInfo = {
groupId: string;
groupName: string;
};
type OrganizationRoleInfo = {
roleId: string;
roleName: string;
};
type OrganizationMemberInfo = {
memberId: string;
memberName: string;
memberStatus: string;
memberPhone: string;
organizationId: string;
memberRoleId?: string;
memberRoleName?: string;
memberGroupId?: string;
memberGroupName?: string;
memberUserId?: string;
};
type PageResult<T> = {
records: T[];
total: number;
size: number;
current: number;
pages: number;
};
type AccountPayload = {
configured: boolean;
missing: string[];
staffConfigured: boolean;
staffMissing: string[];
selectedOrganizationId: string;
organizations: OrganizationInfo[];
groups: OrganizationGroupInfo[];
roles: OrganizationRoleInfo[];
members: PageResult<OrganizationMemberInfo>;
warnings?: string[];
memberListAvailable?: boolean;
passwordManagementAvailable: boolean;
};
type MemberForm = {
organizationId: string;
username: string;
memberName: string;
memberPhone: string;
initialPassword: string;
mustChangePassword: boolean;
roleId: string;
groupId: string;
};
type DepartmentForm = {
groupName: string;
groupDesc: string;
};
const blankForm: MemberForm = {
organizationId: "",
username: "",
memberName: "",
memberPhone: "",
initialPassword: "",
mustChangePassword: true,
roleId: "",
groupId: ""
};
const blankDepartmentForm: DepartmentForm = {
groupName: "",
groupDesc: ""
};
const STATUS_OPTIONS = [
{ value: "", label: "全部状态" },
{ value: "0", label: "在岗" },
{ value: "1", label: "休假" },
{ value: "2", label: "停用" }
];
export function AccountManager() {
const [payload, setPayload] = useState<AccountPayload | null>(null);
const [organizationId, setOrganizationId] = useState("");
const [status, setStatus] = useState("");
const [query, setQuery] = useState("");
const [draftQuery, setDraftQuery] = useState("");
const [pageNum, setPageNum] = useState(1);
const [form, setForm] = useState<MemberForm>(blankForm);
const [departmentForm, setDepartmentForm] = useState<DepartmentForm>(blankDepartmentForm);
const [editingId, setEditingId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [creatingGroup, setCreatingGroup] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const managerRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLElement | null>(null);
const feedbackRef = useRef<HTMLDivElement | null>(null);
const selectedOrganizationId = organizationId || payload?.selectedOrganizationId || "";
const members = payload?.members.records || [];
const total = payload?.members.total || 0;
const pages = payload?.members.pages || 0;
const memberListAvailable = payload?.memberListAvailable !== false;
const warnings = payload?.warnings || [];
const canSubmit = Boolean(payload?.configured && selectedOrganizationId && form.memberName.trim() &&
(editingId || (payload.staffConfigured && form.memberPhone.trim())) &&
selectedRoleId(payload, form) && selectedGroupId(payload, form));
const currentOrganization = useMemo(() => {
return payload?.organizations.find((item) => item.organizationId === selectedOrganizationId);
}, [payload?.organizations, selectedOrganizationId]);
useEffect(() => {
void loadAccounts();
}, [organizationId, pageNum, query, status]);
useEffect(() => {
return runScopedMotion(managerRef, (scope) => revealChildren(scope));
}, []);
useEffect(() => {
crossfadeIn(listRef.current);
}, [members.length, pageNum, query, status, selectedOrganizationId]);
useEffect(() => {
pulseFeedback(feedbackRef.current);
}, [error, message]);
async function loadAccounts() {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
pageNum: String(pageNum),
pageSize: "10"
});
if (organizationId) params.set("organizationId", organizationId);
if (query) params.set("memberName", query);
if (status) params.set("memberStatus", status);
const response = await fetch(`/api/admin/accounts?${params.toString()}`, { cache: "no-store" });
const nextPayload = await response.json() as AccountPayload & { error?: string };
if (!response.ok) throw new Error(nextPayload.error || "读取账号数据失败");
setPayload(nextPayload);
syncSelectedOrganization(nextPayload);
syncFormDefaults(nextPayload);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
function syncSelectedOrganization(nextPayload: AccountPayload) {
if (!organizationId && nextPayload.selectedOrganizationId) {
setOrganizationId(nextPayload.selectedOrganizationId);
}
}
function syncFormDefaults(nextPayload: AccountPayload) {
setForm((current) => {
const nextOrganizationId = nextPayload.selectedOrganizationId || current.organizationId;
return {
...current,
organizationId: nextOrganizationId,
roleId: nextPayload.roles.some((role) => role.roleId === current.roleId)
? current.roleId
: nextPayload.roles[0]?.roleId || "",
groupId: nextPayload.groups.some((group) => group.groupId === current.groupId)
? current.groupId
: nextPayload.groups[0]?.groupId || ""
};
});
}
function submitSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPageNum(1);
setQuery(draftQuery.trim());
}
async function submitMember(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
setError(null);
setMessage(null);
try {
const roleId = selectedRoleId(payload, form);
const groupId = selectedGroupId(payload, form);
const body = editingId ? {
memberId: editingId,
memberName: form.memberName.trim(),
roleId,
groupId
} : {
organizationId: selectedOrganizationId,
username: form.username.trim(),
phone: form.memberPhone.trim(),
name: form.memberName.trim(),
initialPassword: form.initialPassword.trim(),
mustChangePassword: form.mustChangePassword,
memberName: form.memberName.trim(),
memberPhone: form.memberPhone.trim(),
roleId,
groupId
};
const response = await fetch("/api/admin/accounts", {
method: editingId ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "保存成员失败");
const initialPassword = result.user?.initialPassword;
const successMessage = editingId
? "成员信息已更新。"
: initialPassword
? `成员已创建,初始密码:${initialPassword}`
: "成员已创建。";
setMessage(!editingId && !memberListAvailable
? `${successMessage} 成员列表暂不可用,上游授权后可在列表看到。`
: successMessage);
resetForm();
await loadAccounts();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
async function changeStatus(memberId: string, memberStatus: "0" | "1" | "2") {
await mutateMember("PUT", { memberId, memberStatus }, "成员状态已更新。");
}
async function removeMember(memberId: string) {
if (!window.confirm("确定删除这个成员?")) return;
await mutateMember("DELETE", { memberId }, "成员已删除。");
}
async function resetPassword(member: OrganizationMemberInfo) {
if (!member.memberUserId) {
setError("该成员还没有绑定企业端用户,不能重置密码。");
return;
}
const newPassword = window.prompt(`请输入 ${member.memberName} 的新密码`);
if (!newPassword) return;
setSaving(true);
setError(null);
setMessage(null);
try {
const response = await fetch("/api/admin/accounts/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId: member.memberUserId,
newPassword,
mustChangePassword: true
})
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "重置密码失败");
setMessage("密码已重置,用户下次登录需要修改密码。");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
async function mutateMember(method: "PUT" | "DELETE", body: Record<string, string>, successMessage: string) {
setSaving(true);
setError(null);
setMessage(null);
try {
const response = await fetch("/api/admin/accounts", {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "成员操作失败");
setMessage(successMessage);
await loadAccounts();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
async function submitGroup(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedOrganizationId) {
setError("请先选择组织。");
return;
}
const groupName = departmentForm.groupName.trim();
if (!groupName) {
setError("部门名称不能为空。");
return;
}
setCreatingGroup(true);
setError(null);
setMessage(null);
try {
const response = await fetch("/api/admin/accounts/groups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
organizationId: selectedOrganizationId,
groupName,
groupDesc: departmentForm.groupDesc.trim()
})
});
const result = await response.json() as { error?: string; group?: OrganizationGroupInfo | null };
if (!response.ok) throw new Error(result.error || "创建部门失败");
setDepartmentForm(blankDepartmentForm);
await loadAccounts();
if (result.group?.groupId) {
setForm((current) => ({ ...current, groupId: result.group?.groupId || current.groupId }));
}
setMessage("部门已创建。");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setCreatingGroup(false);
}
}
function editMember(member: OrganizationMemberInfo) {
setEditingId(member.memberId);
setForm({
organizationId: member.organizationId || selectedOrganizationId,
username: "",
memberName: member.memberName || "",
memberPhone: member.memberPhone || "",
initialPassword: "",
mustChangePassword: true,
roleId: member.memberRoleId || payload?.roles[0]?.roleId || "",
groupId: member.memberGroupId || payload?.groups[0]?.groupId || ""
});
}
function resetForm() {
setEditingId(null);
setForm({
organizationId: selectedOrganizationId,
username: "",
memberName: "",
memberPhone: "",
initialPassword: "",
mustChangePassword: true,
roleId: payload?.roles[0]?.roleId || "",
groupId: payload?.groups[0]?.groupId || ""
});
}
return (
<div className="account-manager" ref={managerRef}>
<div className="workspace-head" data-animate>
<div>
<h1 className="workspace-title"></h1>
</div>
<div className="workspace-meta">
<span className="status">{payload?.organizations.length || 0} </span>
<span className="status running">{total} </span>
<span className={`status ${payload?.configured ? "" : "failed"}`}>{payload?.configured ? "已接入" : "待配置"}</span>
</div>
</div>
<section className="panel account-actions" data-animate>
<label className="field account-org-field">
<span></span>
<select
value={selectedOrganizationId}
onChange={(event) => {
setOrganizationId(event.target.value);
setPageNum(1);
setEditingId(null);
setForm({ ...blankForm, organizationId: event.target.value });
}}
disabled={!payload?.configured || loading}
>
{payload?.organizations.length ? payload.organizations.map((item) => (
<option value={item.organizationId} key={item.organizationId}>{item.organizationName || item.organizationId}</option>
)) : (
<option value="">{payload?.configured ? "暂无组织" : "组织接口未配置"}</option>
)}
</select>
</label>
<form className="log-search account-search" onSubmit={submitSearch}>
<Search aria-hidden="true" />
<input
value={draftQuery}
placeholder="搜索成员姓名"
onChange={(event) => setDraftQuery(event.target.value)}
disabled={!memberListAvailable || loading}
/>
</form>
<select className="account-status-select" value={status} onChange={(event) => {
setStatus(event.target.value);
setPageNum(1);
}} disabled={!memberListAvailable || loading}>
{STATUS_OPTIONS.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
</select>
<div className="toolbar">
<button className="button" type="button" onClick={loadAccounts} disabled={loading || saving}>
{loading ? <Loader2 className="spin" size={18} /> : <RefreshCw size={18} />}
</button>
</div>
</section>
{error || message ? (
<div ref={feedbackRef}>
{error ? <div className="callout" role="alert">{error}</div> : null}
{message ? <div className="callout success-callout" role="status" aria-live="polite">{message}</div> : null}
</div>
) : null}
{warnings.length ? (
<div className="callout" role="status" data-animate>
{accountWarningText(warnings[0])}
</div>
) : null}
{!payload?.configured ? (
<div className="callout" role="status" data-animate>
</div>
) : !payload.passwordManagementAvailable ? (
<div className="callout" role="status" data-animate>
{payload?.staffMissing.join("、") || "ZHINIAN_STAFF_API_BASE_URL、ZHINIAN_ORG_TENANT_ID"}
</div>
) : null}
<section className="panel account-department-panel" data-animate>
<div className="settings-group-head">
<div>
<h2></h2>
<p className="muted">{currentOrganization?.organizationName || selectedOrganizationId || "请选择组织"}</p>
</div>
</div>
<form className="account-department-form" onSubmit={submitGroup}>
<label className="field">
<span></span>
<input
value={departmentForm.groupName}
onChange={(event) => setDepartmentForm((item) => ({ ...item, groupName: event.target.value }))}
disabled={!payload?.configured || !selectedOrganizationId || creatingGroup}
/>
</label>
<label className="field">
<span></span>
<input
value={departmentForm.groupDesc}
placeholder="可留空"
onChange={(event) => setDepartmentForm((item) => ({ ...item, groupDesc: event.target.value }))}
disabled={!payload?.configured || !selectedOrganizationId || creatingGroup}
/>
</label>
<button className="button" type="submit" disabled={!payload?.configured || !selectedOrganizationId || creatingGroup || !departmentForm.groupName.trim()}>
{creatingGroup ? <Loader2 className="spin" size={18} /> : <Plus size={18} />}
</button>
</form>
</section>
<section className="panel account-form-panel" data-animate>
<div className="settings-group-head">
<div>
<h2>{editingId ? "编辑成员" : "新建用户"}</h2>
<p className="muted">{currentOrganization?.organizationName || selectedOrganizationId || "请选择组织"}</p>
</div>
{editingId ? (
<button className="button" type="button" onClick={resetForm} disabled={saving}>
</button>
) : null}
</div>
<form className="account-form-grid" onSubmit={submitMember}>
<label className="field">
<span></span>
<input value={form.memberName} onChange={(event) => setForm((item) => ({ ...item, memberName: event.target.value }))} disabled={!payload?.configured || saving} />
</label>
<label className="field">
<span></span>
<input value={form.memberPhone} onChange={(event) => setForm((item) => ({ ...item, memberPhone: event.target.value }))} disabled={!payload?.configured || saving || Boolean(editingId)} />
</label>
<label className="field">
<span></span>
<input value={form.username} placeholder="默认使用手机号" onChange={(event) => setForm((item) => ({ ...item, username: event.target.value }))} disabled={!payload?.staffConfigured || saving || Boolean(editingId)} />
</label>
<label className="field">
<span></span>
<input type="password" value={form.initialPassword} placeholder="留空由服务端生成" onChange={(event) => setForm((item) => ({ ...item, initialPassword: event.target.value }))} disabled={!payload?.staffConfigured || saving || Boolean(editingId)} />
</label>
<label className="field">
<span></span>
<select value={selectedRoleId(payload, form)} onChange={(event) => setForm((item) => ({ ...item, roleId: event.target.value }))} disabled={!payload?.roles.length || saving}>
{payload?.roles.length ? payload.roles.map((role) => (
<option value={role.roleId} key={role.roleId}>{role.roleName}</option>
)) : <option value=""></option>}
</select>
</label>
<label className="field">
<span></span>
<select value={selectedGroupId(payload, form)} onChange={(event) => setForm((item) => ({ ...item, groupId: event.target.value }))} disabled={!payload?.groups.length || saving}>
{payload?.groups.length ? payload.groups.map((group) => (
<option value={group.groupId} key={group.groupId}>{group.groupName}</option>
)) : <option value=""></option>}
</select>
</label>
<label className="toggle-line account-password-toggle">
<input
type="checkbox"
checked={form.mustChangePassword}
onChange={(event) => setForm((item) => ({ ...item, mustChangePassword: event.target.checked }))}
disabled={!payload?.staffConfigured || saving || Boolean(editingId)}
/>
</label>
<div className="toolbar account-form-submit">
<button className="button primary account-create-submit" type="submit" disabled={!canSubmit || saving || creatingGroup}>
{saving ? <Loader2 className="spin" size={18} /> : editingId ? <Save size={18} /> : <UserPlus size={18} />}
{editingId ? "保存成员" : "新建用户"}
</button>
</div>
</form>
</section>
<section className="panel account-list" data-animate ref={listRef}>
{loading && !payload ? (
<div className="settings-loading">
<Loader2 className="spin" size={18} />
</div>
) : members.length ? (
<div className="account-table" role="table" aria-label="账号成员">
<div className="account-row account-head" role="row">
<span></span>
<span> / </span>
<span></span>
<span></span>
</div>
{members.map((member) => (
<div className="account-row" role="row" key={member.memberId}>
<div className="account-member-main">
<strong>{member.memberName}</strong>
<small>{member.memberPhone || member.memberId}</small>
</div>
<div className="account-member-main">
<strong>{member.memberRoleName || "-"}</strong>
<small>{member.memberGroupName || "-"}</small>
</div>
<span className={`status ${member.memberStatus === "2" ? "failed" : member.memberStatus === "1" ? "running" : ""}`}>{memberStatusLabel(member.memberStatus)}</span>
<div className="toolbar account-row-actions">
<button className="icon-button" type="button" title="编辑成员" aria-label="编辑成员" onClick={() => editMember(member)} disabled={saving}>
<Pencil aria-hidden="true" />
</button>
<button className="icon-button" type="button" title="重置密码" aria-label="重置密码" onClick={() => resetPassword(member)} disabled={saving || !payload?.staffConfigured || !member.memberUserId}>
<KeyRound aria-hidden="true" />
</button>
<button className="icon-button" type="button" title={member.memberStatus === "0" ? "设为休假" : "设为在岗"} aria-label={member.memberStatus === "0" ? "设为休假" : "设为在岗"} onClick={() => changeStatus(member.memberId, member.memberStatus === "0" ? "1" : "0")} disabled={saving}>
{member.memberStatus === "0" ? <CirclePause aria-hidden="true" /> : <UserCheck aria-hidden="true" />}
</button>
<button className="icon-button danger-icon" type="button" title="停用成员" aria-label="停用成员" onClick={() => changeStatus(member.memberId, "2")} disabled={saving || member.memberStatus === "2"}>
<UserX aria-hidden="true" />
</button>
<button className="icon-button danger-icon" type="button" title="删除成员" aria-label="删除成员" onClick={() => removeMember(member.memberId)} disabled={saving}>
<Trash2 aria-hidden="true" />
</button>
</div>
</div>
))}
</div>
) : (
<div className="log-empty">{accountEmptyText(payload)}</div>
)}
{pages > 1 ? (
<div className="pagination">
<button className="icon-button" type="button" onClick={() => setPageNum((item) => Math.max(1, item - 1))} disabled={pageNum <= 1 || loading}></button>
<span className="pager-meta">{pageNum} / {pages}</span>
<button className="icon-button" type="button" onClick={() => setPageNum((item) => Math.min(pages, item + 1))} disabled={pageNum >= pages || loading}></button>
</div>
) : null}
</section>
</div>
);
}
function selectedRoleId(payload: AccountPayload | null, form: MemberForm): string {
if (payload?.roles.some((role) => role.roleId === form.roleId)) return form.roleId;
return payload?.roles[0]?.roleId || "";
}
function selectedGroupId(payload: AccountPayload | null, form: MemberForm): string {
if (payload?.groups.some((group) => group.groupId === form.groupId)) return form.groupId;
return payload?.groups[0]?.groupId || "";
}
function memberStatusLabel(status: string): string {
if (status === "0") return "在岗";
if (status === "1") return "休假";
if (status === "2") return "停用";
return status || "未知";
}
function accountEmptyText(payload: AccountPayload | null): string {
if (!payload?.configured) return "组织接口配置后显示成员";
if (payload.memberListAvailable === false) return "成员列表暂不可用,新增账号仍可使用";
return "暂无成员";
}
function accountWarningText(message: string): string {
if (/仅管理员角色|上游.*管理员|hotelStaff 管理员|管理员角色/i.test(message)) {
return "成员列表需要认证中心管理员角色 1。当前账号已允许进入本平台后台但上游 hotelStaff 暂未授权成员列表查询。";
}
if (/成员列表|organizationMemberList|ZHINIAN_ORG_MEMBER_LIST_PATH/i.test(message)) {
return "成员列表待接入:当前组织服务还没有开放成员查询,新增账号仍可使用。";
}
return message;
}

View File

@@ -6,6 +6,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Archive,
ShieldCheck,
LogIn,
LogOut,
ScrollText,
@@ -20,29 +21,33 @@ import type { AuthUser } from "@/lib/auth/session";
const nav = [
{ href: "/create", label: "创作", icon: Sparkles },
{ href: "/assets", label: "结果", icon: Archive },
{ href: "/logs", label: "日志", icon: ScrollText },
{ href: "/settings", label: "设置", icon: Settings }
{ href: "/logs", label: "日志", icon: ScrollText, adminOnly: true },
{ href: "/settings", label: "设置", icon: Settings, adminOnly: true },
{ href: "/accounts", label: "账号", icon: ShieldCheck, adminOnly: true }
];
export function AppShell({
children,
user,
authRequired
authRequired,
isAdmin
}: {
children: React.ReactNode;
user?: AuthUser | null;
authRequired?: boolean;
isAdmin?: boolean;
}) {
const pathname = usePathname();
const shellRef = useRef<HTMLDivElement | null>(null);
const isAuthPage = pathname.startsWith("/auth");
const isCreatePage = pathname === "/create";
useEffect(() => {
return runScopedMotion(shellRef, (scope) => revealChildren(scope, "[data-shell-animate]"));
}, []);
return (
<div className={clsx("app-shell", isAuthPage && "auth-shell")} ref={shellRef}>
<div className={clsx("app-shell", isAuthPage && "auth-shell", isCreatePage && "create-shell")} ref={shellRef}>
{!isAuthPage ? <a className="skip-link" href="#main-content"></a> : null}
{!isAuthPage ? (
<header className="topbar" data-shell-animate>
@@ -55,7 +60,7 @@ export function AppShell({
</div>
</Link>
<nav className="nav top-nav" aria-label="主导航">
{nav.map((item) => {
{nav.filter((item) => !item.adminOnly || isAdmin).map((item) => {
const Icon = item.icon;
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
return (
@@ -93,7 +98,7 @@ export function AppShell({
</div>
</header>
) : null}
<main className={clsx("main", isAuthPage && "auth-main")} id="main-content" tabIndex={-1}>{children}</main>
<main className={clsx("main", isAuthPage && "auth-main", isCreatePage && "create-main")} id="main-content" tabIndex={-1}>{children}</main>
</div>
);
}

View File

@@ -10,16 +10,22 @@ type AssetView = "assets" | "tasks";
const ASSET_PAGE_SIZE = 8;
const TASK_PAGE_SIZE = 8;
export function AssetManager() {
type AssetManagerProps = {
initialView?: AssetView;
initialTaskId?: string;
};
export function AssetManager({ initialView = "assets", initialTaskId }: AssetManagerProps = {}) {
const [assets, setAssets] = useState<Asset[]>([]);
const [jobs, setJobs] = useState<GenerationJob[]>([]);
const [view, setView] = useState<AssetView>("assets");
const [view, setView] = useState<AssetView>(initialView === "tasks" ? "tasks" : "assets");
const [loading, setLoading] = useState(false);
const [syncingJobId, setSyncingJobId] = useState<string | null>(null);
const [deletingAssetId, setDeletingAssetId] = useState<string | null>(null);
const [deletingJobId, setDeletingJobId] = useState<string | null>(null);
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
const [expandedJobId, setExpandedJobId] = useState<string | null>(initialTaskId || null);
const [durationNow, setDurationNow] = useState(() => Date.now());
const [assetPage, setAssetPage] = useState(1);
const [jobPage, setJobPage] = useState(1);
const [error, setError] = useState<string | null>(null);
@@ -33,6 +39,11 @@ export function AssetManager() {
refresh().catch(() => undefined);
}, []);
useEffect(() => {
if (initialView === "tasks" || initialTaskId) setView("tasks");
if (initialTaskId) setExpandedJobId(initialTaskId);
}, [initialTaskId, initialView]);
const jobByOutputAssetId = useMemo(() => {
const map = new Map<string, GenerationJob>();
for (const job of jobs) {
@@ -43,6 +54,7 @@ export function AssetManager() {
const visibleAssets = pageItems(assets, assetPage, ASSET_PAGE_SIZE);
const visibleJobs = pageItems(jobs, jobPage, TASK_PAGE_SIZE);
const hasLiveJobs = useMemo(() => jobs.some((job) => !isTerminalStatus(job.status)), [jobs]);
useEffect(() => {
setAssetPage((page) => clampPage(page, assets.length, ASSET_PAGE_SIZE));
@@ -57,6 +69,12 @@ export function AssetManager() {
if (view === "tasks") setJobPage((page) => clampPage(page, jobs.length, TASK_PAGE_SIZE));
}, [view, assets.length, jobs.length]);
useEffect(() => {
if (!expandedJobId) return;
const index = jobs.findIndex((job) => job.id === expandedJobId);
if (index >= 0) setJobPage(Math.floor(index / TASK_PAGE_SIZE) + 1);
}, [expandedJobId, jobs]);
useEffect(() => {
return runScopedMotion(managerRef, (scope) => revealChildren(scope));
}, []);
@@ -69,6 +87,15 @@ export function AssetManager() {
pulseFeedback(feedbackRef.current);
}, [error]);
useEffect(() => {
if (!hasLiveJobs) return undefined;
setDurationNow(Date.now());
const timer = window.setInterval(() => {
setDurationNow(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, [hasLiveJobs]);
useEffect(() => {
if (!previewAsset) return undefined;
modalEnter(previewDialogRef.current);
@@ -234,7 +261,7 @@ export function AssetManager() {
<div className="job-summary">
<h3>{job.prompt?.slice(0, 90) || capabilityLabel(job.capability)}</h3>
<p className="muted">{capabilityLabel(job.capability)} / {job.reqKey}</p>
<p className="muted compact-hint">{durationLabel(job)} / {job.inputAssetIds.length || job.inputUrls.length} {job.outputAssetIds.length} </p>
<p className="muted compact-hint">{durationLabel(job, durationNow)} / {job.inputAssetIds.length || job.inputUrls.length} {job.outputAssetIds.length} </p>
</div>
<div className="toolbar job-actions">
<span className={`status ${job.status}`}>{statusLabel(job.status)}</span>
@@ -250,7 +277,7 @@ export function AssetManager() {
</button>
</div>
</div>
{expandedJobId === job.id ? <JobDetails job={job} /> : null}
{expandedJobId === job.id ? <JobDetails job={job} now={durationNow} /> : null}
</article>
))}
{!jobs.length ? <div className="callout"></div> : null}
@@ -286,7 +313,7 @@ export function AssetManager() {
);
}
function JobDetails({ job }: { job: GenerationJob }) {
function JobDetails({ job, now }: { job: GenerationJob; now: number }) {
return (
<div className="job-detail-panel">
<dl className="job-detail-grid">
@@ -296,7 +323,7 @@ function JobDetails({ job }: { job: GenerationJob }) {
</div>
<div>
<dt></dt>
<dd>{durationValue(job)}</dd>
<dd>{durationValue(job, now)}</dd>
</div>
<div>
<dt></dt>
@@ -435,6 +462,10 @@ function statusLabel(status: GenerationJob["status"]) {
return status;
}
function isTerminalStatus(status: GenerationJob["status"]) {
return status === "succeeded" || status === "failed" || status === "expired" || status === "cancelled";
}
function providerLabel(provider: GenerationJob["provider"]) {
if (provider === "volcengine-visual") return "火山视觉";
if (provider === "evolink") return "EvoLink";
@@ -473,15 +504,15 @@ function formatDateTime(value: string) {
}).format(new Date(value));
}
function durationLabel(job: GenerationJob) {
const terminal = ["succeeded", "failed", "expired", "cancelled"].includes(job.status);
return `${terminal ? "耗时" : "已等待"} ${durationValue(job)}`;
function durationLabel(job: GenerationJob, now: number) {
const terminal = isTerminalStatus(job.status);
return `${terminal ? "耗时" : "已耗时"} ${durationValue(job, now)}`;
}
function durationValue(job: GenerationJob) {
function durationValue(job: GenerationJob, now: number) {
const start = new Date(job.createdAt).getTime();
const terminal = ["succeeded", "failed", "expired", "cancelled"].includes(job.status);
const end = terminal ? new Date(job.updatedAt).getTime() : Date.now();
const terminal = isTerminalStatus(job.status);
const end = terminal ? new Date(job.updatedAt).getTime() : now;
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return "--";
return formatDuration(end - start);
}

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@ type SettingsPayload = {
evolink: string;
seedance: string;
auth: string;
organization: string;
data: string;
};
capabilities: Array<{
@@ -159,7 +160,12 @@ export function SettingsPanel() {
{loading ? <Loader2 className="spin" size={18} /> : <RefreshCw size={18} />}
</button>
<button className="button primary" type="button" onClick={saveSettings} disabled={saving || (!fields.length && !payload?.engineAssignments.length)}>
<button
className="button primary"
type="button"
onClick={saveSettings}
disabled={saving || (!fields.length && !payload?.engineAssignments.length)}
>
{saving ? <Loader2 className="spin" size={18} /> : <Save size={18} />}
</button>
@@ -221,6 +227,7 @@ export function SettingsPanel() {
<ServiceBadge label="EvoLink" value={payload?.modes.evolink === "real" ? "真实接口" : "Mock"} ready={payload?.modes.evolink === "real"} />
<ServiceBadge label="Seedance" value={payload?.modes.seedance === "real" ? "真实接口" : "Mock"} ready={payload?.modes.seedance === "real"} />
<ServiceBadge label="账户" value={authModeLabel(payload?.modes.auth)} ready={payload?.modes.auth === "configured"} />
<ServiceBadge label="组织" value={payload?.modes.organization === "configured" ? "已配置" : "待配置"} ready={payload?.modes.organization === "configured"} />
<ServiceBadge label="数据层" value={payload?.modes.data === "supabase" ? "Supabase" : "本地 JSON"} ready={false} />
</div>