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({
最近合作 {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("美食探店", "旅游出行;数码汽车"), "美食探店、旅游出行、数码汽车");
+});