feat: 任务发布到企微客户群(企业群发)与资源库单条新增

- 任务中心新增「发布到企微群」:同步客户群清单(wecom_group_chats)、
  按群主分组创建企业群发任务(add_msg_template)、发送记录落库
  wecom_group_pushes,迁移 mysql/0009;lib/wecom-client.ts 补
  listCustomerGroupChats/createGroupMsgTemplate
- KOC 资源库支持单条新增:app/api/resources-insert + lib/resource-write,
  拆出资源写入公共逻辑供导入复用;0008 补合作方外部联系人字段
- 环境变量示例补企微凭证与 SEED_DEMO_DATA;next.config 增加
  allowedDevOrigins;CLAUDE.md 补充项目说明
- .gitignore 排除 .codegraph/ 与 .ipynb_checkpoints/
This commit is contained in:
ABAPPLO
2026-08-20 15:16:18 +08:00
parent 8f7ea0558d
commit 9697b5890d
22 changed files with 3695 additions and 449 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -41,9 +41,17 @@ import {
resolveWecomConfig,
sendWecomAppMessage,
sendWecomRobotMessage,
listExternalContacts,
WecomClientError,
type WecomBindings,
} from "../../../lib/wecom-client";
import { runDueSoonWecomNotifications } from "../../../lib/wecom-notifier-service";
import {
listGroupChatRows,
listGroupPushes,
pushTaskToGroupChats,
syncGroupChats,
} from "../../../lib/wecom-group-push-service";
import { isManagerRequest } from "../../../lib/user-auth";
import { extractPublishUrl } from "../../../lib/publish-url";
@@ -546,21 +554,32 @@ export async function POST(request: Request) {
const externalId = String(body.wecomExternalUserId ?? "")
.trim()
.slice(0, 128);
const wecomName = String(body.wecomName ?? "").trim().slice(0, 120);
if (!partnerId) {
return Response.json(
{ error: "缺少 partnerId" },
{ status: 400 },
);
}
await db
.prepare(
"UPDATE partners SET wecom_external_user_id = ? WHERE id = ?",
)
.bind(externalId || null, partnerId)
.run();
if (wecomName) {
await db
.prepare(
"UPDATE partners SET wecom_external_user_id = ?, wecom_name = ? WHERE id = ?",
)
.bind(externalId || null, wecomName, partnerId)
.run();
} else {
await db
.prepare(
"UPDATE partners SET wecom_external_user_id = ? WHERE id = ?",
)
.bind(externalId || null, partnerId)
.run();
}
return Response.json({
partnerId,
wecomExternalUserId: externalId || null,
wecomName,
});
} else if (body.action === "send_test_wecom") {
const wecomConfig = resolveWecomConfig(
@@ -601,6 +620,72 @@ export async function POST(request: Request) {
robot: robotStatus,
app: appStatus,
});
} else if (body.action === "wecom_status") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
return Response.json({
robotConfigured: Boolean(wecomConfig.robotWebhook),
appConfigured: Boolean(
wecomConfig.corpId && wecomConfig.agentId && wecomConfig.secret,
),
dueDays: wecomConfig.dueDays,
});
} else if (body.action === "trigger_wecom_due_soon") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const summary = await runDueSoonWecomNotifications(
db,
wecomConfig,
);
return Response.json(summary);
} else if (body.action === "wecom_list_external_contacts") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const contacts = await listExternalContacts(wecomConfig);
return Response.json({ contacts });
} else if (body.action === "wecom_sync_group_chats") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const groups = await syncGroupChats(db, wecomConfig);
return Response.json({ groups, syncedCount: groups.length });
} else if (body.action === "wecom_push_task_to_groups") {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const summary = await pushTaskToGroupChats(
db,
{
taskId: String(body.taskId ?? ""),
chatIds: body.chatIds,
text: body.text,
},
wecomConfig,
);
const failCount = summary.results.reduce(
(total, item) => total + item.failList.length,
0,
);
const groupCount = summary.results.reduce(
(total, item) => total + item.chatCount,
0,
);
return Response.json({
results: summary.results,
groupCount,
failCount,
hint: "群发任务已创建,群主需在企微客户端「群发助手」点击发送后,消息才会送达客户群",
});
} else if (body.action === "wecom_task_group_pushes") {
const taskId = String(body.taskId ?? "").trim();
const pushes = await listGroupPushes(db, taskId || undefined);
return Response.json({ pushes });
} else if (body.action === "wecom_group_chats") {
const groups = await listGroupChatRows(db);
return Response.json({ groups });
} else {
return Response.json({ error: "不支持的操作" }, { status: 400 });
}

