feat: add distribution release and recovery controls

This commit is contained in:
巫凤萍
2026-08-12 11:12:23 +08:00
parent ddad4b7659
commit 7ef150e08b
16 changed files with 717 additions and 33 deletions

View File

@@ -14,6 +14,10 @@ import {
} from "../lib/date-utils";
import type { AuthUser } from "../lib/user-auth";
import { parseResultScreenshotKeys } from "../lib/result-screenshots";
import {
sortWithNullsLast,
type SortDirection,
} from "../lib/sort-utils";
import UsersPage from "./users-page";
type Partner = {
@@ -120,6 +124,14 @@ type Distribution = {
due_at: string;
};
type RecoverySortKey =
| "publish_time"
| "likes"
| "collects"
| "comments"
| "total"
| "collection_updated_at";
type DashboardData = {
partners: Partner[];
tasks: Task[];
@@ -229,6 +241,68 @@ function latestPublicMetrics(distribution: Distribution) {
};
}
function recoverySortValue(
distribution: Distribution,
key: RecoverySortKey,
): number | null {
const metrics = latestPublicMetrics(distribution);
if (key === "likes") return metrics.likes;
if (key === "collects") return metrics.collects;
if (key === "comments") return metrics.comments;
if (key === "total") return metrics.total;
const value =
key === "publish_time"
? distribution.publish_time
: distribution.collection_updated_at ||
(metrics.likes !== null ? distribution.updated_at : null);
if (!value) return null;
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? null : timestamp;
}
function sortRecoveryDistributions(
distributions: Distribution[],
key: RecoverySortKey,
direction: SortDirection,
) {
return sortWithNullsLast(
distributions,
(distribution) => recoverySortValue(distribution, key),
direction,
);
}
function SortableRecoveryHeader({
label,
column,
activeColumn,
direction,
onSort,
}: {
label: string;
column: RecoverySortKey;
activeColumn: RecoverySortKey | null;
direction: SortDirection;
onSort: (column: RecoverySortKey) => void;
}) {
const active = activeColumn === column;
const arrow = active ? (direction === "asc" ? "↑" : "↓") : "↕";
return (
<th aria-sort={active ? (direction === "asc" ? "ascending" : "descending") : "none"}>
<button
type="button"
className={`recovery-sort-button${active ? " active" : ""}`}
onClick={() => onSort(column)}
title={`${label}${active && direction === "desc" ? "正序" : "倒序"}排列`}
>
<span>{label}</span>
<b aria-hidden="true">{arrow}</b>
</button>
</th>
);
}
function hasCreatorMetrics(distribution: Distribution) {
return distribution.exposure !== null && distribution.views !== null;
}
@@ -617,7 +691,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
startDate,
days,
},
`采集任务已创建,将在所选日期10:00自动执行`,
`采集任务已创建将在所选日期09:00自动执行`,
);
};
@@ -893,6 +967,17 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
<DistributionPage
tasks={data.tasks}
distributions={data.distributions}
canRelease={isManager}
working={working}
onRelease={async (distribution) =>
runAction(
{
action: "release_distribution",
distributionId: distribution.id,
},
`${distribution.content_title}”已释放,可重新领取`,
)
}
/>
)}
{activeNav === "resources" && (
@@ -1247,7 +1332,7 @@ function OverviewPage({
<div>
<p className="eyebrow"></p>
<h3></h3>
<p>系统在当天10:00更新点赞</p>
<p>系统在当天09:00更新点赞</p>
</div>
<button className="primary-button soft" onClick={() => onNavigate("recovery")}></button>
</div>
@@ -1489,11 +1574,18 @@ function TaskDetailHeader({
function DistributionPage({
tasks,
distributions,
canRelease,
working,
onRelease,
}: {
tasks: Task[];
distributions: Distribution[];
canRelease: boolean;
working: boolean;
onRelease: (distribution: Distribution) => Promise<boolean>;
}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
if (!selectedTask) {
return (
@@ -1540,9 +1632,70 @@ function DistributionPage({
{isScreenshotTask ? (
<ScreenshotTaskTable distributions={taskDistributions} />
) : (
<DistributionTable distributions={taskDistributions} />
<DistributionTable
distributions={taskDistributions}
onRelease={canRelease ? setReleaseTarget : undefined}
/>
)}
</section>
{releaseTarget && (
<div
className="modal-backdrop"
role="presentation"
onMouseDown={() => { if (!working) setReleaseTarget(null); }}
>
<div
className="modal-card compact release-distribution-modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="release-distribution-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow warning"></p>
<h2 id="release-distribution-title"></h2>
</div>
<button
type="button"
onClick={() => setReleaseTarget(null)}
aria-label="关闭"
disabled={working}
>
×
</button>
</div>
<div className="release-distribution-summary">
<strong>{releaseTarget.content_title}</strong>
<span>{releaseTarget.partner_name}</span>
</div>
<p className="release-distribution-warning">
KOC
</p>
<div className="modal-actions">
<button
type="button"
className="ghost-button"
onClick={() => setReleaseTarget(null)}
disabled={working}
>
</button>
<button
type="button"
className="release-confirm-button"
onClick={async () => {
const released = await onRelease(releaseTarget);
if (released) setReleaseTarget(null);
}}
disabled={working}
>
{working ? "释放中…" : "确认释放"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -1550,9 +1703,11 @@ function DistributionPage({
function DistributionTable({
distributions,
compact = false,
onRelease,
}: {
distributions: Distribution[];
compact?: boolean;
onRelease?: (distribution: Distribution) => void;
}) {
return (
<div className="table-scroll">
@@ -1565,6 +1720,7 @@ function DistributionTable({
<th></th>
<th></th>
<th></th>
{onRelease && <th></th>}
</tr>
</thead>
<tbody>
@@ -1598,6 +1754,21 @@ function DistributionTable({
)}
</td>
<td><span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span></td>
{onRelease && (
<td>
{!item.publish_url && !item.result_submitted_at ? (
<button
type="button"
className="release-distribution-button"
onClick={() => onRelease(item)}
>
</button>
) : (
<span className="muted"></span>
)}
</td>
)}
</tr>
))}
</tbody>
@@ -2233,6 +2404,10 @@ function RecoveryPage({
exportingTaskId: string | null;
}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [recoverySort, setRecoverySort] = useState<{
column: RecoverySortKey | null;
direction: SortDirection;
}>({ column: null, direction: "desc" });
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
if (!selectedTask) {
return (
@@ -2295,6 +2470,23 @@ function RecoveryPage({
const failedCollectionCount = taskDistributions.filter(
(item) => item.collection_status === "failed",
).length;
const sortedTaskDistributions = recoverySort.column
? sortRecoveryDistributions(
taskDistributions,
recoverySort.column,
recoverySort.direction,
)
: taskDistributions;
const changeRecoverySort = (column: RecoverySortKey) => {
setRecoverySort((current) =>
current.column === column
? {
column,
direction: current.direction === "desc" ? "asc" : "desc",
}
: { column, direction: "desc" },
);
};
return (
<div className="stack">
<TaskDetailHeader
@@ -2341,19 +2533,55 @@ function RecoveryPage({
<thead>
<tr>
<th> / </th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<SortableRecoveryHeader
label="发布时间"
column="publish_time"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="点赞"
column="likes"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="收藏"
column="collects"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="评论"
column="comments"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="总互动"
column="total"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="数据更新时间"
column="collection_updated_at"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{taskDistributions.map((item) => {
{sortedTaskDistributions.map((item) => {
const metrics = latestPublicMetrics(item);
const noteUrl = xhsPublishUrl(item.publish_url);
const hasMetrics = metrics.likes !== null;
@@ -2512,7 +2740,7 @@ function CollectionScheduleCard({
<div>
<p className="eyebrow"></p>
<h2></h2>
<span>17所选日期均在北京时间10:00自动采集</span>
<span>17所选日期均在北京时间09:00自动采集</span>
</div>
<div className="schedule-save-area">
<span> {days.length} </span>

View File

@@ -30,6 +30,11 @@ import {
createDistributionTask,
createScreenshotTask,
} from "../../../lib/task-service";
import {
DistributionReleaseError,
releaseUnfinishedDistribution,
} from "../../../lib/distribution-release-service";
import { isManagerRequest } from "../../../lib/user-auth";
type ActionBody = {
action?: string;
@@ -135,6 +140,12 @@ export async function POST(request: Request) {
.bind(available.results.length, partnerId),
);
await db.batch(statements);
} else if (body.action === "release_distribution") {
if (!(await isManagerRequest(request))) return adminForbidden();
await releaseUnfinishedDistribution(
db,
String(body.distributionId ?? "").trim(),
);
} else if (body.action === "save_collection_schedule") {
const taskId = String(body.taskId ?? "").trim();
const startDate = String(body.startDate ?? "").trim();
@@ -198,7 +209,7 @@ export async function POST(request: Request) {
AND publish_url != ''`,
)
.bind(
`已安排${days.length}个采集日,每日10:00执行`,
`已安排${days.length}个采集日每日09:00执行`,
taskId,
),
]);
@@ -362,7 +373,13 @@ export async function POST(request: Request) {
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "操作失败" },
{ status: error instanceof FeishuSourceError ? error.status : 500 },
{
status:
error instanceof FeishuSourceError ||
error instanceof DistributionReleaseError
? error.status
: 500,
},
);
}
}

View File

@@ -2264,6 +2264,41 @@ a {
white-space: nowrap;
}
.recovery-sort-button {
display: inline-flex;
min-width: 100%;
align-items: center;
justify-content: flex-start;
gap: 4px;
padding: 8px 0;
border: 0;
color: inherit;
background: transparent;
font: inherit;
cursor: pointer;
}
.recovery-sort-button b {
color: #aab2af;
font-size: 11px;
line-height: 1;
}
.recovery-sort-button:hover,
.recovery-sort-button.active {
color: #168565;
}
.recovery-sort-button.active b {
color: #168565;
}
.recovery-sort-button:focus-visible {
border-radius: 4px;
outline: 2px solid rgba(22, 133, 101, 0.3);
outline-offset: 2px;
}
.recovery-content {
max-width: 360px;
}
@@ -3572,6 +3607,81 @@ label small {
color: #b04b40;
}
.eyebrow.warning {
color: #b06d24;
}
.release-distribution-button {
min-height: 30px;
padding: 0 11px;
border: 1px solid #efd9bd;
border-radius: 8px;
color: #95601f;
background: #fffaf2;
font-size: 9px;
font-weight: 650;
white-space: nowrap;
}
.release-distribution-button:hover {
border-color: #dfbd91;
background: #fff5e5;
}
.release-distribution-summary {
display: grid;
gap: 6px;
padding: 13px 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: #f8faf8;
}
.release-distribution-summary strong {
color: var(--ink);
font-size: 11px;
line-height: 1.5;
}
.release-distribution-summary span {
color: #7d8985;
font-size: 9px;
}
.release-distribution-warning {
margin: 0;
padding: 13px 14px;
border: 1px solid #f1dfc8;
border-radius: 10px;
color: #805e35;
background: #fffaf3;
font-size: 10px;
line-height: 1.65;
}
.release-confirm-button {
display: inline-flex;
min-height: 38px;
align-items: center;
justify-content: center;
padding: 0 15px;
border: 1px solid #a56a26;
border-radius: 9px;
color: white;
background: #b9792f;
font-size: 11px;
font-weight: 650;
}
.release-confirm-button:hover {
background: #9d6425;
}
.release-confirm-button:disabled {
cursor: wait;
opacity: 0.62;
}
.user-delete-warning {
margin: 0;
padding: 13px 14px;