diff --git a/app/admin-app.tsx b/app/admin-app.tsx index 5f63210..6d00602 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -60,6 +60,7 @@ type Account = { post_count: number; avg_views: number; cooperation_source: string; + tags: string; first_seen_at: string; last_seen_at: string; }; @@ -74,6 +75,7 @@ type ResourceImportPreview = { ipLocation: string; followers: number; cooperationSource: string; + tags: string; action: "create" | "update" | "error"; errors: string[]; }>; @@ -1991,6 +1993,14 @@ function ResourcesPage({ ].filter(Boolean), ), ].sort((left, right) => left.localeCompare(right, "zh-CN")), + tags: [ + ...new Set( + (account.tags || "") + .split(/[、,,;;|]/) + .map((item) => item.trim()) + .filter(Boolean), + ), + ], partnerManagedOnly: deliveries.some((item) => item.delegation_bundle_id) && deliveries.every((item) => item.delegation_bundle_id), @@ -2203,7 +2213,7 @@ function ResourcesPage({
- {filteredAccounts.map(({ account, sources, partnerManagedOnly }) => ( + {filteredAccounts.map(({ account, sources, tags: tagList, partnerManagedOnly }) => (
{account.nickname.slice(0, 1)}
@@ -2227,6 +2237,14 @@ function ResourcesPage({ )}
+ {tagList.length > 0 && ( +
+ 账号标签 +
+ {tagList.map((tag) => {tag})} +
+
+ )}
最近合作 {formatDate(account.last_seen_at)} {account.profile_url && 查看主页 ↗} @@ -2282,7 +2300,7 @@ function ResourcesPage({ {!importPreview && (
-
还没有模板?只有账号链接必填;已有昵称、账号ID、IP属地、粉丝数和合作来源可直接填写,系统只补全空缺字段。
+
还没有模板?只有账号链接必填;已有昵称、账号ID、IP属地、粉丝数、合作来源和标签可直接填写,系统只补全空缺字段。
下载模板
)} @@ -2303,7 +2321,7 @@ function ResourcesPage({ {row.rowNumber} {row.nickname || "—"}{row.platform || "未填写平台"} {row.publicAccountId || "主页识别"} - {row.ipLocation}{row.cooperationSource || "未填写来源"} + {row.ipLocation}{row.cooperationSource || "未填写来源"}{row.tags && {row.tags}} {row.action === "create" ? "新增" : row.action === "update" ? "更新" : row.errors.join(";")} diff --git a/app/api/resources-export/route.ts b/app/api/resources-export/route.ts index ef31c15..7e9b6bd 100644 --- a/app/api/resources-export/route.ts +++ b/app/api/resources-export/route.ts @@ -17,6 +17,7 @@ type AccountRow = { followers: number; post_count: number; cooperation_source: string; + tags: string; first_seen_at: string; last_seen_at: string; }; @@ -111,6 +112,7 @@ async function exportAccounts(accountIds: string[]) { "粉丝数", "合作发布数", "历史合作来源", + "标签", "资源归属", "首次合作时间", "最近合作时间", @@ -140,6 +142,7 @@ async function exportAccounts(accountIds: string[]) { account.followers, account.post_count, sources.join("、"), + account.tags || "", partnerManagedOnly ? "合作社资源 · 不可直联" : "可直联", formatExportDate(account.first_seen_at), formatExportDate(account.last_seen_at), @@ -163,6 +166,7 @@ async function exportAccounts(accountIds: string[]) { 14, 14, 32, + 28, 22, 21, 21, diff --git a/app/api/resources-import/route.ts b/app/api/resources-import/route.ts index 26b3022..690e4db 100644 --- a/app/api/resources-import/route.ts +++ b/app/api/resources-import/route.ts @@ -9,6 +9,7 @@ import { import { getRuntimeEnv } from "../../../lib/runtime-env"; import { mergeCooperationSources, + mergeTags, normalizeProfileUrl, parseResourceImportFile, RESOURCE_IMPORT_MAX_BYTES, @@ -27,6 +28,7 @@ type AccountRow = { ip_location: string; followers: number; cooperation_source: string; + tags: string; }; type AnalyzedRow = ResourceImportRow & { @@ -34,6 +36,7 @@ type AnalyzedRow = ResourceImportRow & { accountId: string; platformUid: string; cooperationSource: string; + tags: string; }; function identityKey(platform: string, value: string) { @@ -46,7 +49,7 @@ async function loadAccounts() { return getRawDb() .prepare( `SELECT id, platform, platform_uid, public_account_id, nickname, - profile_url, ip_location, followers, cooperation_source + profile_url, ip_location, followers, cooperation_source, tags FROM accounts`, ) .all(); @@ -187,6 +190,7 @@ function analyzeRows(rows: ResourceImportRow[], accountRows: AccountRow[]) { existing?.cooperation_source ?? "", row.cooperationSource, ), + tags: mergeTags(existing?.tags ?? "", row.tags), }; if (analyzed.action !== "error") { const virtual: AccountRow = { @@ -199,6 +203,7 @@ function analyzeRows(rows: ResourceImportRow[], accountRows: AccountRow[]) { ip_location: row.ipLocation || existing?.ip_location || "待识别", followers: row.followers || existing?.followers || 0, cooperation_source: analyzed.cooperationSource, + tags: analyzed.tags, }; if (row.profileUrl) profileMap.set(identityKey(row.platform, row.profileUrl), virtual); if (row.publicAccountId) { @@ -249,6 +254,7 @@ export async function POST(request: Request) { ipLocation: row.ipLocation, followers: row.followers, cooperationSource: row.cooperationSource, + tags: row.tags, action: row.action, errors: row.errors, })), @@ -275,6 +281,7 @@ export async function POST(request: Request) { WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END, followers = CASE WHEN ? = 1 THEN ? ELSE followers END, cooperation_source = ?, + tags = ?, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`, ) @@ -290,6 +297,7 @@ export async function POST(request: Request) { row.followersResolved ? 1 : 0, row.followers, row.cooperationSource, + row.tags, row.accountId, ) : db @@ -297,8 +305,8 @@ export async function POST(request: Request) { `INSERT INTO accounts (id, platform, platform_uid, public_account_id, nickname, profile_url, ip_location, followers, post_count, avg_views, - cooperation_source) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?)`, + cooperation_source, tags) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`, ) .bind( row.accountId, @@ -310,6 +318,7 @@ export async function POST(request: Request) { row.ipLocation, row.followers, row.cooperationSource, + row.tags, ), ); if (statements.length > 0) await db.batch(statements); diff --git a/app/globals.css b/app/globals.css index 1a9d597..f299a73 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2008,6 +2008,18 @@ a { font-size: 8px; } +.import-preview-row em { + display: block; + margin-top: 3px; + color: #4a5575; + background: #eef1f6; + border-radius: 4px; + padding: 2px 6px; + font-size: 8px; + font-style: normal; + font-weight: 580; +} + .import-result { color: #557269; font-weight: 650; @@ -2175,6 +2187,31 @@ a { background: #fff3e8; } +.resource-tags { + margin-top: 13px; +} + +.resource-tags > span { + color: #9ba4a1; + font-size: 8px; +} + +.resource-tags > div { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 8px; +} + +.resource-tags b { + padding: 5px 7px; + border-radius: 6px; + color: #4a5575; + background: #eef1f6; + font-size: 8px; + font-weight: 580; +} + .resource-card-foot { display: flex; justify-content: space-between; diff --git a/db/schema.ts b/db/schema.ts index 7f5ab7b..b0387ca 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -19,6 +19,7 @@ export const partners = mysqlTable("partners", { name: varchar("name", { length: 255 }).notNull(), wecomName: varchar("wecom_name", { length: 255 }).notNull(), owner: varchar("owner", { length: 255 }).notNull().default("运营组"), + wecomExternalUserId: varchar("wecom_external_user_id", { length: 128 }), claimedTotal: int("claimed_total").notNull().default(0), completedTotal: int("completed_total").notNull().default(0), createdAt: timestamp("created_at"), @@ -81,6 +82,7 @@ export const accounts = mysqlTable( cooperationSource: varchar("cooperation_source", { length: 500 }) .notNull() .default(""), + tags: varchar("tags", { length: 500 }).notNull().default(""), firstSeenAt: timestamp("first_seen_at"), lastSeenAt: timestamp("last_seen_at"), }, diff --git a/lib/mvp-db.ts b/lib/mvp-db.ts index 66687a9..d41132a 100644 --- a/lib/mvp-db.ts +++ b/lib/mvp-db.ts @@ -15,6 +15,7 @@ export function getUploadBucket() { export async function ensureSchema(database?: DatabaseClient) { const db = database ?? getRawDb(); + await ensurePartnersWecomColumn(db); { await db.prepare("SELECT id FROM tasks LIMIT 1").all(); @@ -81,6 +82,7 @@ export async function ensureSchema(database?: DatabaseClient) { name TEXT NOT NULL, wecom_name TEXT NOT NULL, owner TEXT NOT NULL DEFAULT '运营组', + wecom_external_user_id TEXT, claimed_total INTEGER NOT NULL DEFAULT 0, completed_total INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP @@ -127,6 +129,7 @@ export async function ensureSchema(database?: DatabaseClient) { post_count INTEGER NOT NULL DEFAULT 0, avg_views INTEGER NOT NULL DEFAULT 0, cooperation_source TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '', first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )`, @@ -293,6 +296,7 @@ export async function ensureSchema(database?: DatabaseClient) { "public_account_id", "public_account_id TEXT NOT NULL DEFAULT ''", ); + await ensureColumn("accounts", "tags", "tags TEXT NOT NULL DEFAULT ''"); await ensureColumn("distributions", "claim_id", "claim_id TEXT"); await ensureColumn( "distributions", @@ -832,6 +836,32 @@ export function uid(prefix: string) { return `${prefix}-${crypto.randomUUID().slice(0, 8)}`; } +async function ensurePartnersWecomColumn(db: DatabaseClient) { + try { + const rows = await db + .prepare( + `SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'partners' + AND COLUMN_NAME = 'wecom_external_user_id'`, + ) + .all<{ COLUMN_NAME: string }>(); + if (rows.results.length === 0) { + await db + .prepare( + "ALTER TABLE partners ADD COLUMN wecom_external_user_id TEXT", + ) + .run(); + } + } catch (error) { + console.warn( + "[KOC LOOP] failed to ensure partners.wecom_external_user_id column", + error, + ); + } +} + export function hashText(value: string) { let hash = 2166136261; for (let index = 0; index < value.length; index += 1) { diff --git a/lib/resource-import.ts b/lib/resource-import.ts index cb14ef7..8fb50ab 100644 --- a/lib/resource-import.ts +++ b/lib/resource-import.ts @@ -13,6 +13,7 @@ export type ResourceImportRow = { followers: number; followersResolved: boolean; cooperationSource: string; + tags: string; errors: string[]; }; @@ -23,6 +24,7 @@ const HEADER_ALIASES = { ipLocation: ["IP属地", "IP地址", "IP地区", "IP所在地"], followers: ["粉丝数", "粉丝", "粉丝量"], cooperationSource: ["合作来源", "资源来源", "历史合作来源"], + tags: ["标签", "账号标签", "人设标签"], } as const; type CanonicalHeader = keyof typeof HEADER_ALIASES; @@ -60,9 +62,11 @@ function parseWorksheet(xml: string, sharedStrings: string[]) { const rowAttributes = rowMatch[1]; const rowNumber = Number(rowAttributes.match(/\br="(\d+)"/)?.[1] ?? rows.length + 1); const values: string[] = []; - for (const cellMatch of rowMatch[2].matchAll(/]*)>([\s\S]*?)<\/c>/g)) { + for (const cellMatch of rowMatch[2].matchAll( + /]*?)(?:\/>|>([\s\S]*?)<\/c>)/g, + )) { const attributes = cellMatch[1]; - const body = cellMatch[2]; + const body = cellMatch[2] ?? ""; const reference = attributes.match(/\br="([A-Z]+\d+)"/i)?.[1] ?? "A1"; const type = attributes.match(/\bt="([^"]+)"/)?.[1] ?? ""; const rawValue = body.match(/([\s\S]*?)<\/v>/)?.[1] ?? ""; @@ -259,6 +263,7 @@ function normalizeRows(rows: string[][]) { followers: parsedFollowers.value, followersResolved: parsedFollowers.resolved, cooperationSource: valueAt(source, header.mapping, "cooperationSource"), + tags: normalizeTags(valueAt(source, header.mapping, "tags")), errors, }); } @@ -320,3 +325,18 @@ export function mergeCooperationSources(existing: string, incoming: string) { ), ].join("、"); } + +export function normalizeTags(value: string) { + return [ + ...new Set( + value + .split(/[、,,;;|]/) + .map((item) => item.trim()) + .filter(Boolean), + ), + ].join("、"); +} + +export function mergeTags(existing: string, incoming: string) { + return normalizeTags(`${existing}、${incoming}`); +} diff --git a/mysql/0005_account_tags.sql b/mysql/0005_account_tags.sql new file mode 100644 index 0000000..df2dd92 --- /dev/null +++ b/mysql/0005_account_tags.sql @@ -0,0 +1,2 @@ +ALTER TABLE accounts + ADD COLUMN tags VARCHAR(500) NOT NULL DEFAULT '' AFTER cooperation_source; diff --git a/public/KOC资源导入模板.xlsx b/public/KOC资源导入模板.xlsx index 15346f9..68b5547 100644 Binary files a/public/KOC资源导入模板.xlsx and b/public/KOC资源导入模板.xlsx differ diff --git a/tests/resource-import.test.mjs b/tests/resource-import.test.mjs index 456f2a8..64bf897 100644 --- a/tests/resource-import.test.mjs +++ b/tests/resource-import.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { buildRecoveryWorkbook } from "../lib/recovery-workbook.ts"; import { mergeCooperationSources, + mergeTags, normalizeProfileUrl, parseResourceFollowers, parseResourceImportFile, @@ -27,6 +28,7 @@ test("parses CSV resources and normalizes public profile data", () => { followers: 0, followersResolved: false, cooperationSource: "林林KOC社群", + tags: "", errors: [], }); assert.equal(resourcePlatformUid(rows[0]), "abc123"); @@ -115,3 +117,16 @@ test("normalizes profile URLs and merges cooperation sources", () => { "林林社群、木子、历史表格", ); }); + +test("parses and normalizes the optional tags column", () => { + const csv = [ + "账号链接,标签", + '"https://www.xiaohongshu.com/user/profile/abc123","美食探店, 旅游出行, 美食探店"', + ].join("\n"); + const [row] = parseResourceImportFile( + "resources.csv", + new TextEncoder().encode(csv), + ); + assert.equal(row.tags, "美食探店、旅游出行"); + assert.equal(mergeTags("美食探店", "旅游出行;数码汽车"), "美食探店、旅游出行、数码汽车"); +});