From 582c26e57edb7a2e1ebdc4eea6dadd599f87263d Mon Sep 17 00:00:00 2001
From: ABAPPLO <1134226402@qq.com>
Date: Tue, 18 Aug 2026 16:39:38 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9C=A8=E8=B4=A6=E5=8F=B7=E8=B5=84?=
=?UTF-8?q?=E6=BA=90=E5=BA=93=E4=B8=AD=E5=B1=95=E7=A4=BA=E5=AF=BC=E5=85=A5?=
=?UTF-8?q?=E7=9A=84=E6=A0=87=E7=AD=BE=E5=AD=97=E6=AE=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
KOC 资源导入模板新增「标签」列,导入后写入 accounts.tags 并在
账号资源库面板以 badge 形式展示,导出时也带回标签列以保证往返一致。
- lib/resource-import.ts: 新增 tags 字段与 HEADER 别名(标签/账号标签/人设标签),
新增 normalizeTags/mergeTags 工具,按顿号规整与合并
- 修复 xlsx 解析器对自闭合空 cell () 的错位 bug,否则
行内出现空 cell 会让后续 cell 的列引用读到错误 body(如老茄子行标签丢失)
- db/schema.ts + lib/mvp-db.ts: accounts 表新增 tags VARCHAR(500)
- app/api/resources-import/route.ts: SELECT/INSERT/UPDATE/预览全部带上 tags
- app/api/resources-export/route.ts: 导出表加「标签」列
- app/admin-app.tsx + app/globals.css: 资源卡片新增「账号标签」区,
导入预览行展示标签
- public/KOC资源导入模板.xlsx: 模板补「标签(选填)」列与填写说明
- mysql/0005_account_tags.sql: 线上 MySQL 迁移
- tests/resource-import.test.mjs: 补 tags 解析/合并用例
---
app/admin-app.tsx | 24 ++++++++++++++++---
app/api/resources-export/route.ts | 4 ++++
app/api/resources-import/route.ts | 15 +++++++++---
app/globals.css | 37 ++++++++++++++++++++++++++++++
db/schema.ts | 2 ++
lib/mvp-db.ts | 30 ++++++++++++++++++++++++
lib/resource-import.ts | 24 +++++++++++++++++--
mysql/0005_account_tags.sql | 2 ++
public/KOC资源导入模板.xlsx | Bin 7580 -> 8239 bytes
tests/resource-import.test.mjs | 15 ++++++++++++
10 files changed, 145 insertions(+), 8 deletions(-)
create mode 100644 mysql/0005_account_tags.sql
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 15346f91b5315f4198e5b65ae258f8cb1eb09892..68b5547443f228dc25946c173636b4a9dd6eb511 100644
GIT binary patch
literal 8239
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
Rq7p9huZ)I*B1Ly`_g}42kgxy%
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("美食探店", "旅游出行;数码汽车"), "美食探店、旅游出行、数码汽车");
+});