feat: 在账号资源库中展示导入的标签字段

KOC 资源导入模板新增「标签」列,导入后写入 accounts.tags 并在
账号资源库面板以 badge 形式展示,导出时也带回标签列以保证往返一致。

- lib/resource-import.ts: 新增 tags 字段与 HEADER 别名(标签/账号标签/人设标签),
  新增 normalizeTags/mergeTags 工具,按顿号规整与合并
- 修复 xlsx 解析器对自闭合空 cell (<c r="G6"/>) 的错位 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 解析/合并用例
This commit is contained in:
ABAPPLO
2026-08-18 16:39:38 +08:00
parent ad3dbdcc86
commit 582c26e57e
10 changed files with 145 additions and 8 deletions

View File

@@ -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) {

View File

@@ -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(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
for (const cellMatch of rowMatch[2].matchAll(
/<c\b([^>]*?)(?:\/>|>([\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(/<v>([\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}`);
}