feat(client): vendor digital employee source
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import { adaptSkillSummaries, type SkillSummary } from '@/lib/consoleDataAdapter';
|
||||
import { getSkills, setSkillEnabled } from '@/lib/api';
|
||||
import { getDemoSkills } from './demoScenario';
|
||||
import { domSafeId } from './navigation';
|
||||
|
||||
type TaskCadence = 'daily' | 'weekly' | 'monthly' | 'adhoc';
|
||||
type SkillSection = {
|
||||
key: string;
|
||||
title: string;
|
||||
hint: string;
|
||||
skills: SkillSummary[];
|
||||
emptyText: string;
|
||||
};
|
||||
|
||||
const cadenceOptions: Array<{ value: 'all' | TaskCadence; label: string }> = [
|
||||
{ value: 'all', label: '全部任务' },
|
||||
{ value: 'daily', label: '日常任务' },
|
||||
{ value: 'weekly', label: '周任务' },
|
||||
{ value: 'monthly', label: '月度任务' },
|
||||
{ value: 'adhoc', label: '临时任务' },
|
||||
];
|
||||
|
||||
const cadenceLabels: Record<TaskCadence, string> = {
|
||||
daily: '日常',
|
||||
weekly: '周',
|
||||
monthly: '月度',
|
||||
adhoc: '临时',
|
||||
};
|
||||
|
||||
function getCadence(skill: SkillSummary): TaskCadence {
|
||||
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
|
||||
if (/weekly|week|周/.test(text)) return 'weekly';
|
||||
if (/monthly|month|月报|月度/.test(text)) return 'monthly';
|
||||
if (/monitor|crawler|巡检|监控|待办|日程|日报|日终|收件箱|工作台|客户跟进/.test(text)) return 'daily';
|
||||
return 'adhoc';
|
||||
}
|
||||
|
||||
function isAdvancedSkill(skill: SkillSummary): boolean {
|
||||
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
|
||||
return (
|
||||
skill.capabilityLevel === 'specialized' ||
|
||||
skill.capabilityLevel === 'experimental' ||
|
||||
/加密|深度|分析|自动化|部署|代码|审查|调研|批量|converter|crypto|github|deploy|review|research/.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function isRecommendedSkill(skill: SkillSummary): boolean {
|
||||
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
|
||||
return /日报|周报|报表|待办|日程|巡检|客户|知识库|经营|费用|收件箱|工作台/.test(text);
|
||||
}
|
||||
|
||||
function getOutcome(skill: SkillSummary): string {
|
||||
const name = skill.name;
|
||||
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
|
||||
if (/report|报表|日报|周报|月报|汇报/.test(text)) return `自动整理${name}材料,减少手工汇总和重复填报。`;
|
||||
if (/monitor|巡检|监控|预警/.test(text)) return `按约定频率完成${name},及时提示异常和遗漏。`;
|
||||
if (/todo|待办|task|看板|日程/.test(text)) return `帮你梳理${name},把后续处理事项集中呈现。`;
|
||||
if (/客户|crm|回访|满意/.test(text)) return `汇总客户相关信息,形成可直接跟进的处理建议。`;
|
||||
if (/知识|文档|制度|模板/.test(text)) return `查找和核对资料内容,减少人工翻找和版本确认。`;
|
||||
return skill.description;
|
||||
}
|
||||
|
||||
function learningStatus(skill: SkillSummary, recommended: boolean, advanced: boolean): string {
|
||||
if (skill.enabled) return '已学习';
|
||||
if (!skill.enabled) return '暂不使用';
|
||||
if (advanced) return '需确认后学习';
|
||||
if (recommended) return '建议学习';
|
||||
return '可学习';
|
||||
}
|
||||
|
||||
function SkillCard({
|
||||
skill,
|
||||
recommended = false,
|
||||
advanced = false,
|
||||
selected = false,
|
||||
updating = false,
|
||||
onSelect,
|
||||
onToggle,
|
||||
}: {
|
||||
skill: SkillSummary;
|
||||
recommended?: boolean;
|
||||
advanced?: boolean;
|
||||
selected?: boolean;
|
||||
updating?: boolean;
|
||||
onSelect?: (skill: SkillSummary) => void;
|
||||
onToggle?: (skill: SkillSummary) => void;
|
||||
}) {
|
||||
const cadence = getCadence(skill);
|
||||
const status = learningStatus(skill, recommended, advanced);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`skill-card-${domSafeId(skill.id)}`}
|
||||
className={`de-skill-card ${selected ? 'de-skill-card--selected' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect?.(skill)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onSelect?.(skill);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="de-skill-card-head">
|
||||
<span className="de-skill-name">{skill.name}</span>
|
||||
<span className={`de-skill-status ${skill.enabled ? 'enabled' : 'disabled'}`}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="de-skill-desc">{getOutcome(skill)}</p>
|
||||
<div className="de-skill-card-foot">
|
||||
<span className="de-skill-version">
|
||||
适用任务:{cadenceLabels[cadence]}
|
||||
</span>
|
||||
<button
|
||||
className={`de-skill-btn ${skill.enabled ? 'de-skill-btn--danger' : ''}`}
|
||||
disabled={updating}
|
||||
title={skill.enabled ? '暂时停用此技能,后续路由不会选择它。' : '恢复使用此技能。'}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!updating) onToggle?.(skill);
|
||||
}}
|
||||
>
|
||||
{updating ? '处理中...' : skill.enabled ? '暂不使用' : '恢复使用'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string | null }) {
|
||||
const [skills, setSkills] = useState<SkillSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [cadenceFilter, setCadenceFilter] = useState<'all' | TaskCadence>('all');
|
||||
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(focusSkillId ?? null);
|
||||
const [updatingSkillId, setUpdatingSkillId] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const mountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => { mountedRef.current = false; };
|
||||
}, []);
|
||||
|
||||
const fetchSkills = useCallback(async () => {
|
||||
setLoading(true); setError(false);
|
||||
try {
|
||||
const data = await getSkills();
|
||||
if (!mountedRef.current) return;
|
||||
setSkills(
|
||||
Array.isArray(data) && data.length > 0
|
||||
? adaptSkillSummaries(data as Record<string, unknown>[])
|
||||
: getDemoSkills(),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return;
|
||||
console.error('[SkillLibrary] fetch failed:', err);
|
||||
setSkills(getDemoSkills());
|
||||
setError(false);
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchSkills(); }, [fetchSkills]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusSkillId) return;
|
||||
setSelectedSkillId(focusSkillId);
|
||||
setCadenceFilter('all');
|
||||
setSearch('');
|
||||
window.setTimeout(() => {
|
||||
document.getElementById(`skill-card-${domSafeId(focusSkillId)}`)?.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, 80);
|
||||
}, [focusSkillId, skills.length]);
|
||||
|
||||
const toggleSkill = useCallback(async (skill: SkillSummary) => {
|
||||
const nextEnabled = !skill.enabled;
|
||||
setUpdatingSkillId(skill.id);
|
||||
setActionError(null);
|
||||
try {
|
||||
const result = await setSkillEnabled(skill.id, nextEnabled);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || '技能状态变更失败');
|
||||
}
|
||||
setSkills((current) => current.map((item) => (
|
||||
item.id === skill.id ? { ...item, enabled: nextEnabled } : item
|
||||
)));
|
||||
setSelectedSkillId(skill.id);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : '技能状态变更失败');
|
||||
} finally {
|
||||
setUpdatingSkillId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filtered = skills
|
||||
.filter(s => cadenceFilter === 'all' || getCadence(s) === cadenceFilter)
|
||||
.filter(s => !search || s.name.includes(search) || s.description.includes(search) || s.category.includes(search));
|
||||
|
||||
const advancedSkills = filtered.filter(isAdvancedSkill);
|
||||
const advancedIds = new Set(advancedSkills.map(s => s.id));
|
||||
const learnedSkills = filtered.filter(s => s.enabled && !advancedIds.has(s.id));
|
||||
const recommendedPool = filtered.filter(s => !s.enabled && isRecommendedSkill(s) && !isAdvancedSkill(s));
|
||||
const baseLearnableSkills = filtered.filter(s => !s.enabled && !isAdvancedSkill(s));
|
||||
const recommendedSkills = (recommendedPool.length > 0 ? recommendedPool : baseLearnableSkills).slice(0, 4);
|
||||
const recommendedIds = new Set(recommendedSkills.map(s => s.id));
|
||||
const learnableSkills = baseLearnableSkills.filter(s => !recommendedIds.has(s.id));
|
||||
const sections: SkillSection[] = [
|
||||
{
|
||||
key: 'recommended',
|
||||
title: '推荐学习',
|
||||
hint: '优先补齐高频工作能力,适合让数字员工尽快接手重复事务。',
|
||||
skills: recommendedSkills,
|
||||
emptyText: '暂无推荐学习项。',
|
||||
},
|
||||
{
|
||||
key: 'learnable',
|
||||
title: '可学习技能',
|
||||
hint: '可纳入数字员工日常工作的业务能力,学习入口待接入后启用。',
|
||||
skills: learnableSkills,
|
||||
emptyText: '当前没有可学习技能。',
|
||||
},
|
||||
{
|
||||
key: 'learned',
|
||||
title: '已学技能',
|
||||
hint: '数字员工已经掌握并可被业务场景调用的能力。',
|
||||
skills: learnedSkills,
|
||||
emptyText: '当前还没有已学习技能。',
|
||||
},
|
||||
{
|
||||
key: 'advanced',
|
||||
title: '高级技能',
|
||||
hint: '适合有明确边界或需要确认后再交给数字员工处理的能力。',
|
||||
skills: advancedSkills,
|
||||
emptyText: '暂无高级技能。',
|
||||
},
|
||||
];
|
||||
|
||||
const renderSection = (section: SkillSection) => (
|
||||
<section key={section.key} style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, color: 'var(--de-text-primary)' }}>{section.title}</h3>
|
||||
<p style={{ margin: '4px 0 0', fontSize: 12, color: 'var(--de-text-muted)' }}>{section.hint}</p>
|
||||
</div>
|
||||
<span className="de-capability-badge de-capability-standard">{section.skills.length} 项</span>
|
||||
</div>
|
||||
{section.skills.length === 0 ? (
|
||||
<p className="de-empty-text" style={{ margin: 0 }}>{section.emptyText}</p>
|
||||
) : (
|
||||
<div className="de-skill-grid">
|
||||
{section.skills.map(s => (
|
||||
<SkillCard
|
||||
key={`${section.key}-${s.id}`}
|
||||
skill={s}
|
||||
recommended={section.key === 'recommended'}
|
||||
advanced={section.key === 'advanced'}
|
||||
selected={selectedSkillId === s.id}
|
||||
updating={updatingSkillId === s.id}
|
||||
onSelect={(skill) => setSelectedSkillId(skill.id)}
|
||||
onToggle={toggleSkill}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '14px 18px' }}>
|
||||
<GlassCard>
|
||||
<CardTitleBar title="学习技能">
|
||||
<button onClick={fetchSkills} className="de-btn-secondary de-btn-sm">刷新</button>
|
||||
</CardTitleBar>
|
||||
<div className="de-card-body">
|
||||
{actionError && (
|
||||
<div className="de-schedule-result de-schedule-result--error" style={{ marginBottom: 12 }}>
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
<div className="de-skill-toolbar">
|
||||
<input type="text" className="de-input" placeholder="搜索业务能力..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||
<div className="de-filter-row">
|
||||
{cadenceOptions.map(option => (
|
||||
<button key={option.value} className={`de-filter-chip ${cadenceFilter === option.value ? 'active' : ''}`} onClick={() => setCadenceFilter(option.value)}>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="de-loading-skeleton" style={{ height: 300 }} />
|
||||
) : error ? (
|
||||
<p className="de-empty-text">服务暂不可用,已保留本地示例供预览。</p>
|
||||
) : filtered.length === 0 && skills.length > 0 ? (
|
||||
<p className="de-empty-text">无匹配技能,请调整搜索或筛选条件</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="de-empty-text">暂无可展示的学习技能。</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 18 }}>
|
||||
{sections.map(renderSection)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user