Add recovery Excel export and automatic account detection
This commit is contained in:
@@ -8,7 +8,10 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { formatShanghaiDate as formatDate } from "../lib/date-utils";
|
||||
import {
|
||||
formatShanghaiDate as formatDate,
|
||||
formatShanghaiToday,
|
||||
} from "../lib/date-utils";
|
||||
|
||||
type Partner = {
|
||||
id: string;
|
||||
@@ -317,6 +320,7 @@ export default function Home() {
|
||||
const [sourcePreview, setSourcePreview] = useState<FeishuPreview | null>(null);
|
||||
const [sourceWorking, setSourceWorking] = useState(false);
|
||||
const [metricForm, setMetricForm] = useState({ exposure: "", views: "" });
|
||||
const [exportingTaskId, setExportingTaskId] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
@@ -502,6 +506,37 @@ export default function Home() {
|
||||
);
|
||||
};
|
||||
|
||||
const exportRecoveryData = async (task: Task) => {
|
||||
try {
|
||||
setExportingTaskId(task.id);
|
||||
const response = await fetch(
|
||||
`/api/recovery-export?task=${encodeURIComponent(task.id)}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
const result = await readApiResponse<{ error?: string }>(
|
||||
response,
|
||||
"导出失败",
|
||||
);
|
||||
throw new Error(result.error || "导出失败");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = `${task.name}-数据回收.xlsx`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
setToast("Excel 已导出,图片和截图已嵌入表格");
|
||||
} catch (reason) {
|
||||
setToast(reason instanceof Error ? reason.message : "导出失败");
|
||||
} finally {
|
||||
setExportingTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadScreenshot = async (
|
||||
distribution: Distribution,
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
@@ -654,7 +689,7 @@ export default function Home() {
|
||||
{activeNav === "overview" && (
|
||||
<div className="date-chip">
|
||||
<span>今天</span>
|
||||
<strong>7月27日</strong>
|
||||
<strong>{formatShanghaiToday()}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -710,6 +745,8 @@ export default function Home() {
|
||||
onUpload={uploadScreenshot}
|
||||
onFallback={openMetricFallback}
|
||||
onRetryFailed={retryFailedMetrics}
|
||||
onExport={exportRecoveryData}
|
||||
exportingTaskId={exportingTaskId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -1373,6 +1410,8 @@ function RecoveryPage({
|
||||
onUpload,
|
||||
onFallback,
|
||||
onRetryFailed,
|
||||
onExport,
|
||||
exportingTaskId,
|
||||
}: {
|
||||
tasks: Task[];
|
||||
distributions: Distribution[];
|
||||
@@ -1386,6 +1425,8 @@ function RecoveryPage({
|
||||
onUpload: (distribution: Distribution, event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onFallback: (distribution: Distribution) => void;
|
||||
onRetryFailed: (taskId: string, failedCount: number) => void;
|
||||
onExport: (task: Task) => void;
|
||||
exportingTaskId: string | null;
|
||||
}) {
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
|
||||
@@ -1431,16 +1472,27 @@ function RecoveryPage({
|
||||
<h2>数据回收队列</h2>
|
||||
<p>{taskDistributions.length} 篇已发布笔记 · 展示最近一次采集结果</p>
|
||||
</div>
|
||||
<button
|
||||
className="retry-failed-button"
|
||||
disabled={working || failedCollectionCount === 0}
|
||||
onClick={() =>
|
||||
onRetryFailed(selectedTask.id, failedCollectionCount)
|
||||
}
|
||||
>
|
||||
一键补采异常数据
|
||||
{failedCollectionCount > 0 && <b>{failedCollectionCount}</b>}
|
||||
</button>
|
||||
<div className="recovery-panel-actions">
|
||||
<button
|
||||
className="export-data-button"
|
||||
disabled={working || exportingTaskId === selectedTask.id}
|
||||
onClick={() => onExport(selectedTask)}
|
||||
>
|
||||
{exportingTaskId === selectedTask.id
|
||||
? "正在生成 Excel…"
|
||||
: "导出全部数据"}
|
||||
</button>
|
||||
<button
|
||||
className="retry-failed-button"
|
||||
disabled={working || failedCollectionCount === 0}
|
||||
onClick={() =>
|
||||
onRetryFailed(selectedTask.id, failedCollectionCount)
|
||||
}
|
||||
>
|
||||
一键补采异常数据
|
||||
{failedCollectionCount > 0 && <b>{failedCollectionCount}</b>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-scroll">
|
||||
<table className="data-table recovery-table">
|
||||
|
||||
@@ -36,7 +36,6 @@ type PartnerBody = {
|
||||
claimantIdentifier?: string;
|
||||
claimantName?: string;
|
||||
quantity?: number;
|
||||
accountNickname?: string;
|
||||
publishUrl?: string;
|
||||
exposure?: number | string;
|
||||
views?: number | string;
|
||||
@@ -762,11 +761,10 @@ async function handlePost(request: Request) {
|
||||
if (body.action === "submit") {
|
||||
const claimToken = textValue(body.claimToken, 80);
|
||||
const distributionId = textValue(body.distributionId, 80);
|
||||
const accountNickname = textValue(body.accountNickname, 80);
|
||||
const publishInput = textValue(body.publishUrl, 5000);
|
||||
if (!accountNickname || !publishInput) {
|
||||
if (!publishInput) {
|
||||
return Response.json(
|
||||
{ error: "请填写发布账号昵称和发布链接" },
|
||||
{ error: "请填写发布链接" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
@@ -777,7 +775,7 @@ async function handlePost(request: Request) {
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const account = accountFromPublishLink(publishUrl, accountNickname);
|
||||
const account = accountFromPublishLink(publishUrl);
|
||||
if (!account) {
|
||||
return Response.json({ error: "发布链接格式不正确" }, { status: 400 });
|
||||
}
|
||||
@@ -881,7 +879,7 @@ async function handlePost(request: Request) {
|
||||
db,
|
||||
assignment.id,
|
||||
publishUrl,
|
||||
accountNickname,
|
||||
account.nickname,
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
|
||||
430
app/api/recovery-export/route.ts
Normal file
430
app/api/recovery-export/route.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||
import { parseStoredDate } from "../../../lib/date-utils";
|
||||
import {
|
||||
downloadFeishuMedia,
|
||||
type FeishuBindings,
|
||||
} from "../../../lib/feishu-client";
|
||||
import {
|
||||
ensureSchema,
|
||||
getRawDb,
|
||||
getUploadBucket,
|
||||
} from "../../../lib/mvp-db";
|
||||
import {
|
||||
buildRecoveryWorkbook,
|
||||
type RecoveryWorkbookImage,
|
||||
type RecoveryWorkbookRow,
|
||||
} from "../../../lib/recovery-workbook";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
type StoredAsset = {
|
||||
index: number;
|
||||
key: string;
|
||||
fileToken?: string;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
};
|
||||
|
||||
type ExportRow = {
|
||||
content_id: string;
|
||||
source_row: number | null;
|
||||
title: string;
|
||||
body: string;
|
||||
image_assets: string;
|
||||
distribution_id: string | null;
|
||||
publish_url: string | null;
|
||||
publish_time: string | null;
|
||||
publish_screenshot_key: string | null;
|
||||
screenshot_key: string | null;
|
||||
exposure: number | null;
|
||||
views: number | null;
|
||||
d2_likes: number | null;
|
||||
d2_comments: number | null;
|
||||
d2_collects: number | null;
|
||||
d5_likes: number | null;
|
||||
d5_comments: number | null;
|
||||
d5_collects: number | null;
|
||||
d7_likes: number | null;
|
||||
d7_comments: number | null;
|
||||
d7_collects: number | null;
|
||||
latest_likes: number | null;
|
||||
latest_comments: number | null;
|
||||
latest_collects: number | null;
|
||||
collection_status: string | null;
|
||||
collection_status_description: string | null;
|
||||
collection_updated_at: string | null;
|
||||
updated_at: string | null;
|
||||
account_nickname: string | null;
|
||||
partner_name: string | null;
|
||||
claimant_name: string | null;
|
||||
};
|
||||
|
||||
type ImageReference = StoredAsset & {
|
||||
description: string;
|
||||
};
|
||||
|
||||
function parseAssets(value: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(value || "[]") as StoredAsset[];
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.filter(
|
||||
(asset) =>
|
||||
Number.isInteger(Number(asset.index)) &&
|
||||
Number(asset.index) > 0 &&
|
||||
typeof asset.key === "string" &&
|
||||
asset.key.startsWith("content-assets/"),
|
||||
)
|
||||
.map((asset) => ({ ...asset, index: Number(asset.index) }))
|
||||
.sort((left, right) => left.index - right.index);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function latestMetrics(row: ExportRow) {
|
||||
const legacyDay =
|
||||
row.d7_likes !== null
|
||||
? 7
|
||||
: row.d5_likes !== null
|
||||
? 5
|
||||
: row.d2_likes !== null
|
||||
? 2
|
||||
: null;
|
||||
const legacyLikes =
|
||||
legacyDay === 7
|
||||
? row.d7_likes
|
||||
: legacyDay === 5
|
||||
? row.d5_likes
|
||||
: legacyDay === 2
|
||||
? row.d2_likes
|
||||
: null;
|
||||
const legacyComments =
|
||||
legacyDay === 7
|
||||
? row.d7_comments
|
||||
: legacyDay === 5
|
||||
? row.d5_comments
|
||||
: legacyDay === 2
|
||||
? row.d2_comments
|
||||
: null;
|
||||
const legacyCollects =
|
||||
legacyDay === 7
|
||||
? row.d7_collects
|
||||
: legacyDay === 5
|
||||
? row.d5_collects
|
||||
: legacyDay === 2
|
||||
? row.d2_collects
|
||||
: null;
|
||||
const likes = row.latest_likes ?? legacyLikes;
|
||||
const comments = row.latest_comments ?? legacyComments;
|
||||
const collects = row.latest_collects ?? legacyCollects;
|
||||
return {
|
||||
likes,
|
||||
comments,
|
||||
collects,
|
||||
total:
|
||||
likes === null ? null : likes + (comments ?? 0) + (collects ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function formatExportDate(value: string | null) {
|
||||
if (!value) return "";
|
||||
const date = parseStoredDate(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function collectionLabel(row: ExportRow) {
|
||||
const labels: Record<string, string> = {
|
||||
pending: "待设置",
|
||||
scheduled: "已计划",
|
||||
collecting: "采集中",
|
||||
success: "采集成功",
|
||||
failed: "采集失败",
|
||||
};
|
||||
const status = row.collection_status || (row.latest_likes !== null ? "success" : "pending");
|
||||
const label = labels[status] || status;
|
||||
return row.collection_status_description
|
||||
? `${label}:${row.collection_status_description}`
|
||||
: label;
|
||||
}
|
||||
|
||||
function contentTypeFromObject(object: R2ObjectBody) {
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
return headers.get("Content-Type") || "application/octet-stream";
|
||||
}
|
||||
|
||||
async function loadImage(reference: ImageReference) {
|
||||
const bucket = getUploadBucket();
|
||||
let object = await bucket.get(reference.key);
|
||||
if (!object && reference.fileToken) {
|
||||
const media = await downloadFeishuMedia(
|
||||
reference.fileToken,
|
||||
env as unknown as FeishuBindings,
|
||||
);
|
||||
await bucket.put(reference.key, media.bytes, {
|
||||
httpMetadata: { contentType: media.contentType },
|
||||
customMetadata: { source: "feishu-api" },
|
||||
});
|
||||
object = await bucket.get(reference.key);
|
||||
}
|
||||
if (!object) return null;
|
||||
return {
|
||||
bytes: new Uint8Array(await object.arrayBuffer()),
|
||||
contentType: contentTypeFromObject(object),
|
||||
width: reference.width,
|
||||
height: reference.height,
|
||||
description: reference.description,
|
||||
} satisfies RecoveryWorkbookImage;
|
||||
}
|
||||
|
||||
async function loadImages(references: ImageReference[]) {
|
||||
const unique = new Map(references.map((reference) => [reference.key, reference]));
|
||||
const queue = [...unique.values()];
|
||||
const loaded = new Map<string, RecoveryWorkbookImage | null>();
|
||||
const workers = Array.from({ length: Math.min(6, queue.length) }, async () => {
|
||||
while (queue.length > 0) {
|
||||
const reference = queue.shift();
|
||||
if (!reference) return;
|
||||
try {
|
||||
loaded.set(reference.key, await loadImage(reference));
|
||||
} catch {
|
||||
loaded.set(reference.key, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function safeFileName(value: string) {
|
||||
return value.replace(/[\\/:*?"<>|]/g, " ").trim().slice(0, 60) || "KOC数据回收";
|
||||
}
|
||||
|
||||
function exactArrayBuffer(bytes: Uint8Array) {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return copy.buffer;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
try {
|
||||
await ensureSchema();
|
||||
const taskId = new URL(request.url).searchParams.get("task")?.trim() || "";
|
||||
if (!taskId) {
|
||||
return Response.json({ error: "缺少导出任务" }, { status: 400 });
|
||||
}
|
||||
const db = getRawDb();
|
||||
const task = await db
|
||||
.prepare("SELECT id, name, brand FROM tasks WHERE id = ?")
|
||||
.bind(taskId)
|
||||
.first<{ id: string; name: string; brand: string }>();
|
||||
if (!task) {
|
||||
return Response.json({ error: "没有找到这个任务" }, { status: 404 });
|
||||
}
|
||||
const result = await db
|
||||
.prepare(
|
||||
`SELECT
|
||||
c.id AS content_id,
|
||||
c.source_row,
|
||||
c.title,
|
||||
c.body,
|
||||
c.image_assets,
|
||||
d.id AS distribution_id,
|
||||
d.publish_url,
|
||||
d.publish_time,
|
||||
d.publish_screenshot_key,
|
||||
d.screenshot_key,
|
||||
d.exposure,
|
||||
d.views,
|
||||
d.d2_likes,
|
||||
d.d2_comments,
|
||||
d.d2_collects,
|
||||
d.d5_likes,
|
||||
d.d5_comments,
|
||||
d.d5_collects,
|
||||
d.d7_likes,
|
||||
d.d7_comments,
|
||||
d.d7_collects,
|
||||
d.latest_likes,
|
||||
d.latest_comments,
|
||||
d.latest_collects,
|
||||
d.collection_status,
|
||||
d.collection_status_description,
|
||||
d.collection_updated_at,
|
||||
d.updated_at,
|
||||
a.nickname AS account_nickname,
|
||||
p.name AS partner_name,
|
||||
cl.claimant_name
|
||||
FROM contents c
|
||||
LEFT JOIN distributions d ON d.content_id = c.id
|
||||
LEFT JOIN accounts a ON a.id = d.account_id
|
||||
LEFT JOIN partners p ON p.id = d.partner_id
|
||||
LEFT JOIN claims cl ON cl.id = d.claim_id
|
||||
WHERE c.task_id = ?
|
||||
ORDER BY COALESCE(c.source_row, 999999), c.created_at, d.claimed_at`,
|
||||
)
|
||||
.bind(taskId)
|
||||
.all<ExportRow>();
|
||||
const rows = result.results;
|
||||
const assetsByContent = new Map(
|
||||
rows.map((row) => [row.content_id, parseAssets(row.image_assets)]),
|
||||
);
|
||||
const maxContentImages = Math.max(
|
||||
0,
|
||||
...[...assetsByContent.values()].flat().map((asset) => asset.index),
|
||||
);
|
||||
const references: ImageReference[] = [];
|
||||
rows.forEach((row) => {
|
||||
for (const asset of assetsByContent.get(row.content_id) ?? []) {
|
||||
references.push({
|
||||
...asset,
|
||||
description: `${row.title} 原图${asset.index}`,
|
||||
});
|
||||
}
|
||||
if (row.screenshot_key) {
|
||||
references.push({
|
||||
index: 0,
|
||||
key: row.screenshot_key,
|
||||
description: `${row.title} 创作者中心截图`,
|
||||
});
|
||||
}
|
||||
if (row.publish_screenshot_key) {
|
||||
references.push({
|
||||
index: 0,
|
||||
key: row.publish_screenshot_key,
|
||||
description: `${row.title} 发布截图`,
|
||||
});
|
||||
}
|
||||
});
|
||||
const loadedImages = await loadImages(references);
|
||||
const headers = [
|
||||
"序号(不能改)",
|
||||
"标题",
|
||||
"笔记内容(正文+话题)",
|
||||
...Array.from({ length: maxContentImages }, (_, index) => `图片${index + 1}`),
|
||||
"小红书昵称",
|
||||
"发布链接",
|
||||
"发布时间",
|
||||
"点赞",
|
||||
"收藏",
|
||||
"评论",
|
||||
"总互动",
|
||||
"曝光量-实际(第7天)",
|
||||
"阅读量-实际(第7天)",
|
||||
"数据分析截图(单篇笔记数据分析截图)",
|
||||
"笔记截图",
|
||||
"数据更新时间",
|
||||
"采集状态",
|
||||
"合作方",
|
||||
"领取人微信号/手机号",
|
||||
];
|
||||
const originalImageStart = 3;
|
||||
const accountColumn = originalImageStart + maxContentImages;
|
||||
const creatorScreenshotColumn = accountColumn + 9;
|
||||
const publishScreenshotColumn = creatorScreenshotColumn + 1;
|
||||
const workbookRows: RecoveryWorkbookRow[] = rows.map((row, rowIndex) => {
|
||||
const metrics = latestMetrics(row);
|
||||
const contentAssets = assetsByContent.get(row.content_id) ?? [];
|
||||
const originalImageCells = Array.from(
|
||||
{ length: maxContentImages },
|
||||
(_, index) => {
|
||||
const asset = contentAssets.find((item) => item.index === index + 1);
|
||||
return asset && loadedImages.get(asset.key) ? "见图" : asset ? "图片读取失败" : "";
|
||||
},
|
||||
);
|
||||
const creatorImage = row.screenshot_key
|
||||
? loadedImages.get(row.screenshot_key) ?? null
|
||||
: null;
|
||||
const publishImage = row.publish_screenshot_key
|
||||
? loadedImages.get(row.publish_screenshot_key) ?? null
|
||||
: null;
|
||||
const cells: Array<string | number | null> = [
|
||||
row.source_row ?? rowIndex + 1,
|
||||
row.title,
|
||||
row.body,
|
||||
...originalImageCells,
|
||||
row.account_nickname || "",
|
||||
row.publish_url || "",
|
||||
formatExportDate(row.publish_time),
|
||||
metrics.likes,
|
||||
metrics.collects,
|
||||
metrics.comments,
|
||||
metrics.total,
|
||||
row.exposure,
|
||||
row.views,
|
||||
creatorImage ? "见图" : row.screenshot_key ? "截图读取失败" : "",
|
||||
publishImage ? "见图" : row.publish_screenshot_key ? "截图读取失败" : "",
|
||||
formatExportDate(row.collection_updated_at || row.updated_at),
|
||||
row.distribution_id ? collectionLabel(row) : "未领取",
|
||||
row.partner_name || "",
|
||||
row.claimant_name || "",
|
||||
];
|
||||
const images: RecoveryWorkbookRow["images"] = [];
|
||||
for (const asset of contentAssets) {
|
||||
const image = loadedImages.get(asset.key);
|
||||
if (image) images.push({ column: originalImageStart + asset.index - 1, image });
|
||||
}
|
||||
if (creatorImage) {
|
||||
images.push({ column: creatorScreenshotColumn, image: creatorImage });
|
||||
}
|
||||
if (publishImage) {
|
||||
images.push({ column: publishScreenshotColumn, image: publishImage });
|
||||
}
|
||||
return { cells, images };
|
||||
});
|
||||
const columnWidths = [
|
||||
12,
|
||||
28,
|
||||
56,
|
||||
...Array.from({ length: maxContentImages }, () => 24),
|
||||
18,
|
||||
40,
|
||||
20,
|
||||
11,
|
||||
11,
|
||||
11,
|
||||
11,
|
||||
18,
|
||||
18,
|
||||
26,
|
||||
24,
|
||||
20,
|
||||
24,
|
||||
18,
|
||||
22,
|
||||
];
|
||||
const workbook = buildRecoveryWorkbook({
|
||||
sheetName: `${task.name}-数据回收`,
|
||||
headers,
|
||||
columnWidths,
|
||||
rows: workbookRows,
|
||||
});
|
||||
const fileName = `${safeFileName(task.name)}-数据回收.xlsx`;
|
||||
return new Response(exactArrayBuffer(workbook), {
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
"Content-Type":
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"Content-Disposition": `attachment; filename="koc-recovery.xlsx"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "导出失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1856,6 +1856,31 @@ a {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.recovery-panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.export-data-button {
|
||||
display: inline-flex;
|
||||
height: 34px;
|
||||
align-items: center;
|
||||
padding: 0 13px;
|
||||
border: 1px solid #a9cdbc;
|
||||
border-radius: 8px;
|
||||
color: var(--green-deep);
|
||||
background: #f2faf6;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.export-data-button:disabled {
|
||||
color: #94a29c;
|
||||
background: #f5f7f6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.retry-failed-button b {
|
||||
display: grid;
|
||||
min-width: 18px;
|
||||
|
||||
Reference in New Issue
Block a user