From e8bef334001f4acd55e1fcb18ae049449fe87c7f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 08:11:49 +0000 Subject: [PATCH] Fix Studio reserve edit/hire and list installed knowledge bases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio-pushed agents stay recruited+reserve (not talent-market available). Org editors can edit those employees without user_id, 录用 onboards them, and POST /api/xyai/knowledge/import writes folder=/ files so install lists. Signed-off-by: Cursor Agent Co-authored-by: XYAI Labs --- backend/db.ts | 6 + backend/routes/employees.ts | 15 +- backend/routes/org.ts | 38 +-- backend/routes/talent.ts | 12 +- backend/routes/xyai.ts | 328 ++++++++++++++++------ backend/services/workforce-access.ts | 36 +++ frontend/src/pages/EmployeeDetailPage.tsx | 38 ++- frontend/src/pages/EmployeesPage.tsx | 74 ++++- frontend/src/pages/OrgChart.tsx | 18 +- scripts/test-org-talent-ux.ts | 136 ++++++++- 10 files changed, 554 insertions(+), 147 deletions(-) create mode 100644 backend/services/workforce-access.ts diff --git a/backend/db.ts b/backend/db.ts index e24badb..12cafb0 100644 --- a/backend/db.ts +++ b/backend/db.ts @@ -121,6 +121,7 @@ export async function initDatabase(): Promise { tags TEXT, company_id INTEGER, tenant_id INTEGER DEFAULT 1, + external_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -1479,6 +1480,8 @@ function runMigrations() { try { db.run("ALTER TABLE employees ADD COLUMN source TEXT"); } catch(e) {} try { db.run("CREATE INDEX IF NOT EXISTS idx_talent_pool_external ON talent_pool(tenant_id, source, external_id)"); } catch(e) {} try { db.run("CREATE INDEX IF NOT EXISTS idx_employees_source ON employees(tenant_id, source)"); } catch(e) {} + try { db.run("ALTER TABLE knowledge_notes ADD COLUMN external_id TEXT"); } catch(e) {} + try { db.run("CREATE INDEX IF NOT EXISTS idx_knowledge_notes_external ON knowledge_notes(tenant_id, external_id)"); } catch(e) {} saveDb(); console.log("[迁移] 员工管理(人才管理)表已就绪"); @@ -1541,12 +1544,15 @@ function runMigrations() { page_count INTEGER, parsed_at DATETIME, uploaded_by INTEGER, + external_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); try { db.run('CREATE INDEX IF NOT EXISTS idx_kf_tenant ON knowledge_files(tenant_id, status)'); } catch(e) {} try { db.run('CREATE INDEX IF NOT EXISTS idx_kf_folder ON knowledge_files(tenant_id, folder)'); } catch(e) {} + try { db.run("ALTER TABLE knowledge_files ADD COLUMN external_id TEXT"); } catch(e) {} + try { db.run("CREATE INDEX IF NOT EXISTS idx_knowledge_files_external ON knowledge_files(tenant_id, external_id)"); } catch(e) {} // 知识库文件夹表 db.run(` diff --git a/backend/routes/employees.ts b/backend/routes/employees.ts index 08a816d..9e967f3 100644 --- a/backend/routes/employees.ts +++ b/backend/routes/employees.ts @@ -3,6 +3,7 @@ import { dbAll, dbGet, dbRun } from "../db"; import { authenticate, AuthRequest, requireAdmin } from "../middleware"; import { localizedError } from "../utils/locale"; import { logActivity } from "../services/notification"; +import { findEmployeeForOrgEdit } from "../services/workforce-access"; import bcrypt from "bcryptjs"; export const employeeRoutes = Router(); @@ -94,20 +95,26 @@ employeeRoutes.get("/stats/by-category", (req: AuthRequest, res) => { } }); -// 备选员工入职(reserve → internal) +// 备选员工入职 / 录用(reserve → internal) employeeRoutes.post("/:id/onboard", (req: AuthRequest, res) => { try { + const emp = findEmployeeForOrgEdit(req.user!, req.params.id); + if (!emp) return res.status(404).json({ success: false, error: employeeError(req, "员工不存在或无权限", "Employee not found or access is denied") }); + if (emp.employment_category === "internal") { + return res.status(400).json({ success: false, error: employeeError(req, "该员工已录用为内部员工", "This employee is already hired as internal staff") }); + } + const { department_id, role } = req.body; const updates: string[] = ["employment_category = 'internal'"]; const params: any[] = []; - if (department_id !== undefined) { updates.push("department_id = ?"); params.push(department_id); } + if (department_id !== undefined) { updates.push("department_id = ?"); params.push(department_id || null); } if (role !== undefined) { updates.push("role = ?"); params.push(role); } - params.push(req.params.id, req.user!.tenant_id); + params.push(emp.id, emp.tenant_id); dbRun(`UPDATE employees SET ${updates.join(", ")} WHERE id = ? AND tenant_id = ?`, params); - res.json({ success: true }); + res.json({ success: true, data: { id: emp.id, employment_category: "internal" } }); } catch (err: any) { res.status(500).json({ success: false, error: employeeError(req, "服务暂时不可用,请稍后重试", "Service is temporarily unavailable. Please try again") }); } diff --git a/backend/routes/org.ts b/backend/routes/org.ts index 784d5e5..01d062d 100644 --- a/backend/routes/org.ts +++ b/backend/routes/org.ts @@ -3,6 +3,7 @@ import { dbAll, dbGet, dbRun } from "../db"; import { authenticate, requireAdmin, AuthRequest } from "../middleware"; import { upload } from "../middleware/upload"; import { localizedError } from "../utils/locale"; +import { findEmployeeForOrgEdit } from "../services/workforce-access"; export const orgRoutes = Router(); orgRoutes.use(authenticate); @@ -151,23 +152,13 @@ orgRoutes.put("/departments/reorder", requireAdmin, (req: AuthRequest, res) => { } catch { res.status(500).json({ success: false, error: orgError(req, "组织服务暂时不可用,请稍后重试", "Organization service is temporarily unavailable. Please try again") }); } }); -// 员工信息编辑(超级管理员可跨租户,管理员可改本租户,普通用户仅可改自己) +// 员工信息编辑(超级管理员可跨租户,管理员可改本租户;普通用户可改自己,或编辑 Studio/备选 AI 组织资产) orgRoutes.put("/employees/:id", (req: AuthRequest, res) => { try { const eid = req.params.id; const user = req.user!; const isSuperAdmin = user.role === "super_admin"; - const isAdmin = user.role === "admin"; - - // 按角色权限获取员工:超级管理员可查任意,管理员查本租户,普通用户仅查自己 - let emp: any; - if (isSuperAdmin) { - emp = dbGet("SELECT * FROM employees WHERE id = ?", [eid]); - } else if (isAdmin) { - emp = dbGet("SELECT * FROM employees WHERE id = ? AND tenant_id = ?", [eid, user.tenant_id]); - } else { - emp = dbGet("SELECT * FROM employees WHERE id = ? AND tenant_id = ? AND user_id = ?", [eid, user.tenant_id, user.id]); - } + const emp = findEmployeeForOrgEdit(user, eid); if (!emp) return res.status(404).json({ success: false, error: orgError(req, "员工不存在或无权限", "Employee not found or access is denied") }); const { name, role, description, skills, agent_type, department_id, employee_type, avatar_emoji, status } = req.body; @@ -200,17 +191,8 @@ orgRoutes.post("/employees/:id/avatar", upload.single("file"), (req: AuthRequest const eid = req.params.id; const user = req.user!; const isSuperAdmin = user.role === "super_admin"; - const isAdmin = user.role === "admin"; - // 按角色权限获取员工 - let emp: any; - if (isSuperAdmin) { - emp = dbGet("SELECT * FROM employees WHERE id = ?", [eid]); - } else if (isAdmin) { - emp = dbGet("SELECT * FROM employees WHERE id = ? AND tenant_id = ?", [eid, user.tenant_id]); - } else { - emp = dbGet("SELECT * FROM employees WHERE id = ? AND tenant_id = ? AND user_id = ?", [eid, user.tenant_id, user.id]); - } + const emp = findEmployeeForOrgEdit(user, eid); if (!emp) return res.status(404).json({ success: false, error: orgError(req, "员工不存在或无权限", "Employee not found or access is denied") }); if (!req.file) return res.status(400).json({ success: false, error: orgError(req, "请选择图片文件", "Please select an image file") }); @@ -297,18 +279,8 @@ orgRoutes.post("/employees/:id/avatar-preset", (req: AuthRequest, res) => { const eid = req.params.id; const user = req.user!; const isSuperAdmin = user.role === "super_admin"; - const isAdmin = user.role === "admin"; - - // 按角色权限获取员工 - let emp: any; - if (isSuperAdmin) { - emp = dbGet("SELECT * FROM employees WHERE id = ?", [eid]); - } else if (isAdmin) { - emp = dbGet("SELECT * FROM employees WHERE id = ? AND tenant_id = ?", [eid, user.tenant_id]); - } else { - emp = dbGet("SELECT * FROM employees WHERE id = ? AND tenant_id = ? AND user_id = ?", [eid, user.tenant_id, user.id]); - } + const emp = findEmployeeForOrgEdit(user, eid); if (!emp) return res.status(404).json({ success: false, error: orgError(req, "员工不存在或无权限", "Employee not found or access is denied") }); const { avatar_url } = req.body; diff --git a/backend/routes/talent.ts b/backend/routes/talent.ts index 3ce1c9b..79d3a80 100644 --- a/backend/routes/talent.ts +++ b/backend/routes/talent.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import { dbAll, dbGet, dbRun } from "../db"; import { authenticate, requireAdmin, AuthRequest } from "../middleware"; import { localizedError } from "../utils/locale"; +import { isStudioEmployeeSource } from "../services/workforce-access"; export const talentRoutes = Router(); talentRoutes.use(authenticate); @@ -51,8 +52,17 @@ talentRoutes.get("/stats", (req: AuthRequest, res) => { // 招募人才 → 加入备选员工库(仅管理员) talentRoutes.post("/:id/recruit", requireAdmin, (req: AuthRequest, res) => { try { - const talent = dbGet("SELECT * FROM talent_pool WHERE id = ? AND tenant_id = ?", [req.params.id, req.user!.tenant_id]); + const talent = dbGet("SELECT * FROM talent_pool WHERE id = ? AND tenant_id = ?", [req.params.id, req.user!.tenant_id]) as any; if (!talent) return res.status(404).json({ success: false, error: talentError(req, "人才不存在", "Talent not found") }); + if (isStudioEmployeeSource(talent.source)) { + return res.status(400).json({ + success: false, + error: talentError(req, "Studio 推送的智能体已在备选员工中,请直接编辑或录用,无需从人才市场招募", "Studio-pushed agents are already in the reserve pool. Edit or hire them there; talent-market recruit is only for available customized agents."), + }); + } + if (talent.status !== "available") { + return res.status(400).json({ success: false, error: talentError(req, "该人才当前不可招募", "This talent is not available to recruit") }); + } // 写入 employees 表(备选状态) const result = dbRun( diff --git a/backend/routes/xyai.ts b/backend/routes/xyai.ts index a00a878..5457233 100644 --- a/backend/routes/xyai.ts +++ b/backend/routes/xyai.ts @@ -1,6 +1,10 @@ import { Router } from "express"; +import crypto from "crypto"; +import fs from "fs"; +import path from "path"; import { dbGet, dbRun } from "../db"; import { authenticate, AuthRequest } from "../middleware"; +import { UPLOAD_DIR } from "../middleware/upload"; import { localizedError } from "../utils/locale"; export const XYAI_INTEROP_HEADER = "x-xyai-interop"; @@ -15,6 +19,10 @@ function cleanText(value: unknown, max: number): string { return typeof value === "string" ? value.trim().slice(0, max) : ""; } +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + function asStringList(value: unknown): string[] { if (Array.isArray(value)) { return value.map((item) => cleanText(item, 80)).filter(Boolean).slice(0, 32); @@ -34,8 +42,13 @@ function requireStudioInterop(req: AuthRequest, res: import("express").Response, next(); } -function resolveDepartmentId(tenantId: number, body: any): number | null { - const rawId = body?.department_id ?? body?.departmentId; +function interopAsset(body: any): { asset: Record; payload: Record } { + const asset = asRecord(body?.asset); + return { asset, payload: asRecord(asset.payload) }; +} + +function resolveDepartmentId(tenantId: number, body: any, payload: Record): number | null { + const rawId = body?.department_id ?? body?.departmentId ?? payload.department_id ?? payload.departmentId; if (rawId !== undefined && rawId !== null && rawId !== "") { const id = Number(rawId); if (Number.isSafeInteger(id) && id > 0) { @@ -43,104 +56,243 @@ function resolveDepartmentId(tenantId: number, body: any): number | null { return dept?.id ?? null; } } - const name = cleanText(body?.department ?? body?.department_name ?? body?.departmentName, 80); + const name = cleanText(body?.department ?? body?.department_name ?? body?.departmentName ?? payload.department ?? payload.department_name, 80); if (!name) return null; const dept = dbGet("SELECT id FROM departments WHERE tenant_id = ? AND name = ?", [tenantId, name]) as { id: number } | undefined; return dept?.id ?? null; } -// Studio 开发空间 → 备选员工. Contract for XYAI Studio `components/openxyos` submodule: +function importStudioAgent(req: AuthRequest, res: import("express").Response) { + const tenantId = req.user!.tenant_id; + const { asset, payload } = interopAsset(req.body); + const kind = cleanText(asset.kind ?? req.body?.kind, 40); + if (kind === "knowledge-mount" || kind === "knowledge") { + return res.status(400).json({ + success: false, + error: xyaiError(req, "知识库请使用 /api/xyai/knowledge/import", "Use /api/xyai/knowledge/import for knowledge bases"), + }); + } + + const name = cleanText(req.body?.name ?? asset.name, 80); + if (!name) return res.status(400).json({ success: false, error: xyaiError(req, "智能体名称必填", "Agent name is required") }); + + const role = cleanText(req.body?.role ?? req.body?.title ?? payload.role ?? payload.title ?? payload.category, 80); + const positioning = cleanText(req.body?.positioning ?? payload.positioning ?? payload.subtitle, 400); + const industry = cleanText(req.body?.industry ?? payload.industry, 80); + const description = cleanText(req.body?.description ?? asset.description, 12000) + || [positioning, industry ? `行业:${industry}` : ""].filter(Boolean).join("\n"); + const skills = asStringList(req.body?.skills ?? payload.skills ?? payload.capabilities).join(","); + const capabilities = asStringList(req.body?.capabilities ?? payload.capabilities); + const agentType = cleanText(req.body?.agent_type ?? req.body?.agentType ?? payload.agent_type, 80); + const avatarEmoji = cleanText(req.body?.avatar_emoji ?? req.body?.avatarEmoji ?? payload.avatar_emoji ?? payload.emoji, 16) || "🤖"; + const employeeType = cleanText(req.body?.employee_type ?? req.body?.employeeType ?? payload.employee_type, 16) === "human" ? "human" : "ai"; + const externalId = cleanText(req.body?.external_id ?? req.body?.source_id ?? req.body?.studio_id ?? asset.id ?? payload.agentId, 120); + const departmentId = resolveDepartmentId(tenantId, req.body, payload); + const employeeSource = externalId ? `studio:${externalId}` : "studio"; + const capabilityPayload = JSON.stringify({ + schema: "openxyos.studio-agent.v1", + capabilities, + imported_from: "xyai-studio", + ...(externalId ? { interop_id: externalId } : {}), + }); + + let talent = externalId + ? dbGet("SELECT * FROM talent_pool WHERE tenant_id = ? AND source = 'studio' AND external_id = ?", [tenantId, externalId]) as any + : undefined; + let employee = externalId + ? dbGet("SELECT * FROM employees WHERE tenant_id = ? AND source = ?", [tenantId, employeeSource]) as any + : undefined; + + let talentId: number; + let employeeId: number; + let created = false; + let updated = false; + + if (talent) { + dbRun( + `UPDATE talent_pool SET name = ?, avatar_emoji = ?, skills = ?, category = ?, description = ?, agent_type = ?, capabilities = ?, status = 'recruited', updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND tenant_id = ?`, + [name, avatarEmoji, skills, role, description, agentType || talent.agent_type, capabilityPayload, talent.id, tenantId], + ); + talentId = Number(talent.id); + updated = true; + } else { + const inserted = dbRun( + `INSERT INTO talent_pool + (tenant_id, talent_type, name, avatar_emoji, skills, category, description, source, rating, status, agent_type, capabilities, provider, integration_type, external_id) + VALUES (?, ?, ?, ?, ?, ?, ?, 'studio', 5, 'recruited', ?, ?, 'studio', 'studio-interop', ?)`, + [tenantId, employeeType === "human" ? "human" : "ai", name, avatarEmoji, skills, role, description, agentType || null, capabilityPayload, externalId || null], + ); + talentId = Number(inserted.lastInsertRowid); + created = true; + } + + if (employee) { + dbRun( + `UPDATE employees SET name = ?, role = ?, agent_type = ?, employee_type = ?, skills = ?, avatar_emoji = ?, description = ?, department_id = COALESCE(?, department_id) + WHERE id = ? AND tenant_id = ?`, + [name, role, agentType || employee.agent_type, employeeType, skills, avatarEmoji, description, departmentId, employee.id, tenantId], + ); + employeeId = Number(employee.id); + updated = true; + } else { + const inserted = dbRun( + `INSERT INTO employees + (company_id, department_id, name, role, agent_type, employee_type, skills, avatar_emoji, status, employment_category, description, tenant_id, source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', 'reserve', ?, ?, ?)`, + [1, departmentId, name, role, agentType || null, employeeType, skills, avatarEmoji, description, tenantId, employeeSource], + ); + employeeId = Number(inserted.lastInsertRowid); + created = true; + } + + return res.status(employee ? 200 : 201).json({ + success: true, + data: { + talent_id: talentId, + employee_id: employeeId, + employment_category: "reserve", + status: "recruited", + department_id: departmentId, + created, + updated, + talent: { id: talentId, action: employee ? "updated" : "created" }, + employee: { id: employeeId, action: employee ? "updated" : "created" }, + }, + }); +} + +function writeKnowledgeSnapshot(tenantId: number, body: string): { storedPath: string; sizeKB: number } { + const tenantDir = path.join(UPLOAD_DIR, "tenants", String(tenantId)); + fs.mkdirSync(tenantDir, { recursive: true, mode: 0o750 }); + const storedPath = path.join(tenantDir, `${crypto.randomUUID()}.md`); + fs.writeFileSync(storedPath, body, { encoding: "utf8", mode: 0o640 }); + return { storedPath, sizeKB: Math.max(1, Math.round(Buffer.byteLength(body, "utf8") / 1024)) }; +} + +function importStudioKnowledge(req: AuthRequest, res: import("express").Response) { + const tenantId = req.user!.tenant_id; + const { asset, payload } = interopAsset(req.body); + const name = cleanText(req.body?.name ?? req.body?.title ?? asset.name, 160); + if (!name) return res.status(400).json({ success: false, error: xyaiError(req, "知识库名称必填", "Knowledge base name is required") }); + + const description = cleanText(req.body?.description ?? req.body?.content ?? asset.description ?? payload.subtitle, 12000); + const externalId = cleanText( + req.body?.external_id ?? req.body?.source_id ?? req.body?.studio_id ?? req.body?.kbId ?? asset.id ?? payload.kbId, + 120, + ); + const tags = cleanText(req.body?.tags ?? payload.tags, 240); + const folder = "/"; + const sourceRoot = cleanText(payload.sourceRoot ?? req.body?.sourceRoot, 1000); + const indexRoot = cleanText(payload.indexRoot ?? req.body?.indexRoot, 1000); + const provider = cleanText(payload.provider ?? req.body?.provider, 80); + const mountKind = cleanText(payload.mountKind ?? req.body?.mountKind, 40); + const snapshot = [ + `# ${name}`, + "", + description || "Studio 推送的知识库挂接快照。", + "", + mountKind ? `- kind: ${mountKind}` : "", + sourceRoot ? `- sourceRoot: ${sourceRoot}` : "", + indexRoot ? `- indexRoot: ${indexRoot}` : "", + provider ? `- provider: ${provider}` : "", + externalId ? `- external_id: ${externalId}` : "", + ].filter((line) => line !== "").join("\n"); + const summary = (description || snapshot).replace(/\n/g, " ").slice(0, 300); + const noteContent = description || snapshot; + + const existingFile = externalId + ? dbGet("SELECT * FROM knowledge_files WHERE tenant_id = ? AND external_id = ?", [tenantId, externalId]) as any + : undefined; + const existingNote = externalId + ? dbGet("SELECT * FROM knowledge_notes WHERE tenant_id = ? AND external_id = ?", [tenantId, externalId]) as any + : undefined; + + let fileId: number; + let noteId: number; + let created = false; + let updated = false; + + if (existingFile) { + dbRun( + `UPDATE knowledge_files SET name = ?, original_name = ?, extracted_summary = ?, content_extracted = ?, keywords = ?, folder = ?, status = 'parsed', updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND tenant_id = ?`, + [name, name, summary, snapshot.slice(0, 10000), tags, folder, existingFile.id, tenantId], + ); + fileId = Number(existingFile.id); + updated = true; + } else { + const written = writeKnowledgeSnapshot(tenantId, snapshot); + const inserted = dbRun( + `INSERT INTO knowledge_files (tenant_id, name, original_name, file_path, file_size, file_type, folder, status, content_extracted, extracted_summary, keywords, uploaded_by, external_id) + VALUES (?, ?, ?, ?, ?, '.md', ?, 'parsed', ?, ?, ?, ?, ?)`, + [tenantId, path.basename(written.storedPath), name, written.storedPath, written.sizeKB, folder, snapshot.slice(0, 10000), summary, tags, req.user!.id, externalId || null], + ); + fileId = Number(inserted.lastInsertRowid); + created = true; + } + + if (existingNote) { + dbRun( + "UPDATE knowledge_notes SET title = ?, content = ?, tags = ?, source = 'studio' WHERE id = ? AND tenant_id = ?", + [name, noteContent, tags, existingNote.id, tenantId], + ); + noteId = Number(existingNote.id); + updated = true; + } else { + const inserted = dbRun( + "INSERT INTO knowledge_notes (title, content, tags, source, tenant_id, external_id) VALUES (?, ?, ?, 'studio', ?, ?)", + [name, noteContent, tags, tenantId, externalId || null], + ); + noteId = Number(inserted.lastInsertRowid); + created = true; + } + + return res.status(existingFile ? 200 : 201).json({ + success: true, + data: { + file_id: fileId, + note_id: noteId, + folder, + external_id: externalId || null, + created, + updated, + }, + }); +} + +// Studio 开发空间 → 备选员工. Contract: // POST /api/xyai/agents/import // Header X-XYAI-Interop: studio -// Body: name (required), role|title, description, department|department_name|department_id, -// agent_type, skills[], capabilities[], avatar_emoji, employee_type, external_id|source_id|studio_id +// Body: name (required) or asset { id, kind, name, description, payload } +// Result: talent_pool.status=recruited (not listed in 人才市场) + employees.employment_category=reserve +// Capabilities schema remains openxyos.studio-agent.v1 xyaiRoutes.post("/agents/import", requireStudioInterop, (req: AuthRequest, res) => { try { - const tenantId = req.user!.tenant_id; - const name = cleanText(req.body?.name, 80); - if (!name) return res.status(400).json({ success: false, error: xyaiError(req, "智能体名称必填", "Agent name is required") }); - - const role = cleanText(req.body?.role ?? req.body?.title, 80); - const positioning = cleanText(req.body?.positioning, 400); - const industry = cleanText(req.body?.industry, 80); - const description = cleanText(req.body?.description, 12000) - || [positioning, industry ? `行业:${industry}` : ""].filter(Boolean).join("\n"); - const skills = asStringList(req.body?.skills).join(","); - const capabilities = asStringList(req.body?.capabilities); - const agentType = cleanText(req.body?.agent_type ?? req.body?.agentType, 80); - const avatarEmoji = cleanText(req.body?.avatar_emoji ?? req.body?.avatarEmoji, 16) || "🤖"; - const employeeType = cleanText(req.body?.employee_type ?? req.body?.employeeType, 16) === "human" ? "human" : "ai"; - const externalId = cleanText(req.body?.external_id ?? req.body?.source_id ?? req.body?.studio_id, 120); - const departmentId = resolveDepartmentId(tenantId, req.body); - const employeeSource = externalId ? `studio:${externalId}` : "studio"; - const capabilityPayload = JSON.stringify({ - schema: "openxyos.studio-agent.v1", - capabilities, - imported_from: "xyai-studio", - }); + importStudioAgent(req, res); + } catch { + res.status(500).json({ success: false, error: xyaiError(req, "智能体导入暂时不可用,请稍后重试", "Agent import is temporarily unavailable. Please try again") }); + } +}); - let talent = externalId - ? dbGet("SELECT * FROM talent_pool WHERE tenant_id = ? AND source = 'studio' AND external_id = ?", [tenantId, externalId]) as any - : undefined; - let employee = externalId - ? dbGet("SELECT * FROM employees WHERE tenant_id = ? AND source = ?", [tenantId, employeeSource]) as any - : undefined; - - let talentId: number; - let employeeId: number; - let created = false; - let updated = false; - - if (talent) { - dbRun( - `UPDATE talent_pool SET name = ?, avatar_emoji = ?, skills = ?, category = ?, description = ?, agent_type = ?, capabilities = ?, status = 'recruited', updated_at = CURRENT_TIMESTAMP - WHERE id = ? AND tenant_id = ?`, - [name, avatarEmoji, skills, role, description, agentType || talent.agent_type, capabilityPayload, talent.id, tenantId], - ); - talentId = Number(talent.id); - updated = true; - } else { - const inserted = dbRun( - `INSERT INTO talent_pool - (tenant_id, talent_type, name, avatar_emoji, skills, category, description, source, rating, status, agent_type, capabilities, provider, integration_type, external_id) - VALUES (?, ?, ?, ?, ?, ?, ?, 'studio', 5, 'recruited', ?, ?, 'studio', 'studio-interop', ?)`, - [tenantId, employeeType === "human" ? "human" : "ai", name, avatarEmoji, skills, role, description, agentType || null, capabilityPayload, externalId || null], - ); - talentId = Number(inserted.lastInsertRowid); - created = true; - } +// Studio knowledge-mount → 知识库列表(folder=/) +xyaiRoutes.post("/knowledge/import", requireStudioInterop, (req: AuthRequest, res) => { + try { + importStudioKnowledge(req, res); + } catch { + res.status(500).json({ success: false, error: xyaiError(req, "知识库导入暂时不可用,请稍后重试", "Knowledge import is temporarily unavailable. Please try again") }); + } +}); - if (employee) { - dbRun( - `UPDATE employees SET name = ?, role = ?, agent_type = ?, employee_type = ?, skills = ?, avatar_emoji = ?, description = ?, department_id = COALESCE(?, department_id) - WHERE id = ? AND tenant_id = ?`, - [name, role, agentType || employee.agent_type, employeeType, skills, avatarEmoji, description, departmentId, employee.id, tenantId], - ); - employeeId = Number(employee.id); - updated = true; - } else { - const inserted = dbRun( - `INSERT INTO employees - (company_id, department_id, name, role, agent_type, employee_type, skills, avatar_emoji, status, employment_category, description, tenant_id, source) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', 'reserve', ?, ?, ?)`, - [1, departmentId, name, role, agentType || null, employeeType, skills, avatarEmoji, description, tenantId, employeeSource], - ); - employeeId = Number(inserted.lastInsertRowid); - created = true; +// Studio xyos-bridge inbox fallback: route by asset.kind +xyaiRoutes.post("/inbox", requireStudioInterop, (req: AuthRequest, res) => { + try { + const kind = cleanText(req.body?.kind ?? req.body?.asset?.kind, 40); + if (kind === "knowledge-mount" || kind === "knowledge") { + return importStudioKnowledge(req, res); } - - res.status(employee ? 200 : 201).json({ - success: true, - data: { - talent_id: talentId, - employee_id: employeeId, - employment_category: "reserve", - department_id: departmentId, - created, - updated, - }, - }); + return importStudioAgent(req, res); } catch { - res.status(500).json({ success: false, error: xyaiError(req, "智能体导入暂时不可用,请稍后重试", "Agent import is temporarily unavailable. Please try again") }); + res.status(500).json({ success: false, error: xyaiError(req, "互通导入暂时不可用,请稍后重试", "Interop import is temporarily unavailable. Please try again") }); } }); diff --git a/backend/services/workforce-access.ts b/backend/services/workforce-access.ts new file mode 100644 index 0000000..c3739b5 --- /dev/null +++ b/backend/services/workforce-access.ts @@ -0,0 +1,36 @@ +import { dbGet } from "../db"; +import type { AuthUser } from "../middleware"; + +export type WorkforceEmployee = { + id: number; + tenant_id: number; + user_id?: number | null; + source?: string | null; + employee_type?: string | null; + employment_category?: string | null; +}; + +export function isStudioEmployeeSource(source: unknown): boolean { + const value = String(source || "").trim().toLowerCase(); + return value === "studio" || value === "xyai-studio" || value.startsWith("studio:"); +} + +export function isOrgEditableInteropEmployee(emp: WorkforceEmployee | null | undefined): boolean { + if (!emp) return false; + if (isStudioEmployeeSource(emp.source)) return true; + return emp.employment_category === "reserve" && emp.employee_type === "ai" && !emp.user_id; +} + +export function findEmployeeForOrgEdit(user: AuthUser, employeeId: string | number): WorkforceEmployee | undefined { + const eid = String(employeeId); + if (user.role === "super_admin") { + return dbGet("SELECT * FROM employees WHERE id = ?", [eid]) as WorkforceEmployee | undefined; + } + + const emp = dbGet("SELECT * FROM employees WHERE id = ? AND tenant_id = ?", [eid, user.tenant_id]) as WorkforceEmployee | undefined; + if (!emp) return undefined; + if (user.role === "admin") return emp; + if (emp.user_id && Number(emp.user_id) === Number(user.id)) return emp; + if (isOrgEditableInteropEmployee(emp)) return emp; + return undefined; +} diff --git a/frontend/src/pages/EmployeeDetailPage.tsx b/frontend/src/pages/EmployeeDetailPage.tsx index 8c6d2f4..ceb1151 100644 --- a/frontend/src/pages/EmployeeDetailPage.tsx +++ b/frontend/src/pages/EmployeeDetailPage.tsx @@ -18,6 +18,8 @@ interface Employee { position_level_id?: number; position_sequence?: string; is_online?: boolean; pid?: string; avatar_url?: string; user_id?: number; + source?: string; + employment_category?: string; } interface Department { @@ -64,9 +66,12 @@ export default function EmployeeDetailPage() { const [selectedSkillIds, setSelectedSkillIds] = useState>(new Set()); const [skillCategory, setSkillCategory] = useState("all"); - // 权限判断:管理员 或 本人(employee.user_id 匹配当前用户) + // 权限判断:管理员、本人,或 Studio/备选 AI(无 user_id 的组织资产) const isOwnProfile = !!(user && employee && employee.user_id === user.id); - const canEdit = isAdmin || isOwnProfile; + const isStudioSource = String(employee?.source || "").toLowerCase() === "studio" + || String(employee?.source || "").toLowerCase() === "xyai-studio" + || String(employee?.source || "").startsWith("studio:"); + const canEdit = isAdmin || isOwnProfile || isStudioSource || (employee?.employment_category === "reserve" && employee?.employee_type === "ai" && !employee?.user_id); // Edit form state const [editName, setEditName] = useState(""); @@ -199,19 +204,43 @@ export default function EmployeeDetailPage() { const handleSave = async () => { if (!id || !editName.trim()) return; setSaving(true); - await authFetch(`/api/org/employees/${id}`, { + const r = await authFetch(`/api/org/employees/${id}`, { method: "PUT", body: JSON.stringify({ name: editName, role: editRole, description: editDescription, avatar_emoji: editAvatar, department_id: editDepartmentId || null, }), }); + const d = await r.json(); setSaving(false); + if (!d.success) { + alert(d.error || t("保存失败", "Save failed")); + return; + } setEditing(false); notifyWorkforceChanged(); fetchData(); }; + const handleHire = async () => { + if (!id) return; + if (!confirm(t("确认录用该备选员工为内部员工?", "Hire this reserve employee as internal staff?"))) return; + const r = await authFetch(`/api/employees/${id}/onboard`, { + method: "POST", + body: JSON.stringify({ + department_id: editDepartmentId || employee?.department_id || undefined, + role: editRole || employee?.role || undefined, + }), + }); + const d = await r.json(); + if (!d.success) { + alert(d.error || t("录用失败", "Hire failed")); + return; + } + notifyWorkforceChanged(); + fetchData(); + }; + const handleSaveSkills = async () => { if (!id) return; await authFetch(`/api/org/employees/${id}/skills`, { @@ -292,6 +321,9 @@ export default function EmployeeDetailPage() { {canEdit && !editing && ( )} + {canEdit && employee.employment_category === "reserve" && ( + + )} {isAdmin && employee.status === "active" && ( {!isInternal ? ( - + <> + + {isAdmin && !isStudioSource(emp.source) && ( + + )} + ) : ( - + isAdmin && ( + + ) )} )} diff --git a/frontend/src/pages/OrgChart.tsx b/frontend/src/pages/OrgChart.tsx index cd5d5b2..7477da3 100644 --- a/frontend/src/pages/OrgChart.tsx +++ b/frontend/src/pages/OrgChart.tsx @@ -1540,10 +1540,24 @@ function AddEmpModal({ try { if (mode === "reserve") { if (!selectedReserveId) return; - await authFetch(`/api/org/employees/${selectedReserveId}`, { + const assigned = await authFetch(`/api/org/employees/${selectedReserveId}`, { method: "PUT", body: JSON.stringify({ department_id: departmentId }), }); + const assignedBody = await assigned.json(); + if (!assignedBody.success) { + alert(assignedBody.error || t("保存失败", "Save failed")); + return; + } + const hired = await authFetch(`/api/employees/${selectedReserveId}/onboard`, { + method: "POST", + body: JSON.stringify({ department_id: departmentId }), + }); + const hiredBody = await hired.json(); + if (!hiredBody.success) { + alert(hiredBody.error || t("录用失败", "Hire failed")); + return; + } } else { if (!name.trim()) return; await authFetch("/api/org/employees", { @@ -1678,7 +1692,7 @@ function AddEmpModal({ disabled={saving || (mode === "create" ? !name.trim() : !selectedReserveId)} className="px-5 py-2 bg-blue-500 text-white text-sm rounded-xl hover:bg-blue-600 disabled:opacity-50" > - {saving ? t("保存中...", "Saving...") : mode === "reserve" ? t("加入部门", "Add to department") : t("创建", "Create")} + {saving ? t("保存中...", "Saving...") : mode === "reserve" ? t("录用", "Hire") : t("创建", "Create")} diff --git a/scripts/test-org-talent-ux.ts b/scripts/test-org-talent-ux.ts index 3326aa3..c30aea3 100644 --- a/scripts/test-org-talent-ux.ts +++ b/scripts/test-org-talent-ux.ts @@ -34,6 +34,8 @@ async function main() { const { orgRoutes } = await import("../backend/routes/org"); const { employeeRoutes } = await import("../backend/routes/employees"); const { xyaiRoutes } = await import("../backend/routes/xyai"); + const { talentRoutes } = await import("../backend/routes/talent"); + const { knowledgeRoutes } = await import("../backend/routes/knowledge"); const { signToken } = await import("../backend/middleware"); await initDatabase(); @@ -52,6 +54,8 @@ async function main() { app.use("/api/org", orgRoutes); app.use("/api/employees", employeeRoutes); app.use("/api/xyai", xyaiRoutes); + app.use("/api/talent", talentRoutes); + app.use("/api/knowledge", knowledgeRoutes); const server = app.listen(0, "127.0.0.1"); let baseUrl = ""; @@ -220,7 +224,137 @@ async function main() { const outsiderEmployees = collectEmployees((await outsiderTree.json() as any).data); assert.equal(outsiderEmployees.some((item: any) => item.id === employeeId), false, "cross-tenant org tree does not include the agent"); - console.log("org canvas + studio reserve employee + shared membership tests passed"); + const talentAfterImport = dbGet("SELECT * FROM talent_pool WHERE id = ?", [importedBody.data.talent_id]) as any; + assert.equal(talentAfterImport.status, "recruited", "studio import marks talent recruited, not available"); + const market = await request(tokenA, "/api/talent"); + const marketBody = await market.json() as any; + assert.equal((marketBody.data || []).some((item: any) => item.id === importedBody.data.talent_id), false, "studio-pushed talent is hidden from 人才市场"); + + const recruitStudio = await request(tokenA, `/api/talent/${importedBody.data.talent_id}/recruit`, { method: "POST" }); + expectStatus(recruitStudio.status, 400, "招募 is not used for studio-pushed agents"); + assert.match((await recruitStudio.json() as any).error, /备选员工|reserve/i); + + const userEdit = await request(tokenUser, `/api/org/employees/${employeeId}`, { + method: "PUT", + body: JSON.stringify({ description: "普通用户也可编辑 Studio 备选员工" }), + }); + expectStatus(userEdit.status, 200, "org editor can edit studio reserve employee without user_id"); + + const otherHuman = Number(dbRun( + "INSERT INTO employees (company_id, name, role, employee_type, status, employment_category, tenant_id) VALUES (1, '他人', '专员', 'human', 'active', 'internal', ?)", + [tenantA], + ).lastInsertRowid); + const userEditOther = await request(tokenUser, `/api/org/employees/${otherHuman}`, { + method: "PUT", + body: JSON.stringify({ name: "不应成功" }), + }); + expectStatus(userEditOther.status, 404, "non-admin cannot edit unrelated employees"); + + const hired = await request(tokenUser, `/api/employees/${employeeId}/onboard`, { + method: "POST", + body: JSON.stringify({ department_id: rootBody.data.id, role: "增长负责人" }), + }); + expectStatus(hired.status, 200, "录用 onboards studio reserve employee"); + const afterHire = dbGet("SELECT * FROM employees WHERE id = ?", [employeeId]) as any; + assert.equal(afterHire.employment_category, "internal"); + assert.equal(Number(afterHire.department_id), Number(rootBody.data.id)); + + const alreadyHired = await request(tokenA, `/api/employees/${employeeId}/onboard`, { method: "POST" }); + expectStatus(alreadyHired.status, 400, "already hired employee cannot be onboarded again"); + + const wrapped = await request(tokenA, "/api/xyai/agents/import", { + method: "POST", + headers: { "X-XYAI-Interop": "studio" }, + body: JSON.stringify({ + tenant_id: tenantA, + asset: { + id: "interop-general-01", + kind: "agent", + name: "通用智能体", + description: "AI智能助手", + payload: { agentId: "agent-general", skills: ["对话", "总结"], role: "助手" }, + }, + }), + }); + expectStatus(wrapped.status, 201, "asset-wrapper studio payload imports to reserve"); + const wrappedBody = await wrapped.json() as any; + const wrappedTalent = dbGet("SELECT * FROM talent_pool WHERE id = ?", [wrappedBody.data.talent_id]) as any; + assert.equal(wrappedTalent.status, "recruited"); + assert.equal(JSON.parse(wrappedTalent.capabilities).schema, "openxyos.studio-agent.v1"); + const wrappedEmp = dbGet("SELECT * FROM employees WHERE id = ?", [wrappedBody.data.employee_id]) as any; + assert.equal(wrappedEmp.employment_category, "reserve"); + assert.equal(wrappedEmp.source, "studio:interop-general-01"); + + const kbDenied = await request(tokenA, "/api/xyai/knowledge/import", { + method: "POST", + body: JSON.stringify({ name: "政策库" }), + }); + expectStatus(kbDenied.status, 403, "knowledge import requires interop header"); + + const kbImported = await request(tokenA, "/api/xyai/knowledge/import", { + method: "POST", + headers: { "X-XYAI-Interop": "studio" }, + body: JSON.stringify({ + asset: { + id: "kb-policy-01", + kind: "knowledge-mount", + name: "政策库", + description: "合规政策挂接", + payload: { kbId: "kb-policy-01", mountKind: "local", sourceRoot: "/docs/policy" }, + }, + }), + }); + expectStatus(kbImported.status, 201, "knowledge import creates listable file"); + const kbBody = await kbImported.json() as any; + assert.ok(kbBody.data.file_id); + assert.equal(kbBody.data.folder, "/"); + + const files = await request(tokenA, "/api/knowledge/files/list?folder=/"); + expectStatus(files.status, 200, "knowledge file list"); + const filesBody = await files.json() as any; + const listed = (filesBody.data || []).find((item: any) => item.id === kbBody.data.file_id); + assert.ok(listed, "installed Studio KB appears in GET /api/knowledge/files/list?folder=/"); + assert.equal(listed.original_name, "政策库"); + assert.equal(listed.folder, "/"); + assert.equal(listed.external_id, "kb-policy-01"); + + const notes = await request(tokenA, "/api/knowledge"); + const notesBody = await notes.json() as any; + assert.ok((notesBody.data || []).some((item: any) => item.id === kbBody.data.note_id), "installed Studio KB note is listed"); + + const kbAgain = await request(tokenA, "/api/xyai/knowledge/import", { + method: "POST", + headers: { "X-XYAI-Interop": "studio" }, + body: JSON.stringify({ + name: "政策库·修订", + external_id: "kb-policy-01", + description: "更新后的政策库", + }), + }); + expectStatus(kbAgain.status, 200, "knowledge import is idempotent by external_id"); + const kbAgainBody = await kbAgain.json() as any; + assert.equal(Number(kbAgainBody.data.file_id), Number(kbBody.data.file_id)); + const fileCount = (dbAll("SELECT id FROM knowledge_files WHERE tenant_id = ? AND external_id = ?", [tenantA, "kb-policy-01"]) as any[]).length; + assert.equal(fileCount, 1, "studio KB import stays idempotent"); + + const outsiderFiles = await request(tokenB, "/api/knowledge/files/list?folder=/"); + const outsiderFilesBody = await outsiderFiles.json() as any; + assert.equal((outsiderFilesBody.data || []).some((item: any) => item.id === kbBody.data.file_id), false, "cross-tenant cannot see imported KB"); + + const inboxKb = await request(tokenA, "/api/xyai/inbox", { + method: "POST", + headers: { "X-XYAI-Interop": "studio" }, + body: JSON.stringify({ + asset: { id: "kb-inbox-02", kind: "knowledge-mount", name: "行业语料", description: "inbox 路由导入" }, + }), + }); + expectStatus(inboxKb.status, 201, "inbox routes knowledge-mount to knowledge import"); + const inboxKbBody = await inboxKb.json() as any; + const inboxListed = ((await (await request(tokenA, "/api/knowledge/files/list?folder=/")).json() as any).data || []) + .some((item: any) => item.id === inboxKbBody.data.file_id); + assert.ok(inboxListed, "inbox-installed KB appears in folder=/ list"); + + console.log("org canvas + studio reserve edit/hire + knowledge import tests passed"); } finally { await new Promise(resolve => server.close(() => resolve())); fs.rmSync(tempRoot, { recursive: true, force: true });