Compare commits
5 Commits
ddad4b7659
...
ad3dbdcc86
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad3dbdcc86 | ||
|
|
ee6caaf9e5 | ||
|
|
e05041e037 | ||
|
|
51934b0638 | ||
|
|
7ef150e08b |
@@ -24,7 +24,7 @@ FEISHU_APP_SECRET=
|
|||||||
AI_TOOL_CENTER_MCP_URL=
|
AI_TOOL_CENTER_MCP_URL=
|
||||||
AI_TOOL_CENTER_MCP_KEY=
|
AI_TOOL_CENTER_MCP_KEY=
|
||||||
|
|
||||||
# 每天北京时间 10:00 自动执行采集计划。
|
# 每天北京时间 09:00 自动执行采集计划。
|
||||||
ENABLE_SCHEDULER=true
|
ENABLE_SCHEDULER=true
|
||||||
SEED_DEMO_DATA=false
|
SEED_DEMO_DATA=false
|
||||||
HTTP_PORT=80
|
HTTP_PORT=80
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import {
|
|||||||
} from "../lib/date-utils";
|
} from "../lib/date-utils";
|
||||||
import type { AuthUser } from "../lib/user-auth";
|
import type { AuthUser } from "../lib/user-auth";
|
||||||
import { parseResultScreenshotKeys } from "../lib/result-screenshots";
|
import { parseResultScreenshotKeys } from "../lib/result-screenshots";
|
||||||
|
import {
|
||||||
|
sortWithNullsLast,
|
||||||
|
type SortDirection,
|
||||||
|
} from "../lib/sort-utils";
|
||||||
import UsersPage from "./users-page";
|
import UsersPage from "./users-page";
|
||||||
|
|
||||||
type Partner = {
|
type Partner = {
|
||||||
@@ -120,6 +124,14 @@ type Distribution = {
|
|||||||
due_at: string;
|
due_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RecoverySortKey =
|
||||||
|
| "publish_time"
|
||||||
|
| "likes"
|
||||||
|
| "collects"
|
||||||
|
| "comments"
|
||||||
|
| "total"
|
||||||
|
| "collection_updated_at";
|
||||||
|
|
||||||
type DashboardData = {
|
type DashboardData = {
|
||||||
partners: Partner[];
|
partners: Partner[];
|
||||||
tasks: Task[];
|
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) {
|
function hasCreatorMetrics(distribution: Distribution) {
|
||||||
return distribution.exposure !== null && distribution.views !== null;
|
return distribution.exposure !== null && distribution.views !== null;
|
||||||
}
|
}
|
||||||
@@ -617,7 +691,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
startDate,
|
startDate,
|
||||||
days,
|
days,
|
||||||
},
|
},
|
||||||
`采集任务已创建,将在所选日期10:00自动执行`,
|
`采集任务已创建,将在所选日期09:00自动执行`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -893,6 +967,17 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
<DistributionPage
|
<DistributionPage
|
||||||
tasks={data.tasks}
|
tasks={data.tasks}
|
||||||
distributions={data.distributions}
|
distributions={data.distributions}
|
||||||
|
canRelease={isManager}
|
||||||
|
working={working}
|
||||||
|
onRelease={async (distribution) =>
|
||||||
|
runAction(
|
||||||
|
{
|
||||||
|
action: "release_distribution",
|
||||||
|
distributionId: distribution.id,
|
||||||
|
},
|
||||||
|
`“${distribution.content_title}”已释放,可重新领取`,
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeNav === "resources" && (
|
{activeNav === "resources" && (
|
||||||
@@ -1247,7 +1332,7 @@ function OverviewPage({
|
|||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">今天最需要推进</p>
|
<p className="eyebrow">今天最需要推进</p>
|
||||||
<h3>把已发布笔记的数据收回来</h3>
|
<h3>把已发布笔记的数据收回来</h3>
|
||||||
<p>按任务选择采集日期,系统在当天10:00更新点赞、收藏、评论和总互动。</p>
|
<p>按任务选择采集日期,系统在当天09:00更新点赞、收藏、评论和总互动。</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="primary-button soft" onClick={() => onNavigate("recovery")}>进入数据回收</button>
|
<button className="primary-button soft" onClick={() => onNavigate("recovery")}>进入数据回收</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1489,11 +1574,18 @@ function TaskDetailHeader({
|
|||||||
function DistributionPage({
|
function DistributionPage({
|
||||||
tasks,
|
tasks,
|
||||||
distributions,
|
distributions,
|
||||||
|
canRelease,
|
||||||
|
working,
|
||||||
|
onRelease,
|
||||||
}: {
|
}: {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
distributions: Distribution[];
|
distributions: Distribution[];
|
||||||
|
canRelease: boolean;
|
||||||
|
working: boolean;
|
||||||
|
onRelease: (distribution: Distribution) => Promise<boolean>;
|
||||||
}) {
|
}) {
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||||
|
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
|
||||||
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
|
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
|
||||||
if (!selectedTask) {
|
if (!selectedTask) {
|
||||||
return (
|
return (
|
||||||
@@ -1540,9 +1632,70 @@ function DistributionPage({
|
|||||||
{isScreenshotTask ? (
|
{isScreenshotTask ? (
|
||||||
<ScreenshotTaskTable distributions={taskDistributions} />
|
<ScreenshotTaskTable distributions={taskDistributions} />
|
||||||
) : (
|
) : (
|
||||||
<DistributionTable distributions={taskDistributions} />
|
<DistributionTable
|
||||||
|
distributions={taskDistributions}
|
||||||
|
onRelease={canRelease ? setReleaseTarget : undefined}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1550,9 +1703,11 @@ function DistributionPage({
|
|||||||
function DistributionTable({
|
function DistributionTable({
|
||||||
distributions,
|
distributions,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
onRelease,
|
||||||
}: {
|
}: {
|
||||||
distributions: Distribution[];
|
distributions: Distribution[];
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
onRelease?: (distribution: Distribution) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
@@ -1565,6 +1720,7 @@ function DistributionTable({
|
|||||||
<th>发布时间</th>
|
<th>发布时间</th>
|
||||||
<th>数据状态</th>
|
<th>数据状态</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
|
{onRelease && <th>操作</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -1598,6 +1754,21 @@ function DistributionTable({
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td><span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span></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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -2090,9 +2261,9 @@ function ResourcesPage({
|
|||||||
<button type="button" onClick={closeImport} aria-label="关闭">×</button>
|
<button type="button" onClick={closeImport} aria-label="关闭">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="import-guide-strip">
|
<div className="import-guide-strip">
|
||||||
<span>1</span><p>填写账号主页与合作来源</p>
|
<span>1</span><p>账号链接必填,其余资料选填</p>
|
||||||
<i />
|
<i />
|
||||||
<span>2</span><p>系统解析公开账号资料</p>
|
<span>2</span><p>系统只补全空缺的公开资料</p>
|
||||||
<i />
|
<i />
|
||||||
<span>3</span><p>确认后写入资源库</p>
|
<span>3</span><p>确认后写入资源库</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2111,7 +2282,7 @@ function ResourcesPage({
|
|||||||
</label>
|
</label>
|
||||||
{!importPreview && (
|
{!importPreview && (
|
||||||
<div className="import-template-note">
|
<div className="import-template-note">
|
||||||
<div><strong>还没有模板?</strong><span>只需填写小红书账号主页;合作来源可选填,账号名称、小红书号、IP属地和粉丝数会自动解析。</span></div>
|
<div><strong>还没有模板?</strong><span>只有账号链接必填;已有昵称、账号ID、IP属地、粉丝数和合作来源可直接填写,系统只补全空缺字段。</span></div>
|
||||||
<a className="ghost-button" download href="/KOC资源导入模板.xlsx">下载模板</a>
|
<a className="ghost-button" download href="/KOC资源导入模板.xlsx">下载模板</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2233,6 +2404,10 @@ function RecoveryPage({
|
|||||||
exportingTaskId: string | null;
|
exportingTaskId: string | null;
|
||||||
}) {
|
}) {
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(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);
|
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
|
||||||
if (!selectedTask) {
|
if (!selectedTask) {
|
||||||
return (
|
return (
|
||||||
@@ -2295,6 +2470,23 @@ function RecoveryPage({
|
|||||||
const failedCollectionCount = taskDistributions.filter(
|
const failedCollectionCount = taskDistributions.filter(
|
||||||
(item) => item.collection_status === "failed",
|
(item) => item.collection_status === "failed",
|
||||||
).length;
|
).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 (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<TaskDetailHeader
|
<TaskDetailHeader
|
||||||
@@ -2341,19 +2533,55 @@ function RecoveryPage({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>内容 / 发布账号</th>
|
<th>内容 / 发布账号</th>
|
||||||
<th>发布时间</th>
|
<SortableRecoveryHeader
|
||||||
<th>点赞</th>
|
label="发布时间"
|
||||||
<th>收藏</th>
|
column="publish_time"
|
||||||
<th>评论</th>
|
activeColumn={recoverySort.column}
|
||||||
<th>总互动</th>
|
direction={recoverySort.direction}
|
||||||
<th>数据更新时间</th>
|
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>
|
<th>创作者截图</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{taskDistributions.map((item) => {
|
{sortedTaskDistributions.map((item) => {
|
||||||
const metrics = latestPublicMetrics(item);
|
const metrics = latestPublicMetrics(item);
|
||||||
const noteUrl = xhsPublishUrl(item.publish_url);
|
const noteUrl = xhsPublishUrl(item.publish_url);
|
||||||
const hasMetrics = metrics.likes !== null;
|
const hasMetrics = metrics.likes !== null;
|
||||||
@@ -2512,7 +2740,7 @@ function CollectionScheduleCard({
|
|||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">自动采集计划</p>
|
<p className="eyebrow">自动采集计划</p>
|
||||||
<h2>选择开始日期与采集日</h2>
|
<h2>选择开始日期与采集日</h2>
|
||||||
<span>勾选第1天到第7天,所选日期均在北京时间10:00自动采集。</span>
|
<span>勾选第1天到第7天,所选日期均在北京时间09:00自动采集。</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="schedule-save-area">
|
<div className="schedule-save-area">
|
||||||
<span>已选择 {days.length} 天</span>
|
<span>已选择 {days.length} 天</span>
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ import {
|
|||||||
createDistributionTask,
|
createDistributionTask,
|
||||||
createScreenshotTask,
|
createScreenshotTask,
|
||||||
} from "../../../lib/task-service";
|
} from "../../../lib/task-service";
|
||||||
|
import {
|
||||||
|
DistributionReleaseError,
|
||||||
|
releaseUnfinishedDistribution,
|
||||||
|
} from "../../../lib/distribution-release-service";
|
||||||
|
import { isManagerRequest } from "../../../lib/user-auth";
|
||||||
|
|
||||||
type ActionBody = {
|
type ActionBody = {
|
||||||
action?: string;
|
action?: string;
|
||||||
@@ -135,6 +140,12 @@ export async function POST(request: Request) {
|
|||||||
.bind(available.results.length, partnerId),
|
.bind(available.results.length, partnerId),
|
||||||
);
|
);
|
||||||
await db.batch(statements);
|
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") {
|
} else if (body.action === "save_collection_schedule") {
|
||||||
const taskId = String(body.taskId ?? "").trim();
|
const taskId = String(body.taskId ?? "").trim();
|
||||||
const startDate = String(body.startDate ?? "").trim();
|
const startDate = String(body.startDate ?? "").trim();
|
||||||
@@ -198,7 +209,7 @@ export async function POST(request: Request) {
|
|||||||
AND publish_url != ''`,
|
AND publish_url != ''`,
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
`已安排${days.length}个采集日,每日10:00执行`,
|
`已安排${days.length}个采集日,每日09:00执行`,
|
||||||
taskId,
|
taskId,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -362,7 +373,13 @@ export async function POST(request: Request) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: error instanceof Error ? error.message : "操作失败" },
|
{ error: error instanceof Error ? error.message : "操作失败" },
|
||||||
{ status: error instanceof FeishuSourceError ? error.status : 500 },
|
{
|
||||||
|
status:
|
||||||
|
error instanceof FeishuSourceError ||
|
||||||
|
error instanceof DistributionReleaseError
|
||||||
|
? error.status
|
||||||
|
: 500,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
parseResourceImportFile,
|
parseResourceImportFile,
|
||||||
RESOURCE_IMPORT_MAX_BYTES,
|
RESOURCE_IMPORT_MAX_BYTES,
|
||||||
resourcePlatformUid,
|
resourcePlatformUid,
|
||||||
|
resourceImportMissingFields,
|
||||||
type ResourceImportRow,
|
type ResourceImportRow,
|
||||||
} from "../../../lib/resource-import";
|
} from "../../../lib/resource-import";
|
||||||
|
|
||||||
@@ -86,20 +87,43 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
const existing = existingByProfile.get(identityKey(row.platform, row.profileUrl));
|
const existing = existingByProfile.get(identityKey(row.platform, row.profileUrl));
|
||||||
if (existing?.nickname && existing.public_account_id) {
|
const existingIpLocation =
|
||||||
return {
|
existing?.ip_location && existing.ip_location !== "待识别"
|
||||||
|
? existing.ip_location
|
||||||
|
: "";
|
||||||
|
const baseline: ResourceImportRow = {
|
||||||
...row,
|
...row,
|
||||||
nickname: existing.nickname,
|
nickname: row.nickname || existing?.nickname || "",
|
||||||
publicAccountId: existing.public_account_id,
|
publicAccountId: row.publicAccountId || existing?.public_account_id || "",
|
||||||
ipLocation: existing.ip_location || "待识别",
|
ipLocation: row.ipLocation || existingIpLocation,
|
||||||
followers: Number(existing.followers || 0),
|
followers: row.followersResolved
|
||||||
|
? row.followers
|
||||||
|
: Number(existing?.followers || 0),
|
||||||
|
followersResolved:
|
||||||
|
row.followersResolved || Number(existing?.followers || 0) > 0,
|
||||||
};
|
};
|
||||||
|
if (resourceImportMissingFields(baseline).length === 0) {
|
||||||
|
return baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
let details = await resolveXhsProfileDetailsFromMcp(row.profileUrl, mcpConfig)
|
let details: {
|
||||||
.catch(() => resolveXhsPublicAccountDetails(row.profileUrl));
|
nickname: string | null;
|
||||||
if (!details.nickname || !details.redId || details.followers === null) {
|
redId: string | null;
|
||||||
const publicDetails = await resolveXhsPublicAccountDetails(row.profileUrl);
|
followers: number | null;
|
||||||
|
ipLocation: string | null;
|
||||||
|
} = await resolveXhsProfileDetailsFromMcp(row.profileUrl, mcpConfig).catch(
|
||||||
|
() => ({ nickname: null, redId: null, followers: null, ipLocation: null }),
|
||||||
|
);
|
||||||
|
const mcpResult = {
|
||||||
|
nickname: baseline.nickname || details.nickname?.trim() || "",
|
||||||
|
publicAccountId: baseline.publicAccountId || details.redId?.trim() || "",
|
||||||
|
ipLocation: baseline.ipLocation || details.ipLocation?.trim() || "",
|
||||||
|
followersResolved: baseline.followersResolved || details.followers !== null,
|
||||||
|
};
|
||||||
|
if (resourceImportMissingFields(mcpResult).length > 0) {
|
||||||
|
const publicDetails = await resolveXhsPublicAccountDetails(row.profileUrl).catch(
|
||||||
|
() => ({ nickname: null, redId: null, followers: null, ipLocation: null }),
|
||||||
|
);
|
||||||
details = {
|
details = {
|
||||||
nickname: details.nickname || publicDetails.nickname,
|
nickname: details.nickname || publicDetails.nickname,
|
||||||
redId: details.redId || publicDetails.redId,
|
redId: details.redId || publicDetails.redId,
|
||||||
@@ -107,18 +131,16 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
|
|||||||
ipLocation: details.ipLocation || publicDetails.ipLocation,
|
ipLocation: details.ipLocation || publicDetails.ipLocation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const errors = [...row.errors];
|
|
||||||
const nickname = details.nickname?.trim() || existing?.nickname || "";
|
|
||||||
const publicAccountId = details.redId?.trim() || existing?.public_account_id || "";
|
|
||||||
if (!nickname) errors.push("无法识别账号名称,请确认主页可公开访问");
|
|
||||||
if (!publicAccountId) errors.push("无法识别小红书号,请确认主页可公开访问");
|
|
||||||
return {
|
return {
|
||||||
...row,
|
...baseline,
|
||||||
nickname,
|
nickname: baseline.nickname || details.nickname?.trim() || "待识别账号",
|
||||||
publicAccountId,
|
publicAccountId: baseline.publicAccountId || details.redId?.trim() || "",
|
||||||
ipLocation: details.ipLocation?.trim() || existing?.ip_location || "待识别",
|
ipLocation: baseline.ipLocation || details.ipLocation?.trim() || "待识别",
|
||||||
followers: details.followers ?? Number(existing?.followers || 0),
|
followers: baseline.followersResolved
|
||||||
errors,
|
? baseline.followers
|
||||||
|
: (details.followers ?? 0),
|
||||||
|
followersResolved:
|
||||||
|
baseline.followersResolved || details.followers !== null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -251,7 +273,7 @@ export async function POST(request: Request) {
|
|||||||
profile_url = CASE WHEN ? != '' THEN ? ELSE profile_url END,
|
profile_url = CASE WHEN ? != '' THEN ? ELSE profile_url END,
|
||||||
ip_location = CASE
|
ip_location = CASE
|
||||||
WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END,
|
WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END,
|
||||||
followers = CASE WHEN ? > 0 OR followers = 0 THEN ? ELSE followers END,
|
followers = CASE WHEN ? = 1 THEN ? ELSE followers END,
|
||||||
cooperation_source = ?,
|
cooperation_source = ?,
|
||||||
last_seen_at = CURRENT_TIMESTAMP
|
last_seen_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
@@ -265,7 +287,7 @@ export async function POST(request: Request) {
|
|||||||
row.ipLocation,
|
row.ipLocation,
|
||||||
row.ipLocation,
|
row.ipLocation,
|
||||||
row.ipLocation,
|
row.ipLocation,
|
||||||
row.followers,
|
row.followersResolved ? 1 : 0,
|
||||||
row.followers,
|
row.followers,
|
||||||
row.cooperationSource,
|
row.cooperationSource,
|
||||||
row.accountId,
|
row.accountId,
|
||||||
|
|||||||
110
app/globals.css
110
app/globals.css
@@ -2264,6 +2264,41 @@ a {
|
|||||||
white-space: nowrap;
|
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 {
|
.recovery-content {
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
}
|
}
|
||||||
@@ -3572,6 +3607,81 @@ label small {
|
|||||||
color: #b04b40;
|
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 {
|
.user-delete-warning {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 13px 14px;
|
padding: 13px 14px;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
| 组件 | 容器 | 作用 | 持久化 |
|
| 组件 | 容器 | 作用 | 持久化 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Nginx | `nginx` | 公网入口、反向代理、托管 KOC 领取页 | 配置随镜像 |
|
| Nginx | `nginx` | 公网入口、反向代理、托管 KOC 领取页 | 配置随镜像 |
|
||||||
| KOC 服务 | `app` | Next.js 后台、API、MCP、每天 10:00 自动采集 | 上传目录挂载卷 |
|
| KOC 服务 | `app` | Next.js 后台、API、MCP、每天 09:00 自动采集 | 上传目录挂载卷 |
|
||||||
| MySQL 8 | `mysql` | 任务、笔记、领取、回填、账号、采集和用户数据 | MySQL 数据卷 |
|
| MySQL 8 | `mysql` | 任务、笔记、领取、回填、账号、采集和用户数据 | MySQL 数据卷 |
|
||||||
|
|
||||||
访问路径:
|
访问路径:
|
||||||
@@ -53,7 +53,7 @@ cp .env.self-hosted.example .env.self-hosted
|
|||||||
| `KOC_MCP_API_KEY` | Agent 调用 KOC LOOP MCP 的独立 Bearer 密钥 |
|
| `KOC_MCP_API_KEY` | Agent 调用 KOC LOOP MCP 的独立 Bearer 密钥 |
|
||||||
| `FEISHU_APP_ID` / `FEISHU_APP_SECRET` | 读取飞书内容表和配图 |
|
| `FEISHU_APP_ID` / `FEISHU_APP_SECRET` | 读取飞书内容表和配图 |
|
||||||
| `AI_TOOL_CENTER_MCP_URL` / `AI_TOOL_CENTER_MCP_KEY` | 小红书公开数据采集服务 |
|
| `AI_TOOL_CENTER_MCP_URL` / `AI_TOOL_CENTER_MCP_KEY` | 小红书公开数据采集服务 |
|
||||||
| `ENABLE_SCHEDULER` | 是否启用每天 10:00 自动采集,生产保持 `true` |
|
| `ENABLE_SCHEDULER` | 是否启用每天 09:00 自动采集,生产保持 `true` |
|
||||||
|
|
||||||
密钥必须由密码管理器生成,禁止提交到 Git、聊天、部署日志或 URL。三个业务密钥 `ADMIN_INTERNAL_TOKEN`、`KOC_MCP_API_KEY`、`AI_TOOL_CENTER_MCP_KEY` 不得复用。
|
密钥必须由密码管理器生成,禁止提交到 Git、聊天、部署日志或 URL。三个业务密钥 `ADMIN_INTERNAL_TOKEN`、`KOC_MCP_API_KEY`、`AI_TOOL_CENTER_MCP_KEY` 不得复用。
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ docker compose --env-file .env.self-hosted \
|
|||||||
| `/api/health` 返回 503 | 查看 MySQL 容器健康状态与应用数据库变量 |
|
| `/api/health` 返回 503 | 查看 MySQL 容器健康状态与应用数据库变量 |
|
||||||
| 登录页可开但登录失败 | 确认迁移完成、超级管理员变量仅用于初始化 |
|
| 登录页可开但登录失败 | 确认迁移完成、超级管理员变量仅用于初始化 |
|
||||||
| 配图或截图 404 | 检查 `upload_data` 卷和 `UPLOAD_DIR=/data/koc/uploads` |
|
| 配图或截图 404 | 检查 `upload_data` 卷和 `UPLOAD_DIR=/data/koc/uploads` |
|
||||||
| 10:00 未自动采集 | 检查 `ENABLE_SCHEDULER=true`、服务器日志和采集 MCP 网络 |
|
| 09:00 未自动采集 | 检查 `ENABLE_SCHEDULER=true`、服务器日志和采集 MCP 网络 |
|
||||||
| MCP 401 | 检查请求头是否为 `Authorization: Bearer <KOC_MCP_API_KEY>` |
|
| MCP 401 | 检查请求头是否为 `Authorization: Bearer <KOC_MCP_API_KEY>` |
|
||||||
| 飞书读取失败 | 检查应用权限、文档授权和服务器到飞书 OpenAPI 的网络 |
|
| 飞书读取失败 | 检查应用权限、文档授权和服务器到飞书 OpenAPI 的网络 |
|
||||||
|
|
||||||
|
|||||||
@@ -237,10 +237,10 @@ npm run db:generate
|
|||||||
运营后台 Worker 配置了 Cloudflare Cron:
|
运营后台 Worker 配置了 Cloudflare Cron:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
0 2 * * *
|
0 1 * * *
|
||||||
```
|
```
|
||||||
|
|
||||||
Cloudflare Cron 使用 UTC,`02:00 UTC` 对应北京时间每天 `10:00`。定时任务会:
|
Cloudflare Cron 使用 UTC,`01:00 UTC` 对应北京时间每天 `09:00`。定时任务会:
|
||||||
|
|
||||||
1. 确认数据库结构;
|
1. 确认数据库结构;
|
||||||
2. 执行当天已创建的笔记数据采集任务;
|
2. 执行当天已创建的笔记数据采集任务;
|
||||||
|
|||||||
@@ -1050,6 +1050,11 @@ footer {
|
|||||||
padding: 34px 38px;
|
padding: 34px 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-note-summary,
|
||||||
|
.mobile-note-collapse-trigger {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.note-document-meta {
|
.note-document-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1810,6 +1815,64 @@ footer {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note-document.mobile-collapsed {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-document.mobile-collapsed .note-document-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-document.mobile-collapsed .mobile-note-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-note-summary > div {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-note-summary span {
|
||||||
|
color: var(--green);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-note-summary strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-note-summary small {
|
||||||
|
color: #899590;
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-note-summary button,
|
||||||
|
.mobile-note-collapse-trigger {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #cfe1d9;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: var(--green-deep);
|
||||||
|
background: #f2f8f5;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-note-collapse-trigger {
|
||||||
|
display: block;
|
||||||
|
margin: 14px 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.note-document h1 {
|
.note-document h1 {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { zipSync } from "fflate";
|
import { zipSync } from "fflate";
|
||||||
import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
formatShanghaiDate as formatDate,
|
formatShanghaiDate as formatDate,
|
||||||
parseStoredDate,
|
parseStoredDate,
|
||||||
@@ -96,6 +96,13 @@ function partnerApi(path: "/api/partner" | "/api/partner-upload") {
|
|||||||
return `${resolveAdminOrigin()}${path}`;
|
return `${resolveAdminOrigin()}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMobilePortalViewport() {
|
||||||
|
return (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(max-width: 620px)").matches
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function statusLabel(item: Assignment, taskType = "content_publish") {
|
function statusLabel(item: Assignment, taskType = "content_publish") {
|
||||||
if (taskType === "screenshot_collect") {
|
if (taskType === "screenshot_collect") {
|
||||||
return item.result_submitted_at ? "已提交" : "待提交";
|
return item.result_submitted_at ? "已提交" : "待提交";
|
||||||
@@ -280,6 +287,8 @@ export default function Home() {
|
|||||||
src: string;
|
src: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [noteContentCollapsed, setNoteContentCollapsed] = useState(false);
|
||||||
|
const submitCardRef = useRef<HTMLFormElement | null>(null);
|
||||||
const publishScreenshotPreview = useFilePreview(screenshot);
|
const publishScreenshotPreview = useFilePreview(screenshot);
|
||||||
const creatorScreenshotPreview = useFilePreview(creatorScreenshot);
|
const creatorScreenshotPreview = useFilePreview(creatorScreenshot);
|
||||||
const taskResultPreviews = useMemo(
|
const taskResultPreviews = useMemo(
|
||||||
@@ -384,6 +393,9 @@ export default function Home() {
|
|||||||
selected.exposure === null ? "" : String(selected.exposure),
|
selected.exposure === null ? "" : String(selected.exposure),
|
||||||
);
|
);
|
||||||
setCreatorViews(selected.views === null ? "" : String(selected.views));
|
setCreatorViews(selected.views === null ? "" : String(selected.views));
|
||||||
|
setNoteContentCollapsed(
|
||||||
|
Boolean(selected.publish_url) && isMobilePortalViewport(),
|
||||||
|
);
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [selected]);
|
}, [selected]);
|
||||||
@@ -543,7 +555,9 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const delegationUrl = (shareToken: string) => {
|
const delegationUrl = (shareToken: string) => {
|
||||||
const url = new URL(window.location.origin);
|
const url = new URL(window.location.href);
|
||||||
|
url.search = "";
|
||||||
|
url.hash = "";
|
||||||
url.searchParams.set("share", shareToken);
|
url.searchParams.set("share", shareToken);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
};
|
};
|
||||||
@@ -792,6 +806,7 @@ export default function Home() {
|
|||||||
const submitNote = async (event: FormEvent) => {
|
const submitNote = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
|
const isFirstBackfill = !selected.publish_url;
|
||||||
try {
|
try {
|
||||||
setWorking(true);
|
setWorking(true);
|
||||||
if (screenshot) {
|
if (screenshot) {
|
||||||
@@ -816,6 +831,15 @@ export default function Home() {
|
|||||||
if (!response.ok) throw new Error(result.error || "回填失败");
|
if (!response.ok) throw new Error(result.error || "回填失败");
|
||||||
await loadTask(taskToken, claimToken, delegationToken);
|
await loadTask(taskToken, claimToken, delegationToken);
|
||||||
setScreenshot(null);
|
setScreenshot(null);
|
||||||
|
if (isFirstBackfill && isMobilePortalViewport()) {
|
||||||
|
setNoteContentCollapsed(true);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
submitCardRef.current?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
}, 120);
|
||||||
|
}
|
||||||
setToast("这篇笔记已回填,不会与其他笔记错配");
|
setToast("这篇笔记已回填,不会与其他笔记错配");
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setToast(reason instanceof Error ? reason.message : "回填失败");
|
setToast(reason instanceof Error ? reason.message : "回填失败");
|
||||||
@@ -1102,11 +1126,41 @@ export default function Home() {
|
|||||||
</header>
|
</header>
|
||||||
<button className="back-link" onClick={closeNote}>← 返回我的笔记</button>
|
<button className="back-link" onClick={closeNote}>← 返回我的笔记</button>
|
||||||
<div className="detail-grid">
|
<div className="detail-grid">
|
||||||
<article className="note-document">
|
<article
|
||||||
|
className={`note-document ${
|
||||||
|
noteContentCollapsed ? "mobile-collapsed" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mobile-note-summary">
|
||||||
|
<div>
|
||||||
|
<span>笔记内容已收起</span>
|
||||||
|
<strong>{selected.title}</strong>
|
||||||
|
<small>
|
||||||
|
{selected.images.length} 张配图 · 已完成首次回填
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={!noteContentCollapsed}
|
||||||
|
onClick={() => setNoteContentCollapsed(false)}
|
||||||
|
>
|
||||||
|
展开笔记内容
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="note-document-content">
|
||||||
<div className="note-document-meta">
|
<div className="note-document-meta">
|
||||||
<span>笔记 {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span>
|
<span>笔记 {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span>
|
||||||
<span>飞书源行 {selected.source_row ?? "—"}</span>
|
<span>飞书源行 {selected.source_row ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{selected.publish_url && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mobile-note-collapse-trigger"
|
||||||
|
onClick={() => setNoteContentCollapsed(true)}
|
||||||
|
>
|
||||||
|
收起笔记内容
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="note-title-row">
|
<div className="note-title-row">
|
||||||
<h1>{selected.title}</h1>
|
<h1>{selected.title}</h1>
|
||||||
<button
|
<button
|
||||||
@@ -1183,9 +1237,10 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<form className="submit-card" onSubmit={submitNote}>
|
<form ref={submitCardRef} className="submit-card" onSubmit={submitNote}>
|
||||||
<div className="submit-heading">
|
<div className="submit-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="micro">逐篇回填</p>
|
<p className="micro">逐篇回填</p>
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ test("builds the branded external task shell", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("keeps claiming minimal and backfill one-to-one", async () => {
|
test("keeps claiming minimal and backfill one-to-one", async () => {
|
||||||
const [page, layout, packageJson, nextConfig] =
|
const [page, layout, packageJson, nextConfig, styles] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../next.config.ts", import.meta.url), "utf8"),
|
readFile(new URL("../next.config.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.match(page, /微信号\s*\/\s*手机号/);
|
assert.match(page, /微信号\s*\/\s*手机号/);
|
||||||
@@ -53,6 +54,13 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
|
|||||||
assert.match(page, /action:\s*"recover"/);
|
assert.match(page, /action:\s*"recover"/);
|
||||||
assert.match(page, /同一任务多次领取会分批展示/);
|
assert.match(page, /同一任务多次领取会分批展示/);
|
||||||
assert.match(page, /这篇笔记已回填,不会与其他笔记错配/);
|
assert.match(page, /这篇笔记已回填,不会与其他笔记错配/);
|
||||||
|
assert.match(page, /笔记内容已收起/);
|
||||||
|
assert.match(page, /展开笔记内容/);
|
||||||
|
assert.match(page, /收起笔记内容/);
|
||||||
|
assert.match(page, /isFirstBackfill/);
|
||||||
|
assert.match(page, /scrollIntoView/);
|
||||||
|
assert.match(page, /matchMedia\("\(max-width: 620px\)"\)/);
|
||||||
|
assert.match(styles, /\.note-document\.mobile-collapsed/);
|
||||||
assert.doesNotMatch(page, /批量回填/);
|
assert.doesNotMatch(page, /批量回填/);
|
||||||
assert.doesNotMatch(page, /复制标题和正文/);
|
assert.doesNotMatch(page, /复制标题和正文/);
|
||||||
assert.match(page, /CONFIGURED_ADMIN_ORIGIN/);
|
assert.match(page, /CONFIGURED_ADMIN_ORIGIN/);
|
||||||
@@ -129,6 +137,9 @@ test("creates anonymous delegation bundles and reuses one-to-one backfill", asyn
|
|||||||
assert.match(page, /action:\s*"revoke_delegation"/);
|
assert.match(page, /action:\s*"revoke_delegation"/);
|
||||||
assert.match(page, /合作社转派 · 无需登录/);
|
assert.match(page, /合作社转派 · 无需登录/);
|
||||||
assert.match(page, /"X-KOC-Delegation"/);
|
assert.match(page, /"X-KOC-Delegation"/);
|
||||||
|
assert.match(page, /const url = new URL\(window\.location\.href\)/);
|
||||||
|
assert.match(page, /url\.search = ""/);
|
||||||
|
assert.doesNotMatch(page, /const url = new URL\(window\.location\.origin\)/);
|
||||||
assert.match(page, /url\.searchParams\.set\("share", shareToken\)/);
|
assert.match(page, /url\.searchParams\.set\("share", shareToken\)/);
|
||||||
assert.match(page, /请保存当前分享链接/);
|
assert.match(page, /请保存当前分享链接/);
|
||||||
assert.doesNotMatch(page, /底层KOC手机号|底层KOC企微|底层KOC微信/);
|
assert.doesNotMatch(page, /底层KOC手机号|底层KOC企微|底层KOC微信/);
|
||||||
|
|||||||
@@ -60,13 +60,23 @@ function shanghaiHourFromTimestamp(timestamp: number) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCollectionScheduleDue(
|
||||||
|
scheduledDate: string,
|
||||||
|
timestamp: number,
|
||||||
|
) {
|
||||||
|
const currentDate = shanghaiDateFromTimestamp(timestamp);
|
||||||
|
const currentHour = shanghaiHourFromTimestamp(timestamp);
|
||||||
|
return (
|
||||||
|
scheduledDate < currentDate ||
|
||||||
|
(scheduledDate === currentDate && currentHour >= 9)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function dueSchedules(
|
function dueSchedules(
|
||||||
startDate: string,
|
startDate: string,
|
||||||
days: number[],
|
days: number[],
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
) {
|
) {
|
||||||
const currentDate = shanghaiDateFromTimestamp(timestamp);
|
|
||||||
const currentHour = shanghaiHourFromTimestamp(timestamp);
|
|
||||||
return days
|
return days
|
||||||
.map((scheduleDay) => ({
|
.map((scheduleDay) => ({
|
||||||
scheduleDay,
|
scheduleDay,
|
||||||
@@ -78,8 +88,7 @@ function dueSchedules(
|
|||||||
): value is { scheduleDay: number; scheduledDate: string } =>
|
): value is { scheduleDay: number; scheduledDate: string } =>
|
||||||
Boolean(
|
Boolean(
|
||||||
value.scheduledDate &&
|
value.scheduledDate &&
|
||||||
(value.scheduledDate < currentDate ||
|
isCollectionScheduleDue(value.scheduledDate, timestamp),
|
||||||
(value.scheduledDate === currentDate && currentHour >= 10)),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -131,8 +140,8 @@ export async function createCollectionRunTasks(
|
|||||||
distribution.id,
|
distribution.id,
|
||||||
scheduledDate,
|
scheduledDate,
|
||||||
scheduleDay,
|
scheduleDay,
|
||||||
`${scheduledDate}T10:00:00+08:00`,
|
`${scheduledDate}T09:00:00+08:00`,
|
||||||
`等待第${scheduleDay}天 10:00自动采集`,
|
`等待第${scheduleDay}天 09:00自动采集`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -162,7 +171,7 @@ export async function collectDistributionMetrics(
|
|||||||
if (!current) throw new Error("分发记录不存在");
|
if (!current) throw new Error("分发记录不存在");
|
||||||
if (!current.publish_url) throw new Error("笔记尚未回填发布链接");
|
if (!current.publish_url) throw new Error("笔记尚未回填发布链接");
|
||||||
|
|
||||||
const scheduledAt = `${scheduledDate}T10:00:00+08:00`;
|
const scheduledAt = `${scheduledDate}T09:00:00+08:00`;
|
||||||
const runId = uid("run");
|
const runId = uid("run");
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -231,7 +240,7 @@ export async function collectDistributionMetrics(
|
|||||||
const dayWeight = scheduleDay ?? 1;
|
const dayWeight = scheduleDay ?? 1;
|
||||||
const successDescription =
|
const successDescription =
|
||||||
source === "automatic"
|
source === "automatic"
|
||||||
? `成功 · 第${dayWeight}天 10:00自动采集`
|
? `成功 · 第${dayWeight}天 09:00自动采集`
|
||||||
: source === "catchup"
|
: source === "catchup"
|
||||||
? `成功 · 第${dayWeight}天自动追采`
|
? `成功 · 第${dayWeight}天自动追采`
|
||||||
: "成功 · 手动采集";
|
: "成功 · 手动采集";
|
||||||
|
|||||||
@@ -155,6 +155,23 @@ export class DatabaseClient {
|
|||||||
connection.release();
|
connection.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async transaction<T>(operation: (database: DatabaseClient) => Promise<T>) {
|
||||||
|
const pool = getPool();
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const transaction = new DatabaseClient(connection);
|
||||||
|
const result = await operation(transaction);
|
||||||
|
await connection.commit();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
153
lib/distribution-release-service.ts
Normal file
153
lib/distribution-release-service.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import type { DatabaseClient } from "./database";
|
||||||
|
|
||||||
|
type ReleasableDistribution = {
|
||||||
|
id: string;
|
||||||
|
task_id: string;
|
||||||
|
task_type: string;
|
||||||
|
content_id: string;
|
||||||
|
content_title: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name: string;
|
||||||
|
claim_id: string | null;
|
||||||
|
delegation_bundle_id: string | null;
|
||||||
|
publish_url: string | null;
|
||||||
|
result_submitted_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DistributionReleaseError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = "DistributionReleaseError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function distributionReleaseBlockReason(input: {
|
||||||
|
publishUrl?: string | null;
|
||||||
|
resultSubmittedAt?: string | null;
|
||||||
|
taskType?: string | null;
|
||||||
|
}) {
|
||||||
|
if (input.publishUrl) return "已回填发布链接的笔记不能释放";
|
||||||
|
if (input.resultSubmittedAt) return "已提交结果截图的任务不能释放";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseUnfinishedDistribution(
|
||||||
|
database: DatabaseClient,
|
||||||
|
distributionId: string,
|
||||||
|
) {
|
||||||
|
if (!distributionId) {
|
||||||
|
throw new DistributionReleaseError("请选择需要释放的领取记录", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return database.transaction(async (db) => {
|
||||||
|
const distribution = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT
|
||||||
|
d.id,
|
||||||
|
d.task_id,
|
||||||
|
t.task_type,
|
||||||
|
d.content_id,
|
||||||
|
c.title AS content_title,
|
||||||
|
d.partner_id,
|
||||||
|
p.name AS partner_name,
|
||||||
|
d.claim_id,
|
||||||
|
d.delegation_bundle_id,
|
||||||
|
d.publish_url,
|
||||||
|
d.result_submitted_at
|
||||||
|
FROM distributions d
|
||||||
|
JOIN tasks t ON t.id = d.task_id
|
||||||
|
JOIN contents c ON c.id = d.content_id
|
||||||
|
JOIN partners p ON p.id = d.partner_id
|
||||||
|
WHERE d.id = ?
|
||||||
|
FOR UPDATE`,
|
||||||
|
)
|
||||||
|
.bind(distributionId)
|
||||||
|
.first<ReleasableDistribution>();
|
||||||
|
|
||||||
|
if (!distribution) {
|
||||||
|
throw new DistributionReleaseError("领取记录不存在或已被释放", 404);
|
||||||
|
}
|
||||||
|
const blocked = distributionReleaseBlockReason({
|
||||||
|
publishUrl: distribution.publish_url,
|
||||||
|
resultSubmittedAt: distribution.result_submitted_at,
|
||||||
|
taskType: distribution.task_type,
|
||||||
|
});
|
||||||
|
if (blocked) throw new DistributionReleaseError(blocked, 409);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare("DELETE FROM collection_runs WHERE distribution_id = ?")
|
||||||
|
.bind(distribution.id)
|
||||||
|
.run();
|
||||||
|
await db
|
||||||
|
.prepare("DELETE FROM distributions WHERE id = ?")
|
||||||
|
.bind(distribution.id)
|
||||||
|
.run();
|
||||||
|
await db
|
||||||
|
.prepare("UPDATE contents SET status = 'available' WHERE id = ?")
|
||||||
|
.bind(distribution.content_id)
|
||||||
|
.run();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE tasks
|
||||||
|
SET claimed_quantity = GREATEST(claimed_quantity - 1, 0)
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(distribution.task_id)
|
||||||
|
.run();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE partners
|
||||||
|
SET claimed_total = GREATEST(claimed_total - 1, 0)
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(distribution.partner_id)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
if (distribution.delegation_bundle_id) {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE delegation_bundles
|
||||||
|
SET quantity = GREATEST(quantity - 1, 0),
|
||||||
|
status = CASE WHEN quantity <= 1 THEN 'revoked' ELSE status END,
|
||||||
|
revoked_at = CASE WHEN quantity <= 1 THEN CURRENT_TIMESTAMP ELSE revoked_at END,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(distribution.delegation_bundle_id)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distribution.claim_id) {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE claims
|
||||||
|
SET quantity = GREATEST(quantity - 1, 0)
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(distribution.claim_id)
|
||||||
|
.run();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`DELETE FROM claims
|
||||||
|
WHERE id = ?
|
||||||
|
AND quantity <= 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM distributions WHERE claim_id = ?
|
||||||
|
)`,
|
||||||
|
)
|
||||||
|
.bind(distribution.claim_id, distribution.claim_id)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
distributionId: distribution.id,
|
||||||
|
taskId: distribution.task_id,
|
||||||
|
contentId: distribution.content_id,
|
||||||
|
contentTitle: distribution.content_title,
|
||||||
|
partnerName: distribution.partner_name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -284,7 +284,7 @@ export async function setCollectionPlan(
|
|||||||
collection_status_description = CASE WHEN latest_likes IS NULL THEN ? ELSE collection_status_description END,
|
collection_status_description = CASE WHEN latest_likes IS NULL THEN ? ELSE collection_status_description END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE task_id = ? AND COALESCE(publish_url, '') != ''`,
|
WHERE task_id = ? AND COALESCE(publish_url, '') != ''`,
|
||||||
).bind(`已安排${normalizedDays.length}个采集日,每日10:00执行`, taskId),
|
).bind(`已安排${normalizedDays.length}个采集日,每日09:00执行`, taskId),
|
||||||
]);
|
]);
|
||||||
const created = await createCollectionRunTasks(db, taskId, startDate, normalizedDays);
|
const created = await createCollectionRunTasks(db, taskId, startDate, normalizedDays);
|
||||||
const catchup = await runDueScheduledCollections(
|
const catchup = await runDueScheduledCollections(
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export function registerMcpOperationTools(server: McpServer, options: Options) {
|
|||||||
"collection_plan_set",
|
"collection_plan_set",
|
||||||
{
|
{
|
||||||
title: "设置自动采集计划",
|
title: "设置自动采集计划",
|
||||||
description: "为任务设置开始日期及第1至第7天的自动采集日,系统在北京时间10:00执行。",
|
description: "为任务设置开始日期及第1至第7天的自动采集日,系统在北京时间09:00执行。",
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
task_id: z.string().min(1),
|
task_id: z.string().min(1),
|
||||||
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("北京时间开始日期 YYYY-MM-DD"),
|
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("北京时间开始日期 YYYY-MM-DD"),
|
||||||
|
|||||||
@@ -11,12 +11,17 @@ export type ResourceImportRow = {
|
|||||||
profileUrl: string;
|
profileUrl: string;
|
||||||
ipLocation: string;
|
ipLocation: string;
|
||||||
followers: number;
|
followers: number;
|
||||||
|
followersResolved: boolean;
|
||||||
cooperationSource: string;
|
cooperationSource: string;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const HEADER_ALIASES = {
|
const HEADER_ALIASES = {
|
||||||
profileUrl: ["账号主页", "账号主页链接", "主页链接", "账号链接"],
|
profileUrl: ["账号链接", "账号主页", "账号主页链接", "主页链接"],
|
||||||
|
nickname: ["账号昵称", "账号名称", "昵称"],
|
||||||
|
publicAccountId: ["账号ID", "账号号", "小红书号", "抖音号"],
|
||||||
|
ipLocation: ["IP属地", "IP地址", "IP地区", "IP所在地"],
|
||||||
|
followers: ["粉丝数", "粉丝", "粉丝量"],
|
||||||
cooperationSource: ["合作来源", "资源来源", "历史合作来源"],
|
cooperationSource: ["合作来源", "资源来源", "历史合作来源"],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -120,7 +125,10 @@ function parseCsv(text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHeader(value: string) {
|
function normalizeHeader(value: string) {
|
||||||
return value.replace(/[\s_\-()()]/g, "").toLocaleLowerCase("zh-CN");
|
return value
|
||||||
|
.replace(/[\s_\-()()]/g, "")
|
||||||
|
.replace(/必填|选填/g, "")
|
||||||
|
.toLocaleLowerCase("zh-CN");
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalHeader(value: string): CanonicalHeader | null {
|
function canonicalHeader(value: string): CanonicalHeader | null {
|
||||||
@@ -186,6 +194,40 @@ function valueAt(row: string[], mapping: Map<CanonicalHeader, number>, key: Cano
|
|||||||
return index === undefined ? "" : String(row[index] ?? "").trim();
|
return index === undefined ? "" : String(row[index] ?? "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseResourceFollowers(value: string) {
|
||||||
|
const normalized = value.trim().replace(/[,,\s]/g, "").replace(/\+$/, "");
|
||||||
|
if (!normalized) return { value: 0, resolved: false, valid: true };
|
||||||
|
const match = normalized.match(/^(\d+(?:\.\d+)?)(万|w|W|千|k|K)?$/);
|
||||||
|
if (!match) return { value: 0, resolved: false, valid: false };
|
||||||
|
const multiplier =
|
||||||
|
match[2] === "万" || match[2]?.toLowerCase() === "w"
|
||||||
|
? 10_000
|
||||||
|
: match[2] === "千" || match[2]?.toLowerCase() === "k"
|
||||||
|
? 1_000
|
||||||
|
: 1;
|
||||||
|
return {
|
||||||
|
value: Math.round(Number(match[1]) * multiplier),
|
||||||
|
resolved: true,
|
||||||
|
valid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resourceImportMissingFields(
|
||||||
|
row: Pick<
|
||||||
|
ResourceImportRow,
|
||||||
|
"nickname" | "publicAccountId" | "ipLocation" | "followersResolved"
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!row.nickname.trim()) missing.push("nickname");
|
||||||
|
if (!row.publicAccountId.trim()) missing.push("publicAccountId");
|
||||||
|
if (!row.ipLocation.trim() || row.ipLocation.trim() === "待识别") {
|
||||||
|
missing.push("ipLocation");
|
||||||
|
}
|
||||||
|
if (!row.followersResolved) missing.push("followers");
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRows(rows: string[][]) {
|
function normalizeRows(rows: string[][]) {
|
||||||
const header = findHeader(rows);
|
const header = findHeader(rows);
|
||||||
if (!header) {
|
if (!header) {
|
||||||
@@ -198,18 +240,24 @@ function normalizeRows(rows: string[][]) {
|
|||||||
const rawProfileUrl = valueAt(source, header.mapping, "profileUrl");
|
const rawProfileUrl = valueAt(source, header.mapping, "profileUrl");
|
||||||
const profileUrl = normalizeProfileUrl(rawProfileUrl);
|
const profileUrl = normalizeProfileUrl(rawProfileUrl);
|
||||||
const platform = platformFromProfileUrl(profileUrl);
|
const platform = platformFromProfileUrl(profileUrl);
|
||||||
|
const rawFollowers = valueAt(source, header.mapping, "followers");
|
||||||
|
const parsedFollowers = parseResourceFollowers(rawFollowers);
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
if (!rawProfileUrl) errors.push("账号主页不能为空");
|
if (!rawProfileUrl) errors.push("账号主页不能为空");
|
||||||
else if (!profileUrl) errors.push("账号主页链接格式不正确");
|
else if (!profileUrl) errors.push("账号主页链接格式不正确");
|
||||||
else if (!platform) errors.push("当前自动解析仅支持小红书账号主页");
|
else if (!platform) errors.push("当前自动解析仅支持小红书账号主页");
|
||||||
|
if (!parsedFollowers.valid) {
|
||||||
|
errors.push("粉丝数格式不正确,请填写数字或如 1.3万、10+");
|
||||||
|
}
|
||||||
result.push({
|
result.push({
|
||||||
rowNumber: index + 1,
|
rowNumber: index + 1,
|
||||||
platform,
|
platform,
|
||||||
nickname: "",
|
nickname: valueAt(source, header.mapping, "nickname"),
|
||||||
publicAccountId: "",
|
publicAccountId: valueAt(source, header.mapping, "publicAccountId"),
|
||||||
profileUrl,
|
profileUrl,
|
||||||
ipLocation: "待识别",
|
ipLocation: valueAt(source, header.mapping, "ipLocation"),
|
||||||
followers: 0,
|
followers: parsedFollowers.value,
|
||||||
|
followersResolved: parsedFollowers.resolved,
|
||||||
cooperationSource: valueAt(source, header.mapping, "cooperationSource"),
|
cooperationSource: valueAt(source, header.mapping, "cooperationSource"),
|
||||||
errors,
|
errors,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ export function startScheduler() {
|
|||||||
if (!isEnabled(getRuntimeEnv().ENABLE_SCHEDULER, true)) return;
|
if (!isEnabled(getRuntimeEnv().ENABLE_SCHEDULER, true)) return;
|
||||||
if (globalThis.__kocLoopScheduler) return;
|
if (globalThis.__kocLoopScheduler) return;
|
||||||
globalThis.__kocLoopScheduler = cron.schedule(
|
globalThis.__kocLoopScheduler = cron.schedule(
|
||||||
"0 10 * * *",
|
"0 9 * * *",
|
||||||
() => void runDailyJob().catch((error) => {
|
() => void runDailyJob().catch((error) => {
|
||||||
console.error("[KOC LOOP] daily scheduler failed", error);
|
console.error("[KOC LOOP] daily scheduler failed", error);
|
||||||
}),
|
}),
|
||||||
{ timezone: "Asia/Shanghai", noOverlap: true },
|
{ timezone: "Asia/Shanghai", noOverlap: true },
|
||||||
);
|
);
|
||||||
console.info("[KOC LOOP] scheduler enabled at 10:00 Asia/Shanghai");
|
console.info("[KOC LOOP] scheduler enabled at 09:00 Asia/Shanghai");
|
||||||
}
|
}
|
||||||
|
|||||||
26
lib/sort-utils.ts
Normal file
26
lib/sort-utils.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
export function sortWithNullsLast<T>(
|
||||||
|
items: T[],
|
||||||
|
valueFor: (item: T) => number | null,
|
||||||
|
direction: SortDirection,
|
||||||
|
) {
|
||||||
|
return items
|
||||||
|
.map((item, index) => ({ item, index }))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftValue = valueFor(left.item);
|
||||||
|
const rightValue = valueFor(right.item);
|
||||||
|
if (leftValue === null && rightValue === null) {
|
||||||
|
return left.index - right.index;
|
||||||
|
}
|
||||||
|
if (leftValue === null) return 1;
|
||||||
|
if (rightValue === null) return -1;
|
||||||
|
const compared = leftValue - rightValue;
|
||||||
|
return compared === 0
|
||||||
|
? left.index - right.index
|
||||||
|
: direction === "asc"
|
||||||
|
? compared
|
||||||
|
: -compared;
|
||||||
|
})
|
||||||
|
.map(({ item }) => item);
|
||||||
|
}
|
||||||
Binary file not shown.
58
tests/collection-schedule.test.mjs
Normal file
58
tests/collection-schedule.test.mjs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { isCollectionScheduleDue } from "../lib/collection-service.ts";
|
||||||
|
import { sortWithNullsLast } from "../lib/sort-utils.ts";
|
||||||
|
|
||||||
|
test("runs the Beijing collection schedule from 09:00", () => {
|
||||||
|
const scheduledDate = "2026-08-12";
|
||||||
|
assert.equal(
|
||||||
|
isCollectionScheduleDue(
|
||||||
|
scheduledDate,
|
||||||
|
Date.parse("2026-08-12T00:59:59Z"),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isCollectionScheduleDue(
|
||||||
|
scheduledDate,
|
||||||
|
Date.parse("2026-08-12T01:00:00Z"),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isCollectionScheduleDue(
|
||||||
|
scheduledDate,
|
||||||
|
Date.parse("2026-08-13T00:00:00Z"),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isCollectionScheduleDue(
|
||||||
|
scheduledDate,
|
||||||
|
Date.parse("2026-08-11T16:00:00Z"),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sorts both directions while keeping missing values last", () => {
|
||||||
|
const values = [
|
||||||
|
{ id: "missing-a", value: null },
|
||||||
|
{ id: "middle", value: 20 },
|
||||||
|
{ id: "high", value: 50 },
|
||||||
|
{ id: "missing-b", value: null },
|
||||||
|
{ id: "low", value: 10 },
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
sortWithNullsLast(values, (item) => item.value, "asc").map(
|
||||||
|
(item) => item.id,
|
||||||
|
),
|
||||||
|
["low", "middle", "high", "missing-a", "missing-b"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
sortWithNullsLast(values, (item) => item.value, "desc").map(
|
||||||
|
(item) => item.id,
|
||||||
|
),
|
||||||
|
["high", "middle", "low", "missing-a", "missing-b"],
|
||||||
|
);
|
||||||
|
});
|
||||||
58
tests/distribution-release.test.mjs
Normal file
58
tests/distribution-release.test.mjs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
DistributionReleaseError,
|
||||||
|
distributionReleaseBlockReason,
|
||||||
|
} from "../lib/distribution-release-service.ts";
|
||||||
|
|
||||||
|
test("allows only unfinished assignments to return to the claim pool", () => {
|
||||||
|
assert.equal(
|
||||||
|
distributionReleaseBlockReason({
|
||||||
|
publishUrl: null,
|
||||||
|
resultSubmittedAt: null,
|
||||||
|
taskType: "content_publish",
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
distributionReleaseBlockReason({
|
||||||
|
publishUrl: "https://www.xiaohongshu.com/discovery/item/example",
|
||||||
|
resultSubmittedAt: null,
|
||||||
|
taskType: "content_publish",
|
||||||
|
}),
|
||||||
|
"已回填发布链接的笔记不能释放",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
distributionReleaseBlockReason({
|
||||||
|
publishUrl: null,
|
||||||
|
resultSubmittedAt: "2026-08-12 09:00:00",
|
||||||
|
taskType: "screenshot_collect",
|
||||||
|
}),
|
||||||
|
"已提交结果截图的任务不能释放",
|
||||||
|
);
|
||||||
|
assert.equal(new DistributionReleaseError("blocked", 409).status, 409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("releases a claim atomically and restores all counters", async () => {
|
||||||
|
const [service, database, actionRoute, adminApp] = await Promise.all([
|
||||||
|
readFile(new URL("../lib/distribution-release-service.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../lib/database.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../app/api/action/route.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(database, /async transaction<T>/);
|
||||||
|
assert.match(service, /FOR UPDATE/);
|
||||||
|
assert.match(service, /DELETE FROM distributions WHERE id = \?/);
|
||||||
|
assert.match(service, /UPDATE contents SET status = 'available'/);
|
||||||
|
assert.match(service, /claimed_quantity = GREATEST\(claimed_quantity - 1, 0\)/);
|
||||||
|
assert.match(service, /claimed_total = GREATEST\(claimed_total - 1, 0\)/);
|
||||||
|
assert.match(service, /UPDATE claims/);
|
||||||
|
assert.match(service, /UPDATE delegation_bundles/);
|
||||||
|
assert.match(actionRoute, /body\.action === "release_distribution"/);
|
||||||
|
assert.match(actionRoute, /isManagerRequest/);
|
||||||
|
assert.match(adminApp, /确认释放这篇笔记/);
|
||||||
|
assert.match(adminApp, /已释放,可重新领取/);
|
||||||
|
assert.match(adminApp, /role="alertdialog"/);
|
||||||
|
});
|
||||||
@@ -75,6 +75,8 @@ test("organizes distribution and recovery by task and imports the verified Feish
|
|||||||
assert.match(adminApp, /选择一个数据回收任务/);
|
assert.match(adminApp, /选择一个数据回收任务/);
|
||||||
assert.match(adminApp, /读取表格/);
|
assert.match(adminApp, /读取表格/);
|
||||||
assert.match(adminApp, /当前仅展示/);
|
assert.match(adminApp, /当前仅展示/);
|
||||||
|
assert.match(adminApp, /release_distribution/);
|
||||||
|
assert.match(adminApp, /确认释放这篇笔记/);
|
||||||
assert.equal(snapshot.sheetId, "954953");
|
assert.equal(snapshot.sheetId, "954953");
|
||||||
assert.equal(snapshot.rows.length, 61);
|
assert.equal(snapshot.rows.length, 61);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -229,6 +231,11 @@ test("supports task collection schedules and latest public metrics", async () =>
|
|||||||
}
|
}
|
||||||
assert.match(adminApp, /选择开始日期与采集日/);
|
assert.match(adminApp, /选择开始日期与采集日/);
|
||||||
assert.match(adminApp, /第1天到第7天/);
|
assert.match(adminApp, /第1天到第7天/);
|
||||||
|
assert.match(adminApp, /SortableRecoveryHeader/);
|
||||||
|
assert.match(adminApp, /sortRecoveryDistributions/);
|
||||||
|
assert.match(adminApp, /aria-sort=/);
|
||||||
|
assert.match(adminApp, /direction === "asc" \? "↑" : "↓"/);
|
||||||
|
assert.match(adminApp, /sortWithNullsLast/);
|
||||||
assert.match(adminApp, /内容 \/ 发布账号/);
|
assert.match(adminApp, /内容 \/ 发布账号/);
|
||||||
assert.match(adminApp, /recovery-title-link/);
|
assert.match(adminApp, /recovery-title-link/);
|
||||||
assert.match(adminApp, /打开小红书笔记/);
|
assert.match(adminApp, /打开小红书笔记/);
|
||||||
@@ -252,10 +259,11 @@ test("supports task collection schedules and latest public metrics", async () =>
|
|||||||
assert.match(collectionService, /runDueScheduledCollections/);
|
assert.match(collectionService, /runDueScheduledCollections/);
|
||||||
assert.match(collectionService, /retryFailedCollections/);
|
assert.match(collectionService, /retryFailedCollections/);
|
||||||
assert.match(collectionService, /exposure IS NOT NULL AND views IS NOT NULL/);
|
assert.match(collectionService, /exposure IS NOT NULL AND views IS NOT NULL/);
|
||||||
assert.match(collectionService, /等待第\$\{scheduleDay\}天 10:00自动采集/);
|
assert.match(collectionService, /等待第\$\{scheduleDay\}天 09:00自动采集/);
|
||||||
|
assert.match(collectionService, /isCollectionScheduleDue/);
|
||||||
assert.doesNotMatch(collectionService, /latestDueSchedule/);
|
assert.doesNotMatch(collectionService, /latestDueSchedule/);
|
||||||
assert.match(collectionService, /自动追采/);
|
assert.match(collectionService, /自动追采/);
|
||||||
assert.match(scheduler, /0 10 \* \* \*/);
|
assert.match(scheduler, /0 9 \* \* \*/);
|
||||||
assert.match(scheduler, /backfillAccountProfiles/);
|
assert.match(scheduler, /backfillAccountProfiles/);
|
||||||
assert.match(scheduler, /Asia\/Shanghai/);
|
assert.match(scheduler, /Asia\/Shanghai/);
|
||||||
assert.match(bootstrapRoute, /backfillAccountProfiles/);
|
assert.match(bootstrapRoute, /backfillAccountProfiles/);
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { buildRecoveryWorkbook } from "../lib/recovery-workbook.ts";
|
|||||||
import {
|
import {
|
||||||
mergeCooperationSources,
|
mergeCooperationSources,
|
||||||
normalizeProfileUrl,
|
normalizeProfileUrl,
|
||||||
|
parseResourceFollowers,
|
||||||
parseResourceImportFile,
|
parseResourceImportFile,
|
||||||
|
resourceImportMissingFields,
|
||||||
resourcePlatformUid,
|
resourcePlatformUid,
|
||||||
} from "../lib/resource-import.ts";
|
} from "../lib/resource-import.ts";
|
||||||
|
|
||||||
@@ -21,14 +23,50 @@ test("parses CSV resources and normalizes public profile data", () => {
|
|||||||
nickname: "",
|
nickname: "",
|
||||||
publicAccountId: "",
|
publicAccountId: "",
|
||||||
profileUrl: "https://www.xiaohongshu.com/user/profile/abc123",
|
profileUrl: "https://www.xiaohongshu.com/user/profile/abc123",
|
||||||
ipLocation: "待识别",
|
ipLocation: "",
|
||||||
followers: 0,
|
followers: 0,
|
||||||
|
followersResolved: false,
|
||||||
cooperationSource: "林林KOC社群",
|
cooperationSource: "林林KOC社群",
|
||||||
errors: [],
|
errors: [],
|
||||||
});
|
});
|
||||||
assert.equal(resourcePlatformUid(rows[0]), "abc123");
|
assert.equal(resourcePlatformUid(rows[0]), "abc123");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses optional account fields directly and only requires the profile URL", () => {
|
||||||
|
const csv = [
|
||||||
|
"账号链接,账号昵称,账号ID,IP属地,粉丝数,合作来源",
|
||||||
|
"https://www.xiaohongshu.com/user/profile/abc123,番茄不炒蛋,4171542126,江西,10+,历史资源",
|
||||||
|
].join("\n");
|
||||||
|
const [row] = parseResourceImportFile(
|
||||||
|
"resources.csv",
|
||||||
|
new TextEncoder().encode(csv),
|
||||||
|
);
|
||||||
|
assert.equal(row.nickname, "番茄不炒蛋");
|
||||||
|
assert.equal(row.publicAccountId, "4171542126");
|
||||||
|
assert.equal(row.ipLocation, "江西");
|
||||||
|
assert.equal(row.followers, 10);
|
||||||
|
assert.equal(row.followersResolved, true);
|
||||||
|
assert.deepEqual(resourceImportMissingFields(row), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes common follower formats and identifies missing enrichment fields", () => {
|
||||||
|
assert.deepEqual(parseResourceFollowers("1.3万"), {
|
||||||
|
value: 13_000,
|
||||||
|
resolved: true,
|
||||||
|
valid: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseResourceFollowers("10+"), {
|
||||||
|
value: 10,
|
||||||
|
resolved: true,
|
||||||
|
valid: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseResourceFollowers(""), {
|
||||||
|
value: 0,
|
||||||
|
resolved: false,
|
||||||
|
valid: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("parses the first matching worksheet from an XLSX workbook", () => {
|
test("parses the first matching worksheet from an XLSX workbook", () => {
|
||||||
const workbook = buildRecoveryWorkbook({
|
const workbook = buildRecoveryWorkbook({
|
||||||
sheetName: "KOC资源导入",
|
sheetName: "KOC资源导入",
|
||||||
@@ -45,6 +83,7 @@ test("parses the first matching worksheet from an XLSX workbook", () => {
|
|||||||
assert.equal(rows[0].platform, "小红书");
|
assert.equal(rows[0].platform, "小红书");
|
||||||
assert.equal(rows[0].cooperationSource, "存量资源包");
|
assert.equal(rows[0].cooperationSource, "存量资源包");
|
||||||
assert.equal(rows[0].errors.length, 0);
|
assert.equal(rows[0].errors.length, 0);
|
||||||
|
assert.equal(rows[0].followersResolved, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reports invalid required fields without hiding valid rows", () => {
|
test("reports invalid required fields without hiding valid rows", () => {
|
||||||
@@ -54,6 +93,18 @@ test("reports invalid required fields without hiding valid rows", () => {
|
|||||||
assert.match(rows[1].errors.join(";"), /仅支持小红书账号主页/);
|
assert.match(rows[1].errors.join(";"), /仅支持小红书账号主页/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects invalid optional follower values without requiring other optional fields", () => {
|
||||||
|
const csv = [
|
||||||
|
"账号链接,粉丝数",
|
||||||
|
"https://www.xiaohongshu.com/user/profile/abc123,很多",
|
||||||
|
].join("\n");
|
||||||
|
const [row] = parseResourceImportFile(
|
||||||
|
"resources.csv",
|
||||||
|
new TextEncoder().encode(csv),
|
||||||
|
);
|
||||||
|
assert.match(row.errors.join(";"), /粉丝数格式不正确/);
|
||||||
|
});
|
||||||
|
|
||||||
test("normalizes profile URLs and merges cooperation sources", () => {
|
test("normalizes profile URLs and merges cooperation sources", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
normalizeProfileUrl("https://www.xiaohongshu.com/user/profile/abc/?foo=1#top"),
|
normalizeProfileUrl("https://www.xiaohongshu.com/user/profile/abc/?foo=1#top"),
|
||||||
|
|||||||
Reference in New Issue
Block a user