View File

@@ -1,290 +1,24 @@
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
import { runInBackground } from "../../../lib/background";
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
import {
resolveCollectionMcpConfig,
resolveProfileDetailsFromMcp,
resolveXhsPublicAccountDetails,
type CollectionMcpBindings,
} from "../../../lib/mcp-collection-client";
import { getRuntimeEnv } from "../../../lib/runtime-env";
import {
mergeCooperationSources,
normalizeProfileUrl,
parseResourceImportFile,
RESOURCE_IMPORT_MAX_BYTES,
RESOURCE_IMPORT_MAX_ROWS,
resourcePlatformUid,
resourceImportMissingFields,
type ResourceImportRow,
} from "../../../lib/resource-import";
type AccountRow = {
id: string;
platform: string;
platform_uid: string;
public_account_id: string;
nickname: string;
profile_url: string;
ip_location: string;
followers: number;
gender: string;
bio: string;
tags: string;
cooperation_source: string;
};
type AnalyzedRow = ResourceImportRow & {
action: "create" | "update" | "error";
accountId: string;
platformUid: string;
cooperationSource: string;
};
import {
analyzeRows,
enrichRows,
loadAccounts,
mergeExistingFields,
writeAnalyzedRows,
RESOURCE_IMPORT_SYNC_ENRICH_ROWS,
type AnalyzedRow,
type AccountRow,
} from "../../../lib/resource-write";
const RESOURCE_IMPORT_PREVIEW_ROWS = 100;
const RESOURCE_IMPORT_SYNC_ENRICH_ROWS = 100;
const RESOURCE_IMPORT_DB_BATCH_SIZE = 100;
function identityKey(platform: string, value: string) {
return `${platform.trim().toLocaleLowerCase("zh-CN")}|${value
.trim()
.toLocaleLowerCase("zh-CN")}`;
}
async function loadAccounts() {
return getRawDb()
.prepare(
`SELECT id, platform, platform_uid, public_account_id, nickname,
profile_url, ip_location, followers, gender, bio, tags,
cooperation_source
FROM accounts`,
)
.all<AccountRow>();
}
async function mapConcurrent<T, R>(
items: T[],
limit: number,
worker: (item: T) => Promise<R>,
) {
const results = new Array<R>(items.length);
let cursor = 0;
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor;
cursor += 1;
results[index] = await worker(items[index]);
}
}),
);
return results;
}
function mergeExistingFields(rows: ResourceImportRow[], accounts: AccountRow[]) {
const existingByProfile = new Map<string, AccountRow>();
for (const account of accounts) {
const profileUrl = normalizeProfileUrl(account.profile_url || "");
if (profileUrl) {
existingByProfile.set(identityKey(account.platform, profileUrl), account);
}
}
return rows.map((row) => {
if (
row.errors.length > 0 ||
!row.profileUrl ||
!["小红书", "抖音"].includes(row.platform)
) {
return row;
}
const existing = existingByProfile.get(identityKey(row.platform, row.profileUrl));
const existingNickname =
existing?.nickname && existing.nickname !== "待识别账号"
? existing.nickname
: "";
const existingIpLocation =
existing?.ip_location && existing.ip_location !== "待识别"
? existing.ip_location
: "";
const existingGender: ResourceImportRow["gender"] =
existing?.gender === "男" || existing?.gender === "女"
? existing.gender
: "";
return {
...row,
nickname: row.nickname || existingNickname,
publicAccountId: row.publicAccountId || existing?.public_account_id || "",
ipLocation: row.ipLocation || existingIpLocation,
followers: row.followersResolved
? row.followers
: Number(existing?.followers || 0),
followersResolved:
row.followersResolved || Number(existing?.followers || 0) > 0,
gender: row.gender || existingGender,
bio: row.bio || existing?.bio || "",
tags: row.tags.length > 0
? row.tags
: (existing?.tags || "")
.split(/[,,、;|]/)
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 5),
};
});
}
async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
const mcpConfig = resolveCollectionMcpConfig(
getRuntimeEnv() as unknown as CollectionMcpBindings,
);
const baselineRows = mergeExistingFields(rows, accounts);
return mapConcurrent(baselineRows, 4, async (baseline) => {
const row = baseline;
if (
row.errors.length > 0 ||
!row.profileUrl ||
!["小红书", "抖音"].includes(row.platform)
) {
return row;
}
if (resourceImportMissingFields(baseline).length === 0) {
return baseline;
}
let details: {
nickname: string | null;
redId: string | null;
followers: number | null;
ipLocation: string | null;
gender: "" | "男" | "女";
bio: string;
recentNoteTitles: string[];
providerTags: string[];
} = await resolveProfileDetailsFromMcp(
row.profileUrl,
row.platform === "抖音" ? "抖音" : "小红书",
mcpConfig,
).catch(() => ({
nickname: null,
redId: null,
followers: null,
ipLocation: null,
gender: "" as const,
bio: "",
recentNoteTitles: [],
providerTags: [],
}));
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,
gender: baseline.gender || details.gender,
bio: baseline.bio || details.bio,
tags: baseline.tags,
};
if (
row.platform === "小红书" &&
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,
redId: details.redId || publicDetails.redId,
followers: details.followers ?? publicDetails.followers,
ipLocation: details.ipLocation || publicDetails.ipLocation,
};
}
return {
...baseline,
nickname: baseline.nickname || details.nickname?.trim() || "待识别账号",
publicAccountId: baseline.publicAccountId || details.redId?.trim() || "",
ipLocation: baseline.ipLocation || details.ipLocation?.trim() || "待识别",
followers: baseline.followersResolved
? baseline.followers
: (details.followers ?? 0),
followersResolved:
baseline.followersResolved || details.followers !== null,
gender: baseline.gender || details.gender,
bio: baseline.bio || details.bio,
tags: baseline.tags,
};
});
}
function analyzeRows(rows: ResourceImportRow[], accountRows: AccountRow[]) {
const profileMap = new Map<string, AccountRow>();
const publicIdMap = new Map<string, AccountRow>();
const platformUidMap = new Map<string, AccountRow>();
for (const account of accountRows) {
const profileUrl = normalizeProfileUrl(account.profile_url || "");
if (profileUrl) profileMap.set(identityKey(account.platform, profileUrl), account);
if (account.public_account_id) {
publicIdMap.set(identityKey(account.platform, account.public_account_id), account);
}
platformUidMap.set(identityKey(account.platform, account.platform_uid), account);
}
return rows.map<AnalyzedRow>((row) => {
const platformUid = resourcePlatformUid(row);
const profileMatch = row.profileUrl
? profileMap.get(identityKey(row.platform, row.profileUrl))
: undefined;
const publicIdMatch = row.publicAccountId
? publicIdMap.get(identityKey(row.platform, row.publicAccountId))
: undefined;
const uidMatch = platformUidMap.get(identityKey(row.platform, platformUid));
const matches = [profileMatch, publicIdMatch, uidMatch].filter(
(account): account is AccountRow => Boolean(account),
);
const matchedIds = [...new Set(matches.map((account) => account.id))];
const errors = [...row.errors];
if (matchedIds.length > 1) {
errors.push("账号主页和账号ID匹配到不同的现有账号请先核对");
}
const existing = matchedIds.length === 1 ? matches[0] : undefined;
const accountId = existing?.id ?? `account-${crypto.randomUUID().slice(0, 12)}`;
const analyzed: AnalyzedRow = {
...row,
errors,
action: errors.length > 0 ? "error" : existing ? "update" : "create",
accountId,
platformUid: existing?.platform_uid ?? platformUid,
cooperationSource: mergeCooperationSources(
existing?.cooperation_source ?? "",
row.cooperationSource,
),
};
if (analyzed.action !== "error") {
const virtual: AccountRow = {
id: accountId,
platform: row.platform,
platform_uid: analyzed.platformUid,
public_account_id: row.publicAccountId || existing?.public_account_id || "",
nickname: row.nickname,
profile_url: row.profileUrl || existing?.profile_url || "",
ip_location: row.ipLocation || existing?.ip_location || "待识别",
followers: row.followers || existing?.followers || 0,
gender: row.gender || existing?.gender || "",
bio: row.bio || existing?.bio || "",
tags: (row.tags.length > 0
? row.tags
: (existing?.tags || "").split(/[,,、;|]/).filter(Boolean)
).slice(0, 5).join(","),
cooperation_source: analyzed.cooperationSource,
};
if (row.profileUrl) profileMap.set(identityKey(row.platform, row.profileUrl), virtual);
if (row.publicAccountId) {
publicIdMap.set(identityKey(row.platform, row.publicAccountId), virtual);
}
platformUidMap.set(identityKey(row.platform, analyzed.platformUid), virtual);
}
return analyzed;
});
}
function summarize(rows: AnalyzedRow[]) {
return {
@@ -310,81 +44,6 @@ function previewAnalyzedRows(rows: AnalyzedRow[]) {
];
}
function statementForAnalyzedRow(
db: ReturnType<typeof getRawDb>,
row: AnalyzedRow,
) {
return row.action === "update"
? db
.prepare(
`UPDATE accounts SET
nickname = ?,
public_account_id = CASE WHEN ? != '' THEN ? ELSE public_account_id END,
profile_url = CASE WHEN ? != '' THEN ? ELSE profile_url END,
ip_location = CASE
WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END,
followers = CASE WHEN ? = 1 THEN ? ELSE followers END,
gender = CASE WHEN ? != '' THEN ? ELSE gender END,
bio = CASE WHEN ? != '' THEN ? ELSE bio END,
tags = CASE WHEN ? != '' THEN ? ELSE tags END,
cooperation_source = ?,
last_seen_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
row.nickname || "待识别账号",
row.publicAccountId,
row.publicAccountId,
row.profileUrl,
row.profileUrl,
row.ipLocation,
row.ipLocation,
row.ipLocation,
row.followersResolved ? 1 : 0,
row.followers,
row.gender,
row.gender,
row.bio,
row.bio,
row.tags.join(","),
row.tags.join(","),
row.cooperationSource,
row.accountId,
)
: db
.prepare(
`INSERT INTO accounts
(id, platform, platform_uid, public_account_id, nickname,
profile_url, ip_location, followers, post_count, avg_views,
gender, bio, tags, cooperation_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?)`,
)
.bind(
row.accountId,
row.platform,
row.platformUid,
row.publicAccountId,
row.nickname || "待识别账号",
row.profileUrl,
row.ipLocation || "待识别",
row.followers,
row.gender,
row.bio,
row.tags.join(","),
row.cooperationSource,
);
}
async function writeAnalyzedRows(rows: AnalyzedRow[]) {
const db = getRawDb();
const statements = rows
.filter((row) => row.action !== "error")
.map((row) => statementForAnalyzedRow(db, row));
for (let index = 0; index < statements.length; index += RESOURCE_IMPORT_DB_BATCH_SIZE) {
await db.batch(statements.slice(index, index + RESOURCE_IMPORT_DB_BATCH_SIZE));
}
}
function deferredEnrichmentCount(rows: ResourceImportRow[]) {
return rows.filter(
(row) =>
@@ -415,7 +74,6 @@ async function enrichImportedRowsInBackground(rows: ResourceImportRow[]) {
export async function POST(request: Request) {
if (!(await isManagerRequest(request))) return managerForbidden();
try {
await ensureSchema();
const form = await request.formData();
const file = form.get("file");
const mode = String(form.get("mode") ?? "preview");
@@ -427,11 +85,12 @@ export async function POST(request: Request) {
}
const rows = parseResourceImportFile(file.name, new Uint8Array(await file.arrayBuffer()));
const accounts = await loadAccounts();
const accountRows: AccountRow[] = accounts.results;
const shouldEnrichSynchronously = rows.length <= RESOURCE_IMPORT_SYNC_ENRICH_ROWS;
const preparedRows = shouldEnrichSynchronously
? await enrichRows(rows, accounts.results)
: mergeExistingFields(rows, accounts.results);
const analyzed = analyzeRows(preparedRows, accounts.results);
? await enrichRows(rows, accountRows)
: mergeExistingFields(rows, accountRows);
const analyzed = analyzeRows(preparedRows, accountRows);
const summary = summarize(analyzed);
const importableRows = analyzed.filter((row) => row.action !== "error");
const importableRowNumbers = new Set(

View File

@@ -0,0 +1,64 @@
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
import { buildManualRow, type ManualResourceInput } from "../../../lib/resource-import";
import {
analyzeRows,
enrichRows,
loadAccounts,
writeAnalyzedRows,
} from "../../../lib/resource-write";
type InsertResult = {
action: "create" | "update";
message: string;
};
export async function POST(request: Request) {
if (!(await isManagerRequest(request))) return managerForbidden();
try {
const payload = (await request.json()) as Partial<ManualResourceInput> & {
mode?: string;
};
const row = buildManualRow({
profileUrl: payload.profileUrl ?? "",
nickname: payload.nickname ?? "",
publicAccountId: payload.publicAccountId ?? "",
ipLocation: payload.ipLocation ?? "",
followers: payload.followers ?? "",
gender: payload.gender ?? "",
bio: payload.bio ?? "",
tags: payload.tags ?? "",
cooperationSource: payload.cooperationSource ?? "",
});
if (row.errors.length > 0) {
return Response.json(
{ error: row.errors[0], errors: row.errors },
{ status: 400 },
);
}
const accounts = await loadAccounts();
const enriched = await enrichRows([row], accounts.results);
const analyzed = analyzeRows(enriched, accounts.results).filter(
(item) => item.action !== "error",
);
if (analyzed.length === 0) {
return Response.json(
{ error: "账号数据校验未通过,无法保存" },
{ status: 400 },
);
}
await writeAnalyzedRows(analyzed);
const result = analyzed[0];
const outcome: InsertResult = {
action: result.action === "update" ? "update" : "create",
message:
result.action === "update"
? `已更新账号 ${row.nickname || row.publicAccountId || "资料"}`
: `已新增账号 ${row.nickname || row.publicAccountId || "资料"}`,
};
return Response.json(outcome);
} catch (error) {
const message = error instanceof Error ? error.message : "保存失败";
return Response.json({ error: message }, { status: 400 });
}
}

View File

@@ -984,7 +984,8 @@ a {
.workflow-panel,
.task-mini-panel,
.table-panel {
.table-panel,
.wecom-status-panel {
padding: 21px;
}
@@ -1358,6 +1359,406 @@ a {
margin-top: 0;
}
.wecom-status-panel .wecom-status-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.wecom-status-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
border-radius: 12px;
background: #f4f8f5;
border: 1px solid #e2ebe4;
}
.wecom-status-card span {
color: #687773;
font-size: 10px;
font-weight: 620;
}
.wecom-status-card strong {
font-size: 15px;
color: var(--ink);
}
.wecom-status-card small {
color: #9aa39f;
font-size: 9px;
}
.wecom-status-card.ok {
background: #eaf6ee;
border-color: #c5e4cf;
}
.wecom-status-card.ok strong {
color: var(--green-deep);
}
.wecom-status-card.off {
background: #fdf2ee;
border-color: #f3d4c4;
}
.wecom-status-card.off strong {
color: #c4622b;
}
.wecom-status-actions {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.wecom-status-hint {
margin: 0;
color: #9aa39f;
font-size: 10px;
}
.wecom-status-hint code {
padding: 1px 5px;
border-radius: 4px;
background: #eef2ef;
font-size: 9px;
}
.partner-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.partner-row {
display: grid;
grid-template-columns: 1fr 1fr 0.6fr 1.4fr auto;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 9px;
background: #fbfcfb;
border: 1px solid #e8ece8;
font-size: 11px;
}
.partner-row.head {
background: #f1f4f2;
color: #687773;
font-weight: 620;
font-size: 10px;
}
.partner-row.head span:last-child {
text-align: right;
}
.partner-row.empty {
justify-content: center;
color: #9aa39f;
font-size: 11px;
padding: 18px;
text-align: center;
}
.partner-external-cell {
min-width: 0;
}
.partner-external-cell input,
.partner-external-cell code {
width: 100%;
font-size: 11px;
}
.partner-external-cell code {
display: block;
max-width: 100%;
padding: 4px 8px;
background: #eef2ef;
border-radius: 6px;
color: #556861;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.partner-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
min-width: 228px;
}
.partner-actions .ghost-button,
.partner-actions .primary-button {
min-height: 28px;
padding: 0 10px;
border-radius: 7px;
font-size: 11px;
}
.wecom-picker-modal {
width: min(640px, 100%);
}
.wecom-picker-modal .import-guide-strip {
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.wecom-picker-search {
flex: 1 1 220px;
min-width: 180px;
padding: 8px 12px;
border: 1px solid rgb(20 40 34 / 0.16);
border-radius: 10px;
font-size: 13px;
background: white;
color: rgb(20 40 34);
}
.wecom-picker-cache-hint {
color: rgb(20 40 34 / 0.5);
font-size: 11px;
}
.wecom-picker-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
max-height: 48vh;
overflow-y: auto;
padding-right: 2px;
}
.wecom-picker-empty {
padding: 28px 12px;
text-align: center;
color: rgb(20 40 34 / 0.55);
font-size: 13px;
border: 1px dashed rgb(20 40 34 / 0.18);
border-radius: 12px;
}
.wecom-picker-row {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgb(20 40 34 / 0.08);
background: white;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
width: 100%;
}
.wecom-picker-row:hover:not(:disabled) {
border-color: rgb(20 184 166 / 0.4);
background: rgb(20 184 166 / 0.06);
}
.wecom-picker-row:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.wecom-picker-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.wecom-picker-avatar.placeholder {
display: grid;
place-items: center;
background: rgb(20 40 34 / 0.06);
color: rgb(20 40 34 / 0.55);
font-size: 14px;
font-weight: 600;
}
.wecom-picker-meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.wecom-picker-name {
font-size: 14px;
font-weight: 600;
color: rgb(20 40 34);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wecom-picker-sub {
font-size: 11px;
color: rgb(20 40 34 / 0.55);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wecom-picker-id {
font-size: 10px;
color: rgb(20 40 34 / 0.4);
word-break: break-all;
}
.wecom-picker-binding,
.wecom-picker-current {
font-size: 11px;
color: rgb(20 184 166);
padding: 2px 8px;
border: 1px solid rgb(20 184 166 / 0.35);
border-radius: 999px;
white-space: nowrap;
}
.wecom-picker-binding {
color: rgb(20 40 34 / 0.5);
border-color: rgb(20 40 34 / 0.18);
}
.group-push-modal {
width: min(720px, 100%);
}
.group-push-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.group-push-count {
font-size: 12px;
color: rgb(20 40 34 / 0.55);
}
.group-push-toolbar-actions {
display: flex;
gap: 8px;
}
.group-push-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgb(20 40 34 / 0.08);
background: white;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.group-push-row:hover {
border-color: rgb(20 184 166 / 0.4);
background: rgb(20 184 166 / 0.06);
}
.group-push-row input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: rgb(20 184 166);
}
.group-push-text {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
font-size: 12px;
color: rgb(20 40 34 / 0.6);
}
.group-push-text textarea {
width: 100%;
resize: vertical;
}
.group-push-result {
margin-top: 12px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgb(20 184 166 / 0.35);
background: rgb(20 184 166 / 0.06);
font-size: 12px;
color: rgb(20 40 34 / 0.75);
}
.group-push-result ul,
.group-push-history ul {
margin: 6px 0 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 2px;
}
.group-push-fail {
color: rgb(190 60 50 / 0.9);
}
.group-push-history {
margin-top: 12px;
font-size: 12px;
color: rgb(20 40 34 / 0.6);
}
.group-push-history-title {
font-weight: 600;
color: rgb(20 40 34 / 0.75);
}
@media (max-width: 540px) {
.wecom-picker-row {
grid-template-columns: 32px 1fr auto;
gap: 8px;
padding: 8px 10px;
}
.wecom-picker-avatar,
.wecom-picker-avatar.placeholder {
width: 32px;
height: 32px;
font-size: 12px;
}
}
@media (max-width: 720px) {
.wecom-status-panel .wecom-status-grid {
grid-template-columns: 1fr;
}
.partner-row,
.partner-row.head {
grid-template-columns: 1fr;
gap: 4px;
}
.partner-row.head {
display: none;
}
}
.task-hero {
display: flex;
min-height: 170px;
@@ -2182,6 +2583,39 @@ a {
overflow-y: auto;
}
.import-mode-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
padding: 4px;
border-radius: 11px;
background: #f1f4f2;
}
.import-mode-tabs button {
flex: 1;
height: 36px;
padding: 0 16px;
border: none;
border-radius: 8px;
background: transparent;
color: #687773;
font-size: 11px;
font-weight: 620;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.import-mode-tabs button:hover {
color: var(--ink);
}
.import-mode-tabs button.active {
background: white;
color: var(--green-deep);
box-shadow: 0 1px 2px rgb(31 49 41 / 0.08);
}
.import-guide-strip {
display: grid;
grid-template-columns: auto auto 1fr auto auto 1fr auto auto;