feat: add release controls and 09:00 collection
This commit is contained in:
@@ -13,6 +13,10 @@ import {
|
||||
formatShanghaiToday,
|
||||
} from "../lib/date-utils";
|
||||
import type { AuthUser } from "../lib/user-auth";
|
||||
import {
|
||||
sortWithNullsLast,
|
||||
type SortDirection,
|
||||
} from "../lib/sort-utils";
|
||||
import UsersPage from "./users-page";
|
||||
|
||||
type Partner = {
|
||||
@@ -106,6 +110,14 @@ type DashboardData = {
|
||||
portal_url: string;
|
||||
};
|
||||
|
||||
type RecoverySortKey =
|
||||
| "publish_time"
|
||||
| "likes"
|
||||
| "collects"
|
||||
| "comments"
|
||||
| "total"
|
||||
| "collection_updated_at";
|
||||
|
||||
type FeishuPreview = {
|
||||
sheetId: string;
|
||||
sheetName: string;
|
||||
@@ -207,6 +219,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;
|
||||
}
|
||||
@@ -515,7 +589,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
startDate,
|
||||
days,
|
||||
},
|
||||
`采集任务已创建,将在所选日期10:00自动执行`,
|
||||
`采集任务已创建,将在所选日期09:00自动执行`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -786,6 +860,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" && (
|
||||
@@ -1058,7 +1143,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>
|
||||
@@ -1278,11 +1363,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 (
|
||||
@@ -1325,8 +1417,69 @@ function DistributionPage({
|
||||
<div><h2>内容分发表</h2><p>实际发布账号在链接回填后自动补齐</p></div>
|
||||
<div className="filter-chips"><button className="active">全部</button><button>待发布</button><button>采集中</button></div>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1334,9 +1487,11 @@ function DistributionPage({
|
||||
function DistributionTable({
|
||||
distributions,
|
||||
compact = false,
|
||||
onRelease,
|
||||
}: {
|
||||
distributions: Distribution[];
|
||||
compact?: boolean;
|
||||
onRelease?: (distribution: Distribution) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="table-scroll">
|
||||
@@ -1349,6 +1504,7 @@ function DistributionTable({
|
||||
<th>发布时间</th>
|
||||
<th>数据状态</th>
|
||||
<th>状态</th>
|
||||
{onRelease && <th>操作</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1382,6 +1538,21 @@ function DistributionTable({
|
||||
)}
|
||||
</td>
|
||||
<td><span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span></td>
|
||||
{onRelease && (
|
||||
<td>
|
||||
{!item.publish_url ? (
|
||||
<button
|
||||
type="button"
|
||||
className="release-distribution-button"
|
||||
onClick={() => onRelease(item)}
|
||||
>
|
||||
释放
|
||||
</button>
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -1661,6 +1832,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 (
|
||||
@@ -1685,6 +1860,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
|
||||
@@ -1731,19 +1923,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;
|
||||
@@ -1906,7 +2134,7 @@ function CollectionScheduleCard({
|
||||
<div>
|
||||
<p className="eyebrow">自动采集计划</p>
|
||||
<h2>选择开始日期与采集日</h2>
|
||||
<span>勾选第1天到第7天,所选日期均在北京时间10:00自动采集。</span>
|
||||
<span>勾选第1天到第7天,所选日期均在北京时间09:00自动采集。</span>
|
||||
</div>
|
||||
<div className="schedule-save-area">
|
||||
<span>已选择 {days.length} 天</span>
|
||||
|
||||
Reference in New Issue
Block a user