refactor: 专员目录收敛为 6 个,重整 AI 路由与系统管理页

一次性提交当天全部改动(110 个文件)。

**未按 TOP_CODING_RULES.md G14.5「一次提交只装一件事」拆分** —— 用户明确要求
单一提交,此处如实记录,不静默忽略该冲突。

提交前验证:后端 go build / go vet / go test ./... 全绿,前端 npm run build
exit 0,/api/health 与管理员 login 均返回 200。

- 专员目录收敛为 6 个:下线 knowledge-operations / process-coordination /
  presentation-briefing / report-generation 四个专员(前后端 manifest 与 seed
  同步删除),新增 general-assistant。专员的归属关系(拥有哪些技能定义、
  哪些目录项、提示词与绑定从哪来)改由 specialists/core 的 ownership.go、
  prompt_provider.go、binding_provider.go 统一提供,seed 与 admin_handlers
  随之内置化,卸载路径统一走 uninstall.go。
- 技能:补齐 text-to-speech 的前端 manifest(后端包在 HEAD 已存在),
  skillcore/ownership.go 提供与专员对称的归属查询。
- AI 路由:ai_config.json 由 OpenRouter/Ollama 切到 LMUAI / SiliconFlow /
  llama.cpp 本地路由,ai_secrets.example.json 与部署 env 样例同步新增
  SILICONFLOW_API_KEY。
- 系统管理页重整:新增 AiAdminPage、OrganizationManagementPage,删除
  AdminOverviewPage、CompanyConfigPage,SystemConfigPage 精简,nav / router /
  config/workbench.js 同步调整。依 G05.5,开发阶段直接收口到新结构,不留旧路由。
- 新增 internal/objectrefs:统一统计对象(专员 / 技能 / xapp)的运行时引用
  (被多少 xapp、项目、任务引用),供管理页做删除前的影响面判断。
- 公众号创作专员:新增 OfficialAccountSpecialistPanel,workflow 与投递链路调整。
- XApp:考试 / 培训 Shell 扩展,XAppDirectoryPage 与 xappDefinition 同步。
- 文档:新增 GW01–GW05 工作台演进系列与 AR12 对话驱动与结构化交互架构;
  同步 AR05 / SY17 / SY23 / SY25 / PL04;TOP_CODING_RULES.md 增补 G05.5。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-23 09:01:28 +08:00
co-authored by Claude Code
parent 7fc2903827
commit 593323a934
110 changed files with 8295 additions and 1715 deletions
@@ -5,26 +5,26 @@
<div v-if="groups.length || tabItems.length > 1" class="section-tabs">
<nav v-if="groups.length" class="section-tabs-row section-tabs-groups" aria-label="分组">
<button
v-for="group in groups"
v-for="(group, index) in groups"
:key="group.key"
class="section-tab"
:class="{ active: group.key === groupKey }"
@click="openGroup(group)"
>
{{ group.label }}
{{ formatIndexedLabel(group.label, index) }}
</button>
</nav>
<nav v-if="tabItems.length > 1" class="section-tabs-row" aria-label="页面">
<button
v-for="item in tabItems"
v-for="(item, index) in tabItems"
:key="item.pageRoute"
class="section-tab"
:class="{ active: isCurrent(item.pageRoute) }"
:title="item.caption || item.label"
@click="router.push(item.pageRoute)"
>
{{ item.label }}
{{ formatIndexedLabel(item.label, index) }}
</button>
</nav>
</div>
@@ -40,6 +40,7 @@ const props = defineProps({
groups: { type: Array, default: () => [] },
// 当前路由落在哪个组
groupKey: { type: String, default: '' },
sectionLabel: { type: String, default: '' },
})
const route = useRoute()
@@ -67,6 +68,13 @@ function openGroup(group) {
if (target) router.push(target.pageRoute)
}
function formatIndexedLabel(label, index) {
const sectionIndex = Number(String(props.sectionLabel || '').match(/^(\d+)\./)?.[1] || 0)
const normalizedLabel = String(label || '').replace(/^\d+(?:\.\d+)?\s+/, '')
if (!sectionIndex) return normalizedLabel
return `${sectionIndex}.${index + 1} ${normalizedLabel}`
}
function isCurrent(path) {
return route.path === path || route.path.startsWith(`${path}/`)
}
@@ -22,11 +22,11 @@
</div>
<div class="nav-item" @click="$emit('navChange', 'expert')">
<span class="nav-icon">🧠</span>
<span class="nav-label" :class="{ 'hide': sidebarCollapsed }">专员·技能·APP·连接器</span>
<span class="nav-label" :class="{ 'hide': sidebarCollapsed }">专员·技能·xApp·连接器</span>
</div>
<div class="nav-item" @click="$emit('navChange', 'apps')">
<span class="nav-icon">🧩</span>
<span class="nav-label" :class="{ 'hide': sidebarCollapsed }">长程APP</span>
<span class="nav-label" :class="{ 'hide': sidebarCollapsed }">长程 xApp</span>
</div>
<div class="nav-item" @click="$emit('navChange', 'library')">
<span class="nav-icon">📚</span>
@@ -78,7 +78,7 @@ const visible = computed(() => Boolean(kind.value))
const label = computed(() => {
if (kind.value === 'specialist') {
if (isGeneralAssistant.value) return '通用助手'
if (isGeneralAssistant.value) return '通用专员'
return currentSpecialist.value?.label || specialistEntry.value?.label || '已选专员'
}
return skillEntry.value?.label || ''
@@ -88,7 +88,7 @@ const badge = computed(() => (kind.value === 'specialist' ? '专' : '技'))
const hint = computed(() => {
if (kind.value === 'specialist') {
if (isGeneralAssistant.value) return '当前专员:通用助手'
if (isGeneralAssistant.value) return '当前专员:通用专员'
return specialistEntry.value ? `当前专员:${label.value} · 点开它的工作台` : `当前专员:${label.value}`
}
return skillEntry.value ? `当前技能:${label.value}` : label.value
@@ -67,7 +67,7 @@ onMounted(() => {
}
.llm-route-select {
width: 92px;
width: 176px;
border: none;
outline: none;
background: transparent;
@@ -76,4 +76,8 @@ onMounted(() => {
color: #303133;
cursor: pointer;
}
.llm-route-picker.compact .llm-route-select {
width: 156px;
}
</style>
@@ -121,7 +121,6 @@ import { ElMessageBox } from 'element-plus'
import {
ArrowRight, Avatar, Check, Connection, EditPen, MagicStick, Plus, Search, Setting, UploadFilled,
} from '@element-plus/icons-vue'
import { getStaticSkillByKey } from '@/skills/core/builtin'
import { listConnectors } from '@/connectors/api/connector'
import { useAuthStore } from '@/store/auth'
import { useSkillCatalogStore } from '@/skills/store/skillCatalog'
@@ -179,19 +178,6 @@ function routable(path) {
}
}
const generalAssistantItem = computed(() => {
const item = skillCatalog.getByKey('smart-assistant') || getStaticSkillByKey('smart-assistant')
if (!item) return null
return {
key: GENERAL_ASSISTANT_KEY,
label: item.label,
caption: item.caption,
display_code: '专00',
eailogic_code: 'EAI-S-00',
objectEntryRoute: item.objectEntryRoute,
}
})
const entries = computed(() => {
const list = []
if (props.modes.length) {
@@ -211,20 +197,16 @@ const entries = computed(() => {
type: 'resource',
searchPlaceholder: '搜索专员',
empty: '暂无可用专员',
items: [
...(generalAssistantItem.value ? [generalAssistantItem.value] : []),
...specialistCatalog.specialists
.filter((specialist) => routable(specialist.objectEntryRoute))
// key 要在切换时用:选了专员就等于把当前任务改挂到它名下。
.map((specialist) => ({
key: specialist.key,
label: specialist.label,
caption: specialist.summary,
display_code: specialist.display_code,
eailogic_code: specialist.eailogic_code,
objectEntryRoute: specialist.objectEntryRoute,
})),
],
items: specialistCatalog.specialists
// key 要在切换时用:选了专员就等于把当前任务改挂到它名下。
.map((specialist) => ({
key: specialist.key,
label: specialist.label,
caption: specialist.summary,
display_code: specialist.display_code,
eailogic_code: specialist.eailogic_code,
objectEntryRoute: specialist.objectEntryRoute,
})),
manage: { label: '专员市场', pageRoute: '/market' },
})
}
@@ -11,7 +11,11 @@
靠推断(见 utils/specialistFlow.js 的 markFlowState),并且推断出来的那一步
在界面上要标出来,不能让它冒充实测进度。
-->
<div class="specialist-panel">
<OfficialAccountSpecialistPanel
v-if="isOfficialAccountSpecialist"
@collapse="emit('collapse')"
/>
<div v-else class="specialist-panel">
<div class="sp-tabs">
<button
class="sp-tab"
@@ -158,6 +162,7 @@ import { storeToRefs } from 'pinia'
import { DEFAULT_SKILL_KEY, GENERAL_ASSISTANT_KEY, useTaskRuntimeStore } from '@/store/taskRuntime'
import { useSkillCatalogStore } from '@/skills/store/skillCatalog'
import { artifactsOfStep, buildActionRecords, buildResultRecords, markFlowState } from '@/specialists/runtime/specialistFlow'
import OfficialAccountSpecialistPanel from '@/components/chat/OfficialAccountSpecialistPanel.vue'
const emit = defineEmits(['collapse'])
@@ -176,6 +181,7 @@ const {
const tab = ref('flow')
const specialist = computed(() => currentSpecialist.value)
const isOfficialAccountSpecialist = computed(() => String(currentSpecialistKey.value || '').trim() === 'wechat-official-account')
const skill = computed(() =>
skillCatalog.getByKey(currentFocusedObjectKey.value)
)
@@ -88,7 +88,7 @@ const props = defineProps({
},
emptyText: {
type: String,
default: '你可以一边操作当前 APP,一边在这里让 AI 帮你分析、解释和推进。',
default: '你可以一边操作当前 xApp,一边在这里让 AI 帮你分析、解释和推进。',
},
starterPrompts: {
type: Array,
+16 -19
View File
@@ -2,7 +2,7 @@
//
// 侧边栏只承载一级导航(navSections 里的每一项),内容区顶部那排 tab 直接读每个一级项
// 自己的 items。之前「专员·技能·APP·连接器」做成了 groups 套 items 的两层结构,
// 现在把它收平:4 个 tab(专员 / 技能 / APP / 连接器)都只是“浏览配置”的目录页入口,
// 现在把它收平:5 个 tab(专员 / 技能 / xApp / 连接器 / 工坊)都只是“浏览配置”的目录页入口,
// 不再在这一层承担“进入可执行页面”的职责。
import { Avatar, CirclePlus, Collection, Grid, More, Share } from '@element-plus/icons-vue'
import {
@@ -30,25 +30,26 @@ export const navSections = [
},
{
key: 'object-catalog',
label: '3. 专员·技能·APP·连接器',
label: '3. 专员·技能·xApp·连接器',
icon: Avatar,
// 这一项现在只负责“看目录 / 看配置”,不直接跳进使用页。
// 内容区顶部用一排 tab 在 4 张卡片页之间切换。
// 内容区顶部用一排 tab 在目录页、工坊之间切换。
items: [
{ label: '专员', pageRoute: '/catalog/specialists' },
{ label: '技能', pageRoute: '/catalog/skills' },
{ label: 'APP', pageRoute: '/catalog/xapps' },
{ label: '连接器', pageRoute: '/catalog/connectors' },
{ label: '3.1 专员', pageRoute: '/catalog/specialists' },
{ label: '3.2 技能', pageRoute: '/catalog/skills' },
{ label: '3.3 xApp', pageRoute: '/catalog/xapps' },
{ label: '3.4 连接器', pageRoute: '/catalog/connectors' },
{ label: '3.5 工坊', pageRoute: '/studio', adminOnly: true },
],
},
{
key: 'xapps',
label: '4. APP',
label: '4. xApp',
icon: Grid,
items: [
{ label: 'APP目录', pageRoute: XAPP_DIRECTORY_ROUTE },
{ label: '内部培训APP', pageRoute: INTERNAL_TRAINING_XAPP_ROOT },
{ label: '内部考试APP', pageRoute: INTERNAL_EXAM_XAPP_ROOT },
{ label: 'xApp目录', pageRoute: XAPP_DIRECTORY_ROUTE },
{ label: '内部培训 xApp', pageRoute: INTERNAL_TRAINING_XAPP_ROOT },
{ label: '内部考试 xApp', pageRoute: INTERNAL_EXAM_XAPP_ROOT },
],
},
{
@@ -69,14 +70,10 @@ export const navSections = [
label: '6. 后台管理',
icon: More,
items: [
{ label: '治理总览', pageRoute: '/admin', adminOnly: true },
{ label: '工坊', pageRoute: '/studio', adminOnly: true },
{ label: '用户管理', pageRoute: '/system/users', adminOnly: true },
{ label: '部门管理', pageRoute: '/system/departments', adminOnly: true },
{ label: '岗位管理', pageRoute: '/system/positions', adminOnly: true },
{ label: '公司配置', pageRoute: '/system/company-config', adminOnly: true },
{ label: '系统配置', pageRoute: '/system/config', adminOnly: true },
{ label: 'AI 用量', pageRoute: '/system/ai-usage', adminOnly: true },
{ label: '6.1 用户管理', pageRoute: '/system/users', adminOnly: true },
{ label: '6.2 组织与公司配置', pageRoute: '/system/organization', adminOnly: true },
{ label: '6.3 系统配置', pageRoute: '/system/config', adminOnly: true },
{ label: '6.4 AI后台管理', pageRoute: '/system/ai-admin', adminOnly: true },
],
},
]
@@ -6,10 +6,8 @@
// 模板只存 key(专员 key / 技能 key),名字展示时再去查 —— 专员名以后改了,
// 模板里不会留下一份对不上的旧名字。
import {
DataAnalysis,
Document,
EditPen,
Notebook,
Promotion,
Reading,
} from '@element-plus/icons-vue'
@@ -23,12 +21,28 @@ export function normalizeSkillKey(value) {
return String(value || '').trim()
}
export function normalizeSpecialistKey(value) {
return String(value || '').trim()
}
export function normalizeSkillKeys(values = []) {
return [...new Set((values || []).map((item) => normalizeSkillKey(item)).filter(Boolean))]
}
export function normalizeSpecialistKeys(values = []) {
return [...new Set((values || []).map((item) => normalizeSpecialistKey(item)).filter(Boolean))]
}
function createProjectTemplate(template) {
return {
...template,
specialistKeys: normalizeSpecialistKeys(template?.specialistKeys),
skillKeys: normalizeSkillKeys(template?.skillKeys),
}
}
export const projectTemplates = [
{
createProjectTemplate({
id: 'contract-review',
title: '合同审查',
desc: '逐条比对条款、标注风险,产出修订建议',
@@ -37,8 +51,8 @@ export const projectTemplates = [
'本项目的输出以合同审查口径为准:始终站在甲方立场逐条比对条款,标出偏离标准模板的地方,并按「高风险 / 需确认 / 可接受」三档给结论。引用条款时写明第几条,不要转述。',
specialistKeys: ['contract-review'],
skillKeys: ['contract-review'],
},
{
}),
createProjectTemplate({
id: 'solution-proposal',
title: '售前方案',
desc: '从客户需求到方案与报价的完整链路',
@@ -47,8 +61,8 @@ export const projectTemplates = [
'本项目面向售前场景:每个方案都要先复述客户原始诉求,再给出方案,最后列出报价与交付周期。涉及产品能力时只能引用资料库里已有的产品资料,不确定的地方标「待确认」而不是编。',
specialistKeys: ['solution-proposal'],
skillKeys: ['report-generation'],
},
{
}),
createProjectTemplate({
id: 'internal-training',
title: '培训交付',
desc: '课件准备、现场记录到交付归档',
@@ -57,8 +71,8 @@ export const projectTemplates = [
'本项目按培训交付流程走:课前确认学员与大纲,课中记录要点与答疑,课后 24 小时内出交付包。所有产出用统一模板,标题格式「〈客户〉-〈课程〉-〈环节〉」。',
specialistKeys: [],
skillKeys: ['document-translate', 'audio-transcribe'],
},
{
}),
createProjectTemplate({
id: 'content-operations',
title: '内容运营',
desc: '选题、写稿、校对到发布复盘',
@@ -67,43 +81,21 @@ export const projectTemplates = [
'本项目的内容一律先出提纲再成稿,成稿后必须过一遍校对。语气保持克制,不堆形容词,不写没有出处的数据。每篇发布后补一条复盘:阅读表现、读者反馈、下次要改什么。',
specialistKeys: ['wechat-official-account'],
skillKeys: ['copy-proofreading'],
},
{
id: 'knowledge-operations',
title: '知识运营',
desc: '素材归集、字段提取与知识库沉淀',
icon: Notebook,
prompt:
'本项目负责把零散素材整理成可检索的知识:统一术语表,同一实体在全文用同一个叫法;每条结论都要能追回到原始素材。提取字段时先用批量提取跑一遍,再人工复核异常项。',
specialistKeys: ['knowledge-operations'],
skillKeys: ['batch-extract'],
},
{
id: 'report-generation',
title: '报告生成',
desc: '经营数据、周报月报与专项分析',
icon: DataAnalysis,
prompt:
'本项目的报告统一结构:结论在前、论据在后、附录放明细。数字必须带口径和时间范围,同比环比要写清楚对比基准。没有数据支撑的判断放在「观察」一节,不要混进结论。',
specialistKeys: ['report-generation'],
skillKeys: ['report-generation', 'document-translate'],
},
{
id: 'presentation-briefing',
title: '汇报 PPT',
desc: '面向管理层、客户和培训场景的演示稿交付',
icon: Promotion,
prompt:
'本项目按汇报交付口径推进:先确认汇报对象、场景、页数和主线,再生成页面结构、每页要点和讲稿备注。所有数字、时间和范围必须带口径;没有依据的地方明确标“待补”,不要为了好看自行补齐。',
specialistKeys: ['presentation-briefing'],
skillKeys: ['ppt-generation', 'mind-map'],
},
}),
]
export function findProjectTemplate(id) {
return projectTemplates.find((template) => template.id === id) || null
}
export function listProjectTemplateObjectRefs() {
return projectTemplates.map((template) => ({
templateId: template.id,
specialistKeys: [...template.specialistKeys],
skillKeys: [...template.skillKeys],
}))
}
// 新建项目弹窗里那个「项目模板」下拉的默认项。选中它 = 不套模板,
// 指令和三个对象列表都由用户自己填。
//
@@ -161,141 +161,6 @@ const workspaceDemoData = {
},
},
},
'knowledge-operations': {
title: '知识运营专员',
caseTitle: 'Q3 规则库与对象字典整理',
subtitle: '围绕资料整理、对象映射、证据抽取和规则同步的知识运营工作台。',
cases: [
{ id: 'ko-01', title: 'Q3 规则库与对象字典整理', owner: '知识管理员', priority: 'P1', due: '今天 18:00', summary: '合同、客户、课程三类对象映射需要统一口径', status: '对象映射', contextPatch: { outputs: ['对象字典 v3', '规则口径清单'], artifacts: ['对象映射表.xlsx'], risks: ['客户对象字段存在重名'], events: ['09:20 完成对象字段比对'] } },
{ id: 'ko-02', title: '售前资料证据抽取', owner: '知识运营', priority: 'P2', due: '明天 12:00', summary: '把售前方案与会议纪要沉淀成可引用证据片段', status: '证据整理', contextPatch: { outputs: ['Evidence 索引'], artifacts: ['证据索引.json'], risks: ['两个 PDF 页码缺失'], events: ['10:10 抽取证据片段 42 条'] } },
{ id: 'ko-03', title: '培训规则同步检查', owner: '教研运营', priority: 'P2', due: '周五', summary: '考试规则与课程标签需要同步到知识底座', status: '规则同步', contextPatch: { outputs: ['规则同步摘要'], artifacts: ['规则变更说明.md'], risks: ['旧版规则仍在被引用'], events: ['08:45 拉取考试规则版本'] } },
],
tabs: ['进度', '时间线', '对象字典', '回放'],
stageSummary: [
{ name: '资料盘点', status: 'completed' },
{ name: '对象映射', status: 'active' },
{ name: '证据抽取', status: 'active' },
{ name: '规则同步', status: 'waiting' },
{ name: '发布归档', status: 'pending' },
],
taskCards: [
{ title: '对象映射', status: '进行中', detail: '合同、客户、课程三个对象族正在统一字段口径', tone: 'primary' },
{ title: '证据抽取', status: '已完成', detail: '新增 42 条可引用证据片段并完成哈希入库', tone: 'success' },
{ title: '规则同步', status: '待确认', detail: '等待培训负责人确认考试规则新版本', tone: 'warning' },
],
focusPanels: [
{ title: '当前关注', content: '对象字典要成为所有专员共享的稳定语义层,避免字段漂移。' },
{ title: '知识焦点', content: '优先补齐售前、合同、培训三类高频 Evidence 与 PolicyRule。' },
{ title: '下一步', content: '完成规则同步后,进入知识台发布与版本归档。' },
],
quickViews: ['对象字典', '证据索引', '规则变更', '待确认映射'],
relatedItems: ['合同对象字典', '售前资料池', '考试规则版本'],
context: {
outputs: ['对象字典 v3', 'Evidence 索引', '规则同步摘要'],
artifacts: ['对象映射表.xlsx', '证据索引.json', '规则变更说明.md'],
evidence: ['e-9201 售前纪要', 'e-9202 合同模板条款', 'e-9203 课程规则文档'],
risks: ['客户对象字段存在重名', '两个 PDF 页码缺失', '旧版规则仍在被引用'],
checkpoints: ['知识管理员确认', '培训负责人规则审批'],
relatedObjects: ['ObjectDictionary: Contract/Customer/Course', 'PolicyRule: 考试规则', 'EvidenceSet: 售前资料'],
events: ['09:20 完成对象字段比对', '10:10 抽取证据片段 42 条', '10:40 生成规则同步摘要'],
bottomPanel: {
timeline: ['08:50 初始化知识运营任务', '09:20 更新对象字典', '10:10 完成证据抽取'],
replay: ['回放节点 1: ScanAssets', '回放节点 2: SyncObjects', '回放节点 3: BuildEvidence'],
trace: ['plugin.knowledge.scanAssets()', 'plugin.object.syncDictionary()', 'plugin.evidence.buildIndex()'],
logs: ['[INFO] object dictionary synced', '[INFO] 42 evidence entries created', '[WARN] rule version pending approval'],
},
},
},
'process-coordination': {
title: '流程推进专员',
caseTitle: '本周跨部门 checkpoint 推进',
subtitle: '围绕跨部门任务推进、阻塞升级、人工确认与截止期跟踪的流程协同工作台。',
cases: [
{ id: 'pc-01', title: '本周跨部门 checkpoint 推进', owner: '项目运营', priority: 'P1', due: '今天 17:30', summary: '售前、法务、交付三个事项存在 checkpoint 等待确认', status: '阻塞清理', contextPatch: { outputs: ['催办摘要'], artifacts: ['checkpoint-brief.md'], risks: ['两个确认超时'], events: ['09:10 自动生成催办列表'] } },
{ id: 'pc-02', title: '法务审批链超时事项', owner: '流程运营', priority: 'P2', due: '今天 15:00', summary: '需要升级 3 个超时审批并同步责任人', status: '人工催办', contextPatch: { outputs: ['审批升级清单'], artifacts: ['审批升级.xlsx'], risks: ['法务负责人未读提醒'], events: ['10:00 推送升级通知'] } },
{ id: 'pc-03', title: '培训交付返工跟进', owner: '项目助理', priority: 'P2', due: '周三', summary: '两项交付包返工需要重新排期并通知相关专员', status: '重新排期', contextPatch: { outputs: ['返工排期建议'], artifacts: ['返工计划.md'], risks: ['返工将影响周报时间'], events: ['08:35 重新计算依赖链'] } },
],
tabs: ['进度', '时间线', '阻塞点', '回放'],
stageSummary: [
{ name: '任务聚合', status: 'completed' },
{ name: '阻塞识别', status: 'completed' },
{ name: '催办升级', status: 'active' },
{ name: '人工确认', status: 'waiting' },
{ name: '闭环归档', status: 'pending' },
],
taskCards: [
{ title: '阻塞识别', status: '已完成', detail: '共识别 6 个待处理 checkpoint,2 个超时事项', tone: 'success' },
{ title: '催办升级', status: '进行中', detail: '已向法务、售前、交付三类责任人发送升级通知', tone: 'primary' },
{ title: '人工确认', status: '等待中', detail: '等待责任人逐项确认下一步动作与时间承诺', tone: 'warning' },
],
focusPanels: [
{ title: '当前关注', content: '流程推进专员要把跨专员的阻塞和 checkpoint 聚合成统一可见面板。' },
{ title: '推进焦点', content: '优先清理超时事项,再同步返工排期与责任人。' },
{ title: '下一步', content: '完成责任人确认后自动回写到对应专员事项。' },
],
quickViews: ['阻塞清单', '超时事项', '责任人分布', '返工影响'],
relatedItems: ['售前方案评审', '合同审查确认点', '培训返工计划'],
context: {
outputs: ['催办摘要', '审批升级清单', '返工排期建议'],
artifacts: ['checkpoint-brief.md', '审批升级.xlsx', '返工计划.md'],
evidence: ['e-9301 checkpoint 记录', 'e-9302 审批日志', 'e-9303 项目返工单'],
risks: ['两个确认超时', '法务负责人未读提醒', '返工将影响周报时间'],
checkpoints: ['项目运营确认升级', '责任人逐项确认'],
relatedObjects: ['CheckpointQueue', 'TaskDependency', 'EscalationPolicy'],
events: ['09:10 自动生成催办列表', '10:00 推送升级通知', '10:35 更新返工排期'],
bottomPanel: {
timeline: ['09:00 汇总跨专员事项', '09:10 生成催办清单', '10:00 发送升级通知'],
replay: ['回放节点 1: AggregateTasks', '回放节点 2: DetectBlocking', '回放节点 3: EscalateOwners'],
trace: ['plugin.flow.aggregateTasks()', 'plugin.flow.detectBlocking()', 'connector.wecom.sendEscalation()'],
logs: ['[INFO] 6 checkpoints aggregated', '[WARN] 2 overdue confirmations', '[INFO] escalation notices sent'],
},
},
},
'report-generation': {
title: '报告生成专员',
caseTitle: '周度经营与交付复盘报告',
subtitle: '围绕日报、周报、复盘和交付说明自动生成的报告编制工作台。',
cases: [
{ id: 'rg-01', title: '周度经营与交付复盘报告', owner: '运营分析师', priority: 'P1', due: '今天 19:00', summary: '需要汇总售前、法务、培训、履约四类事项的结果输出', status: '报告汇总', contextPatch: { outputs: ['周报摘要'], artifacts: ['周度复盘报告.pdf'], risks: ['履约数据仍在同步'], events: ['09:50 生成周报大纲'] } },
{ id: 'rg-02', title: '训练营日报自动生成', owner: '班主任', priority: 'P2', due: '今天 20:30', summary: '根据课程、考试、互动情况生成班级日报', status: '日报生成', contextPatch: { outputs: ['班级日报'], artifacts: ['日报.md'], risks: ['两名学员互动记录缺失'], events: ['10:20 拉取课程互动数据'] } },
{ id: 'rg-03', title: '客户交付说明包', owner: '客户成功', priority: 'P2', due: '明天', summary: '需要把本周主要交付物和风险说明整理成客户可读版本', status: '客户说明', contextPatch: { outputs: ['客户说明摘要'], artifacts: ['客户交付说明.pptx'], risks: ['一个风险项尚未关闭'], events: ['08:55 生成客户说明初稿'] } },
],
tabs: ['进度', '时间线', '报告版本', '回放'],
stageSummary: [
{ name: '数据汇总', status: 'completed' },
{ name: '结构生成', status: 'completed' },
{ name: '内容编制', status: 'active' },
{ name: '风险校验', status: 'waiting' },
{ name: '导出归档', status: 'pending' },
],
taskCards: [
{ title: '数据汇总', status: '已完成', detail: '已聚合售前、法务、培训、履约四类输出与产物', tone: 'success' },
{ title: '内容编制', status: '进行中', detail: '正在生成经营摘要、交付亮点和风险说明章节', tone: 'primary' },
{ title: '风险校验', status: '待处理', detail: '等待履约专员同步最后一个异常处理结果', tone: 'warning' },
],
focusPanels: [
{ title: '当前关注', content: '报告生成专员要负责把多专员输出收拢成可交付的统一文档。' },
{ title: '报告焦点', content: '不仅生成文本,还要生成面向管理层与客户的不同版本。' },
{ title: '下一步', content: '完成风险校验后自动导出 PDF / PPT / Markdown 三种格式。' },
],
quickViews: ['报告版本', '数据来源', '待确认风险', '导出记录'],
relatedItems: ['售前方案 v3', '训练营周报', '客户状态报告'],
context: {
outputs: ['周报摘要', '班级日报', '客户说明摘要'],
artifacts: ['周度复盘报告.pdf', '日报.md', '客户交付说明.pptx'],
evidence: ['e-9401 售前方案', 'e-9402 培训周报', 'e-9403 履约状态说明'],
risks: ['履约数据仍在同步', '两名学员互动记录缺失', '一个风险项尚未关闭'],
checkpoints: ['运营分析师审阅', '客户成功确认外发版本'],
relatedObjects: ['ReportTemplate: WeeklyReview', 'ArtifactBundle: CustomerPack', 'EvidenceSet: WeeklySummary'],
events: ['09:50 生成周报大纲', '10:20 拉取课程互动数据', '10:40 导出客户说明初稿'],
bottomPanel: {
timeline: ['09:20 汇总多专员产物', '09:50 生成周报大纲', '10:40 输出客户说明初稿'],
replay: ['回放节点 1: CollectArtifacts', '回放节点 2: DraftReport', '回放节点 3: ExportFormats'],
trace: ['plugin.report.collectArtifacts()', 'plugin.report.generateDraft()', 'plugin.report.exportBundle()'],
logs: ['[INFO] report sources collected', '[INFO] weekly report drafted', '[WARN] logistics data still syncing'],
},
},
},
'customer-followup': {
title: '客户跟进专员',
caseTitle: '本周客户跟进与会议预约',
@@ -346,7 +211,7 @@ const workspaceDemoData = {
},
},
'resume-processor': {
title: '招聘筛选专员',
title: '简历筛选与招聘专员',
caseTitle: '招聘入口归档、候选人筛选与面试安排',
subtitle: '先收拢招聘邮箱和候选人资料,再按岗位要求完成筛选、排序并推进面试安排。',
cases: [
@@ -390,6 +255,59 @@ const workspaceDemoData = {
},
}
const workspaceObjectRefs = {
'contract-review': {
specialistKeys: ['contract-review'],
skillKeys: ['contract-review', 'contract-brief', 'batch-extract'],
xappKeys: [],
},
'solution-proposal': {
specialistKeys: ['solution-proposal'],
skillKeys: ['ppt-generation', 'mind-map'],
xappKeys: [],
},
'internal-training': {
specialistKeys: [],
skillKeys: ['document-translate', 'audio-transcribe'],
xappKeys: ['internal-training'],
},
'customer-followup': {
specialistKeys: ['customer-followup'],
skillKeys: ['progress-report', 'report-generation'],
xappKeys: [],
},
'resume-processor': {
specialistKeys: ['resume-processor'],
skillKeys: ['ocr-understanding', 'batch-extract', 'table-cleanup', 'interview-summary'],
xappKeys: [],
},
'/knowledge-hub': {
specialistKeys: [],
skillKeys: [],
xappKeys: ['knowledge-hub'],
},
'/knowledge-management': {
specialistKeys: [],
skillKeys: [],
xappKeys: [],
},
'/market': {
specialistKeys: [],
skillKeys: [],
xappKeys: [],
},
'/studio': {
specialistKeys: [],
skillKeys: [],
xappKeys: [],
},
'/console': {
specialistKeys: [],
skillKeys: [],
xappKeys: [],
},
}
const genericSurfaceContext = {
'/knowledge-hub': {
outputs: ['知识问答结果', '上传文档摘要', '会话沉淀结果'],
@@ -503,3 +421,22 @@ export function getWorkspaceDescriptor(path, caseId = '') {
void caseId
return null
}
export function getWorkspaceObjectRefs(workspaceKey) {
const refs = workspaceObjectRefs[workspaceKey]
if (!refs) {
return { specialistKeys: [], skillKeys: [], xappKeys: [] }
}
return {
specialistKeys: [...refs.specialistKeys],
skillKeys: [...refs.skillKeys],
xappKeys: [...refs.xappKeys],
}
}
export function listWorkspaceObjectRefs() {
return Object.keys(workspaceObjectRefs).map((workspaceKey) => ({
workspaceKey,
...getWorkspaceObjectRefs(workspaceKey),
}))
}
@@ -269,6 +269,7 @@
:items="activeSection?.items || []"
:groups="activeSection?.groups || []"
:group-key="activeGroup?.key || ''"
:section-label="activeSection?.label || ''"
/>
<div v-if="shouldShowTaskTitlebar && currentTaskDisplayTitle" class="wb-task-titlebar" :title="currentTaskDisplayTitle">
<div class="wb-task-titlebar-text">{{ currentTaskDisplayTitle }}</div>
@@ -812,7 +813,7 @@ function resolveSurfaceChatContext(path) {
title: '培训协作',
subtitle: '边看课程,边让 AI 帮你总结、提炼和辅导。',
placeholder: '例如:帮我总结这门课程的重点,并给出陪练建议',
emptyText: '进入培训 APP 后,右侧会一直保留 AI 对话栏,方便你边学边问。',
emptyText: '进入培训 xApp 后,右侧会一直保留 AI 对话栏,方便你边学边问。',
starterPrompts: [
'帮我总结这门课程的 3 个重点',
'把这节课整理成销售陪练提纲',
@@ -851,7 +852,7 @@ function resolveSurfaceChatContext(path) {
title: '考试协作',
subtitle: '边看考试页,边让 AI 帮你解释规则、复盘和出练习建议。',
placeholder: '例如:根据当前考试情况帮我做一版复盘建议',
emptyText: '考试 APP 里也会保留 AI 对话栏,方便把练习、自测和复盘连起来。',
emptyText: '考试 xApp 里也会保留 AI 对话栏,方便把练习、自测和复盘连起来。',
starterPrompts: [
'根据当前错题情况给我复盘建议',
'帮我生成一版考前冲刺清单',
@@ -25,7 +25,6 @@ const routes = [
{ path: 'xapps', name: 'XAppDirectoryPage', component: () => import('@/xapps/views/XAppDirectoryPage.vue') },
{ path: 'overview', redirect: '/projects' },
{ path: 'my/settings', name: 'MySettingsPage', component: () => import('@/views/me/MySettingsPage.vue') },
{ path: 'admin', name: 'AdminOverviewPage', component: () => import('@/views/system/AdminOverviewPage.vue'), meta: { requiresAdmin: true } },
{ path: 'studio', name: 'StudioPage', component: () => import('@/views/workbench/StudioPage.vue'), meta: { requiresAdmin: true } },
{ path: 'console', name: 'ConsolePage', component: () => import('@/views/workbench/ConsolePage.vue'), meta: { requiresAdmin: true } },
{ path: 'catalog/specialists', name: 'ObjectCatalogSpecialistsPage', component: () => import('@/specialists/views/SpecialistCatalogPage.vue') },
@@ -41,7 +40,6 @@ const routes = [
{
path: INTERNAL_TRAINING_XAPP_ROOT.slice(1),
component: () => import('@/xapps/views/TrainingXAppShell.vue'),
redirect: INTERNAL_TRAINING_MODULE_ROUTES.courses,
children: [
{ path: 'courses', name: 'TrainingCourseListPage', component: () => import('@/views/salesTrain/CourseListPage.vue') },
{ path: 'courses/:id', name: 'TrainingCourseDetailPage', component: () => import('@/views/salesTrain/CourseDetailPage.vue') },
@@ -53,7 +51,6 @@ const routes = [
{
path: INTERNAL_EXAM_XAPP_ROOT.slice(1),
component: () => import('@/xapps/views/ExamXAppShell.vue'),
redirect: INTERNAL_EXAM_MODULE_ROUTES.selfTest,
children: [
{ path: 'self-test', name: 'ExamSelfTestPage', component: () => import('@/views/exam/ExamSelfTestPage.vue') },
{ path: 'formal', name: 'ExamFormalPage', component: () => import('@/views/exam/ExamFormalPage.vue') },
@@ -69,12 +66,10 @@ const routes = [
{ path: 'knowledge/audit', name: 'KnowledgeMaterialAuditPage', component: () => import('@/views/knowledge/KnowledgeMaterialAuditPage.vue') },
{ path: 'knowledge/questions', name: 'KnowledgeQuestionBankPage', component: () => import('@/views/knowledge/KnowledgeQuestionBankPage.vue') },
{ path: 'knowledge/exam-records', name: 'KnowledgeExamRecordsPage', component: () => import('@/views/knowledge/KnowledgeExamRecordsPage.vue') },
{ path: 'system/positions', name: 'PositionManagementPage', component: () => import('@/views/system/PositionManagementPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/departments', name: 'DepartmentManagementPage', component: () => import('@/views/system/DepartmentManagementPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/organization', name: 'OrganizationManagementPage', component: () => import('@/views/system/OrganizationManagementPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/users', name: 'UserManagementPage', component: () => import('@/views/system/UserManagementPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/company-config', name: 'CompanyConfigPage', component: () => import('@/views/system/CompanyConfigPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/config', name: 'SystemConfigPage', component: () => import('@/views/system/SystemConfigPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/ai-usage', name: 'AiUsagePage', component: () => import('@/views/system/AiUsagePage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/ai-admin', name: 'AiAdminPage', component: () => import('@/views/system/AiAdminPage.vue'), meta: { requiresAdmin: true } },
],
},
]
@@ -7,3 +7,19 @@ export function listSkills(params) {
export function getSkillByKey(key) {
return http.get(`/skills/by-key/${key}`)
}
export function createSkillDefinition(data) {
return http.post('/admin/skills', data)
}
export function updateSkillDefinition(id, data) {
return http.put(`/admin/skills/${id}`, data)
}
export function deleteSkillDefinition(id) {
return http.delete(`/admin/skills/${id}`)
}
export function getSkillRemovePlan(id) {
return http.get(`/admin/skills/${id}/remove-plan`)
}
@@ -17,13 +17,17 @@ import progressReportManifest from '@/skills/packages/progress-report/manifest'
import contractBriefManifest from '@/skills/packages/contract-brief/manifest'
import interviewSummaryManifest from '@/skills/packages/interview-summary/manifest'
import policyRewriteManifest from '@/skills/packages/policy-rewrite/manifest'
import textToSpeechManifest from '@/skills/packages/text-to-speech/manifest'
// Register office skill execution builders (side-effect import).
// This centralizes all package→execution imports into one place.
import '@/skills/packages/officeExecutions'
export const builtinSkillManifests = [
const hiddenSkillManifests = [
smartAssistantManifest,
]
export const builtinSkillManifests = [
documentTranslateManifest,
copyProofreadingManifest,
audioTranscribeManifest,
@@ -40,9 +44,11 @@ export const builtinSkillManifests = [
contractBriefManifest,
interviewSummaryManifest,
policyRewriteManifest,
textToSpeechManifest,
]
const builtinSkillRegistry = createRegistry(builtinSkillManifests)
const allSkillRegistry = createRegistry([...hiddenSkillManifests, ...builtinSkillManifests])
// 通用助手的模式。快速模式直出回答,深思模式开启 thinking 多步规划。
export const assistantModes = [
@@ -60,5 +66,6 @@ export const staticSkillCatalog = withObjectCodes(
)
export function getStaticSkillByKey(key) {
return staticSkillCatalog.find((item) => item.key === key) || null
return withObjectCodes(allSkillRegistry.manifests(), { kind: 'skill' })
.find((item) => item.key === key) || null
}
@@ -8,7 +8,7 @@ export default createSkillManifest({
key: 'batch-extract',
label: '批量字段提取 → Excel',
caption: '上百份文件的关键信息,自动汇表。',
description: '上百份文件的关键信息,自动汇表。 操作:在「智能助手」中选择目标知识库 → 用自然语言告诉 AI 需要哪些字段 → AI 自动遍历每份文件,逐项提取,保存为 Excel',
description: '上百份文件的关键信息,自动汇表。 操作:在「通用专员」中选择目标知识库 → 用自然语言告诉 AI 需要哪些字段 → AI 自动遍历每份文件,逐项提取,保存为 Excel',
color: '#39b07a',
skillTags: ['字段抽取', '批量处理', '结构化输出', '异常回看'],
workflowSchema: [
@@ -9,7 +9,7 @@ export default createOfficeSkillManifest({
artifactType: 'presentation',
fileFormat: 'pptx',
color: '#4f46e5',
description: '从资料到演示文稿,一步到位。 操作:在「智能助手」中上传资料或录音 → 说"基于这份资料生成一份 8 页 PPT" → 下载 .pptx 文件,每页标题清晰、要点完整',
description: '从资料到演示文稿,一步到位。 操作:在「通用专员」中上传资料或录音 → 说"基于这份资料生成一份 8 页 PPT" → 下载 .pptx 文件,每页标题清晰、要点完整',
defaultPrompt: '为季度经营复盘生成一版 8 页 PPT 大纲',
resultTitle: '演示文稿骨架',
summary: '已按主题、受众和页数要求整理出演示逻辑,并给出可继续细化的文稿骨架。',
@@ -8,7 +8,7 @@ export default createSkillManifest({
key: 'report-generation',
label: '一键生成报告',
caption: '聊完天,PDF/Word 报告就写好了。',
description: '聊完天,PDF/Word 报告就写好了。 操作:在「智能助手」中上传资料或讨论要点 → 说"帮我做成 PDF 报告"或"整理成 Word" → AI 自动生成结构化内容,排版为正式文档,一键下载',
description: '聊完天,PDF/Word 报告就写好了。 操作:在「通用专员」中上传资料或讨论要点 → 说"帮我做成 PDF 报告"或"整理成 Word" → AI 自动生成结构化内容,排版为正式文档,一键下载',
color: '#9c27b0',
skillTags: ['日报周报', '复盘总结', '章节规划', '管理摘要'],
workflowSchema: [
@@ -6,9 +6,9 @@ import {
export default createSkillManifest({
key: 'smart-assistant',
label: '智能助手',
label: '通用专员',
caption: '说句话,复杂任务自动完成。',
description: '说句话,复杂任务自动完成。 操作:打开「智能助手」,用自然语言描述目标 → 可拖入多份文件、关联知识库 → 助手自动拆解为「计划→执行→验证→交付」,全程可见可控',
description: '说句话,复杂任务自动完成。 操作:打开「通用专员」,用自然语言描述目标 → 可拖入多份文件、关联知识库 → 专员自动拆解为「计划→执行→验证→交付」,全程可见可控',
color: '#409eff',
skillTags: ['任务澄清', '方案拆解', '对象分派', '交付起步'],
workflowSchema: [
@@ -57,7 +57,7 @@ export default createSkillManifest({
greeting: '博昇AI数字员工,您说,我做',
tagline: '今天帮你做些什么?描述你的任务,或先挂载技能与材料',
openingPrompt: '直接告诉我目标,或者把材料贴给我,我来帮你起步。',
relationshipToUser: '你的通用协作搭档',
relationshipToUser: '你的通用协作专员',
starterPrompts: [
'帮我拆解这个任务,并列出下一步',
'把这段产品介绍整理成正式文案',
@@ -0,0 +1,52 @@
import {
createSkillArtifact,
createSkillManifest,
createSkillWorkflowStep,
} from '@/skills/contracts/manifest'
export default createSkillManifest({
id: 'eai.text-to-speech',
code: 'EAI-T-215',
key: 'text-to-speech',
label: '文字转语音',
caption: '把文本合成为可播放、可下载的语音结果。',
description: '把文本合成为可播放、可下载的语音结果。 操作:输入要朗读的文字,可选音色和语速 → 系统生成 MP3 音频并回挂到任务产物里',
color: '#0ea5e9',
skillTags: ['语音合成', '音频产物', '朗读稿'],
workflowSchema: [
createSkillWorkflowStep({
title: '接收文本与参数',
detail: '先确认要朗读的文本内容,以及音色、语速等可选参数。',
expectedOutput: '可执行的合成输入',
}),
createSkillWorkflowStep({
title: '生成语音文件',
detail: '调用本地 TTS 引擎把文本合成为 MP3 音频。',
expectedOutput: '可播放的音频文件',
}),
createSkillWorkflowStep({
title: '回挂任务产物',
detail: '把音频结果写回当前任务,方便继续播放、下载和留档。',
expectedOutput: '任务音频产物',
}),
],
artifactSchema: [
createSkillArtifact({
title: '语音合成结果',
detail: '生成后的 MP3 音频,可播放、下载和继续引用。',
artifactType: 'audio',
fileFormat: 'mp3',
}),
],
interactionCard: {
greeting: '把要朗读的文字给我,我来生成语音文件。',
tagline: '支持把文本快速合成为 MP3 音频。',
openingPrompt: '把文本贴给我,也可以顺手告诉我想要的音色和语速。',
relationshipToUser: '你的语音合成技能',
starterPrompts: [
'把这段产品介绍转成普通话讲解音频',
'把这段通知合成为语音,语速稍微慢一点',
'帮我生成一版可直接播放的朗读音频',
],
},
})
@@ -149,7 +149,9 @@ export const useSkillCatalogStore = defineStore('skillCatalog', () => {
try {
const res = await listSkills()
skills.value = Array.isArray(res?.data)
? res.data.map((item) => normalizeSkill(item))
? res.data
.map((item) => normalizeSkill(item))
.filter((item) => item.key !== 'smart-assistant')
: []
loaded.value = true
} catch (error) {
@@ -160,7 +162,8 @@ export const useSkillCatalogStore = defineStore('skillCatalog', () => {
}
function getByKey(key) {
return skillMap.value[String(key || '').trim()] || null
const normalized = String(key || '').trim()
return skillMap.value[normalized] || getStaticSkillByKey(normalized) || null
}
return {
@@ -13,13 +13,17 @@ export function getSpecialistByKey(key) {
}
export function createSpecialist(data) {
return http.post('/specialists', data)
return http.post('/admin/specialists', data)
}
export function updateSpecialist(id, data) {
return http.put(`/specialists/${id}`, data)
return http.put(`/admin/specialists/${id}`, data)
}
export function deleteSpecialist(id) {
return http.delete(`/specialists/${id}`)
return http.delete(`/admin/specialists/${id}`)
}
export function getSpecialistRemovePlan(id) {
return http.get(`/admin/specialists/${id}/remove-plan`)
}
@@ -1,5 +1,6 @@
import { withObjectCodes } from '@/utils/objectCode'
import { createRegistry } from '@/specialists/core/registry'
import generalAssistantManifest from '@/specialists/packages/general-assistant/manifest'
import contractReviewManifest from '@/specialists/packages/contract-review/manifest'
import solutionProposalManifest from '@/specialists/packages/solution-proposal/manifest'
import wechatOfficialAccountManifest from '@/specialists/packages/wechat-official-account/manifest'
@@ -7,6 +8,7 @@ import customerFollowupManifest from '@/specialists/packages/customer-followup/m
import resumeProcessorManifest from '@/specialists/packages/resume-processor/manifest'
const builtinSpecialistRegistry = createRegistry([
generalAssistantManifest,
contractReviewManifest,
solutionProposalManifest,
wechatOfficialAccountManifest,
@@ -0,0 +1,26 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'general-assistant',
label: '通用专员',
tier: 'generic',
specialistMode: 'dw',
marketTag: '已安装',
summary: '说句话,复杂任务自动完成。',
status: '默认值守',
risk: '按当前任务推进',
color: '#409eff',
stage: '默认值守',
progress: 100,
interactionCard: {
greeting: '博昇AI数字员工,您说,我做',
tagline: '今天帮你做些什么?描述你的任务,或先挂载技能与材料',
openingPrompt: '直接告诉我目标,或者把材料贴给我,我来帮你起步。',
relationshipToUser: '你的通用协作专员',
starterPrompts: [
'帮我拆解这个任务,并列出下一步',
'把这段产品介绍整理成正式文案',
'先帮我判断这个问题该找哪个专员或技能',
],
},
})
@@ -1,26 +0,0 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'knowledge-operations',
label: '知识运营专员',
tier: 'generic',
specialistMode: 'dw',
marketTag: '已安装',
summary: '对象字典、证据索引与规则同步',
status: '4 个待整理',
risk: '1 个映射待确认',
color: '#8e6cf2',
stage: '证据整理',
progress: 63,
interactionCard: {
greeting: '我是知识运营专员,可以帮你整理对象字典、证据索引和规则映射。',
tagline: '把散的知识资产收成可查、可用、可治理的结构。',
openingPrompt: '告诉我这次要整理哪类知识资产,我先帮你搭索引。',
relationshipToUser: '你的知识治理协作搭档',
starterPrompts: [
'帮我整理这一批知识条目和标签',
'把这些规则映射成对象字典',
'先盘点一下这组资料的证据索引结构',
],
},
})
@@ -1,26 +0,0 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'presentation-briefing',
label: '汇报专员',
tier: 'generic',
specialistMode: 'dw',
marketTag: '已安装',
summary: '经营汇报、方案汇报与培训课件统一出稿',
status: '2 个待成稿',
risk: '1 个页数待压缩',
color: '#4f46e5',
stage: '演示编排',
progress: 68,
interactionCard: {
greeting: '我是汇报专员,可以先判断汇报对象和页数,再把材料压成能讲、能交、能继续细化的演示骨架。',
tagline: '把散材料收成一版结构清楚、讲得出去的汇报 PPT。',
openingPrompt: '告诉我汇报对象、场景、目标和页数,我先帮你规划页面结构。',
relationshipToUser: '你的汇报交付协作搭档',
starterPrompts: [
'帮我做一版 8 页管理层汇报 PPT',
'把这份方案压成可讲的客户汇报结构',
'根据这批材料生成培训课件骨架和讲稿备注',
],
},
})
@@ -1,26 +0,0 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'process-coordination',
label: '流程推进专员',
tier: 'generic',
specialistMode: 'adw',
marketTag: '可升级',
summary: '跨部门催办、checkpoint 跟进与阻塞升级',
status: '6 个待跟进',
risk: '2 个阻塞',
color: '#14b8a6',
stage: '阻塞清理',
progress: 57,
interactionCard: {
greeting: '我是流程推进专员,可以帮你梳理 checkpoint、跟进阻塞并推动事项落地。',
tagline: '把多方协作里的待办、阻塞和升级路径理清楚。',
openingPrompt: '把当前事项、参与方和卡点告诉我,我先帮你排推进节奏。',
relationshipToUser: '你的流程推进协作搭档',
starterPrompts: [
'帮我梳理这个项目的推进 checkpoint',
'把当前阻塞整理成催办清单',
'根据这些会议纪要生成后续跟进事项',
],
},
})
@@ -1,26 +0,0 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'report-generation',
label: '报告生成专员',
tier: 'generic',
specialistMode: 'dw',
marketTag: '可升级',
summary: '日报、周报、复盘和交付说明自动生成',
status: '3 个待输出',
risk: '0 个异常',
color: '#ec4899',
stage: '报告汇总',
progress: 76,
interactionCard: {
greeting: '我是报告生成专员,可以把过程材料整理成日报、周报、复盘和交付说明。',
tagline: '把分散信息压成结构完整、可直接提交的报告。',
openingPrompt: '把素材贴给我,或者告诉我报告对象、周期和格式要求。',
relationshipToUser: '你的报告整理协作搭档',
starterPrompts: [
'把今天的工作整理成日报',
'根据这些记录生成项目周报',
'帮我输出一版交付复盘说明',
],
},
})
@@ -2,7 +2,7 @@ import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'resume-processor',
label: '招聘筛选专员',
label: '简历筛选与招聘专员',
tier: 'industry',
specialistMode: 'dw',
marketTag: '试用',
@@ -13,10 +13,10 @@ export default createSpecialistManifest({
stage: '候选人筛选',
progress: 0,
interactionCard: {
greeting: '我是招聘筛选专员,可以先收拢招聘邮件与候选人资料,再完成筛选、排序和后续面试推进。',
greeting: '我是简历筛选与招聘专员,可以先收拢招聘邮件与候选人资料,再完成筛选、排序和后续面试推进。',
tagline: '把招聘入口整理、候选人筛选和后续推进收成一条闭环。',
openingPrompt: '告诉我岗位要求、招聘邮箱情况和筛选标准,我先帮你做一轮归档与初筛。',
relationshipToUser: '你的招聘筛选协作搭档',
relationshipToUser: '你的简历筛选与招聘协作搭档',
starterPrompts: [
'先把今天收到的招聘邮件和附件归档成候选人清单',
'根据这个 JD 帮我筛选一批候选人',
@@ -2,7 +2,7 @@ import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'wechat-official-account',
label: '公众号专员',
label: '公众号创作专员',
tier: 'generic',
specialistMode: 'dw',
marketTag: '已安装',
@@ -13,7 +13,7 @@ export default createSpecialistManifest({
stage: '正文创作',
progress: 0,
interactionCard: {
greeting: '我是公众号专员,可以帮你把选题、提纲、正文和发布准备串起来。',
greeting: '我是公众号创作专员,可以帮你把选题、提纲、正文和发布准备串起来。',
tagline: '把内容创作流程从想法推进到可发布。',
openingPrompt: '告诉我主题、受众和语气,我先帮你起草内容结构。',
relationshipToUser: '你的内容创作协作搭档',
@@ -2,6 +2,7 @@ import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { listSpecialists } from '@/specialists/api/specialist'
import { staticSpecialistCatalog } from '@/specialists/core/builtin'
import { GENERAL_ASSISTANT_KEY } from '@/store/taskRuntime'
function safeParseObject(value, fallback = {}) {
if (value && typeof value === 'object' && !Array.isArray(value)) return value
@@ -36,10 +37,64 @@ function normalizeSpecialist(item) {
}
}
function applySystemSpecialistConvention(item, fallback = null) {
if (fallback?.label) {
item = {
...item,
label: fallback.label,
summary: item.summary || fallback.summary || '',
tier: item.tier || fallback.tier || '',
specialistMode: item.specialistMode || fallback.specialistMode || '',
objectEntryRoute: item.objectEntryRoute || fallback.objectEntryRoute || '',
marketTag: item.marketTag || fallback.marketTag || '',
interactionCard:
Object.keys(item.interactionCard || {}).length
? item.interactionCard
: (fallback.interactionCard || {}),
}
}
if (item.key !== GENERAL_ASSISTANT_KEY) return item
return {
...item,
label: '通用专员',
summary: item.summary || '说句话,复杂任务自动完成。',
tier: item.tier || 'generic',
specialistMode: item.specialistMode || 'dw',
objectEntryRoute: item.objectEntryRoute || '/home',
marketTag: item.marketTag || '已安装',
}
}
function mergeSpecialists(remoteItems = []) {
const fallbackMap = new Map(
staticSpecialistCatalog.map((item) => {
const normalized = applySystemSpecialistConvention(normalizeSpecialist(item))
return [normalized.key, normalized]
})
)
const merged = remoteItems.map((item) => {
const normalized = normalizeSpecialist(item)
const fallback = fallbackMap.get(normalized.key) || {}
return applySystemSpecialistConvention({
...fallback,
...normalized,
interactionCard:
Object.keys(normalized.interactionCard || {}).length
? normalized.interactionCard
: (fallback.interactionCard || {}),
}, fallback)
})
const seen = new Set(merged.map((item) => item.key))
const fallbacks = [...fallbackMap.values()].filter((item) => !seen.has(item.key))
return [...merged, ...fallbacks]
}
export const useSpecialistCatalogStore = defineStore('specialistCatalog', () => {
const loaded = ref(false)
const loading = ref(false)
const specialists = ref(staticSpecialistCatalog.map((item) => normalizeSpecialist(item)))
const specialists = ref(mergeSpecialists())
const specialistMap = computed(() =>
Object.fromEntries(specialists.value.map((item) => [item.key, item]))
@@ -51,13 +106,11 @@ export const useSpecialistCatalogStore = defineStore('specialistCatalog', () =>
loading.value = true
try {
const res = await listSpecialists()
specialists.value = Array.isArray(res?.data)
? res.data.map((item) => normalizeSpecialist(item))
: []
specialists.value = Array.isArray(res?.data) ? mergeSpecialists(res.data) : mergeSpecialists()
loaded.value = true
} catch (error) {
console.warn('load specialist catalog failed', error)
specialists.value = staticSpecialistCatalog.map((item) => normalizeSpecialist(item))
specialists.value = mergeSpecialists()
loaded.value = true
} finally {
loading.value = false
@@ -13,7 +13,7 @@ function readPersisted() {
}
}
const HOME_TAB = { pageRoute: '/home', label: '通用助手', closable: false }
const HOME_TAB = { pageRoute: '/home', label: '通用专员', closable: false }
// 本地存储里可能还残留旧版胶囊:以前每个具体执行页都会各占一个 tab。
// 现在执行面只保留 /home,这里在读取时统一折回一级项,避免升级后看到一排陈年胶囊。
@@ -1,40 +1,95 @@
const GENERIC_ROUTE_NAMES = new Set([
'默认',
'均衡',
'全能',
'推理',
'本地',
'快聊',
'绘图',
'OpenRouter',
'OpenAI',
'Ollama',
])
function normalizeProviderLabel(provider) {
const value = String(provider || '').trim().toLowerCase()
if (!value) return ''
if (value === 'ollama') return 'Ollama'
if (value === 'llamacpp') return 'llama.cpp'
if (value === 'lmuai') return 'LMUAI'
if (value === 'siliconflow') return 'SiliconFlow'
if (value === 'openrouter') return 'OpenRouter'
if (value === 'openai') return 'OpenAI'
return provider
}
function normalizeModelLabel(model) {
return String(model || '')
.trim()
.split('/')
.pop()
.replace(/text-embedding-3-small/gi, 'text-embedding-3-small')
.replace(/deepseek-v4-flash-0731/gi, 'DeepSeek V4 Flash 0731')
.replace(/deepseek-v4-flash/gi, 'DeepSeek V4 Flash')
.replace(/deepseek-v3.2/gi, 'DeepSeek V3.2')
.replace(/deepseek-flash/gi, 'DeepSeek Flash')
.replace(/gpt-5.4-image-2/gi, 'GPT 5.4 Image 2')
.replace(/gpt-5.4/gi, 'GPT 5.4')
.replace(/grok-4.20/gi, 'Grok 4.20')
.replace(/qwen3\.5-4b-q4_k_m\.gguf/gi, 'Qwen3.5 4B')
.replace(/qwen2.5:7b/gi, 'Qwen2.5 7B')
.replace(/bge-m3/gi, 'BGE-M3')
.replace(/llava/gi, 'LLaVA')
.replace(/flux-1.1-pro/gi, 'FLUX 1.1 Pro')
.trim()
}
function routeUsageLabel(aiRoute) {
const routeId = String(aiRoute?.ai_route_id || '').trim()
if (routeId === 'chat_route_ollama_qwen') return '本地对话'
if (routeId === 'chat_route_ollama_qwen_fast') return '本地快聊'
if (routeId === 'embed_route_ollama_bge_m3') return '本地向量'
const shortRouteName = String(aiRoute?.short_route_name || '').trim()
if (!shortRouteName || GENERIC_ROUTE_NAMES.has(shortRouteName)) return ''
return shortRouteName
}
function routeModelLabel(aiRoute) {
const fromModel = normalizeModelLabel(aiRoute?.model)
if (fromModel) return fromModel
return normalizeModelLabel(aiRoute?.short_model_name)
}
export function formatAiRouteShortLabel(aiRoute) {
if (!aiRoute) return '未设置'
if (aiRoute.ai_route_id === 'chat_route_auto' || aiRoute.ai_route_id === 'embed_route_auto') {
return '自动'
}
const shortRouteName = (aiRoute.short_route_name || '').trim()
const shortModelName = (aiRoute.short_model_name || '').trim()
if (shortRouteName && shortModelName) {
return `${shortRouteName}/${shortModelName}`
}
if (shortModelName) return shortModelName
if (shortRouteName) return shortRouteName
const model = (aiRoute.model || '').split('/').pop() || ''
const modelShort = model
.replace(/text-embedding-3-small/gi, 'Emb3')
.replace(/deepseek-v4-flash/gi, 'DS4F')
.replace(/deepseek-v3.2/gi, 'DSV3')
.replace(/gpt-5.4-image-2/gi, 'G图2')
.replace(/gpt-5.4/gi, 'GPT5')
.replace(/grok-4.20/gi, 'Grok4')
.replace(/qwen2.5:7b/gi, 'Qwen')
.replace(/bge-m3/gi, 'BGE')
.trim()
return modelShort || aiRoute.description || aiRoute.ai_route_id || '未设置'
const usage = routeUsageLabel(aiRoute)
const model = routeModelLabel(aiRoute)
if (usage && model) return `${usage} · ${model}`
if (model) return model
if (usage) return usage
return aiRoute.description || aiRoute.ai_route_id || '未设置'
}
export function formatAiRouteTitle(aiRoute) {
if (!aiRoute) return '未设置'
const provider = normalizeProviderLabel(aiRoute.provider)
const model = routeModelLabel(aiRoute)
if (aiRoute.ai_route_id === 'chat_route_auto' || aiRoute.ai_route_id === 'embed_route_auto') {
const detail = [provider, model].filter(Boolean).join(' / ')
const resolved = aiRoute.resolved_ai_route_id || ''
const detail = aiRoute.description || resolved || '自动选择当前最优可用模型'
return `自动 · ${detail}`
const suffix = detail || resolved || '自动选择当前最优可用模型'
return `自动 · 当前 ${suffix}`
}
const shortLabel = formatAiRouteShortLabel(aiRoute)
const detail = aiRoute.description || [aiRoute.provider, aiRoute.model].filter(Boolean).join(' / ') || aiRoute.ai_route_id
return shortLabel === detail ? detail : `${shortLabel} · ${detail}`
const usage = routeUsageLabel(aiRoute)
if (usage && provider && model) return `${usage} · ${provider} / ${model}`
if (usage && model) return `${usage} · ${model}`
if (provider && model) return `${model} · ${provider}`
return aiRoute.description || aiRoute.ai_route_id || '未设置'
}
@@ -60,6 +60,79 @@
/>
</div>
</el-card>
<el-dialog
v-model="quizVisible"
:title="result ? '考试结果' : (currentPaper?.name || '开始考试')"
width="820px"
top="4vh"
destroy-on-close
>
<template v-if="result">
<div class="result-summary">
<el-tag :type="result.passed ? 'success' : 'danger'" size="large">
{{ result.score }} / {{ result.total_score }} 分
</el-tag>
<div class="result-meta">
{{ result.passed ? '已通过' : '未通过' }},及格线 {{ result.pass_score }} 分,答对 {{ result.correct_count }} 题,答错 {{ result.wrong_count }} 题
</div>
</div>
<el-divider content-position="left">答题解析</el-divider>
<div v-for="(q, idx) in result.detail || []" :key="q.question_id || idx" class="question-item">
<div class="question-title">
{{ idx + 1 }}. {{ q.stem }}
<el-tag size="small" :type="q.is_correct ? 'success' : 'danger'">
{{ q.is_correct ? '正确' : '错误' }}
</el-tag>
</div>
<div class="detail-line">你的答案:{{ formatUserAnswer(q.user_answer) }}</div>
<div class="detail-line">正确答案:{{ formatAnswer(q.correct_answer) }}</div>
<div v-if="q.explanation" class="detail-line explain">解析:{{ q.explanation }}</div>
</div>
</template>
<template v-else>
<div class="paper-meta">
共 {{ questions.length }} 题,正式考试提交后将记录成绩。
</div>
<div v-for="(q, idx) in questions" :key="q.id" class="question-item">
<div class="question-title">{{ idx + 1 }}. {{ q.stem }}</div>
<el-radio-group v-if="q.type === 'single'" v-model="answers[q.id]" class="question-options">
<el-radio v-for="opt in q.options || []" :key="opt.key" :label="opt.key">
{{ opt.key }}. {{ opt.text }}
</el-radio>
</el-radio-group>
<el-checkbox-group v-else-if="q.type === 'multiple'" v-model="answers[q.id]" class="question-options">
<el-checkbox v-for="opt in q.options || []" :key="opt.key" :label="opt.key">
{{ opt.key }}. {{ opt.text }}
</el-checkbox>
</el-checkbox-group>
<el-radio-group v-else-if="q.type === 'judge'" v-model="answers[q.id]" class="question-options">
<el-radio label="true">正确</el-radio>
<el-radio label="false">错误</el-radio>
</el-radio-group>
<el-input
v-else
v-model="answers[q.id]"
type="textarea"
:rows="4"
placeholder="请输入答案"
/>
</div>
</template>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeDialog">{{ result ? '关闭' : '取消' }}</el-button>
<el-button v-if="!result" type="primary" :loading="submitting" @click="submit">提交试卷</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
@@ -93,7 +166,7 @@ async function load() {
const exams = res.data || []
items.value = exams
.filter((p) => p.type === 'formal')
.slice((page.value - 1) * size.value, (page.value.value * size.value))
.slice((page.value - 1) * size.value, page.value * size.value)
total.value = exams.filter((p) => p.type === 'formal').length
} catch (e) {
items.value = []
@@ -118,6 +191,7 @@ async function startExam(p) {
try {
const res = await examStart(p.id)
const d = res.data
result.value = null
sessionId.value = d.session_id
currentPaper.value = { name: d.exam_name }
questions.value = d.questions || []
@@ -150,6 +224,11 @@ async function submit() {
}
}
function closeDialog() {
quizVisible.value = false
result.value = null
}
function formatAnswer(correct) {
if (!correct) return '—'
if (correct.length === 1 && (correct[0] === 'true' || correct[0] === 'false')) {
@@ -158,6 +237,13 @@ function formatAnswer(correct) {
return correct.join('、')
}
function formatUserAnswer(ans) {
if (ans == null || ans === '') return '未作答'
if (Array.isArray(ans)) return ans.join('、')
if (ans === 'true' || ans === 'false') return ans === 'true' ? '正确' : '错误'
return String(ans)
}
onMounted(load)
</script>
@@ -175,4 +261,39 @@ onMounted(load)
display: flex;
justify-content: flex-start;
}
.paper-meta,
.result-summary {
margin-bottom: 12px;
}
.result-meta {
margin-top: 8px;
color: #606266;
}
.question-item {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.question-item:last-child {
border-bottom: none;
}
.question-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
margin-bottom: 10px;
}
.question-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-line {
font-size: 13px;
color: #606266;
margin-bottom: 4px;
}
.detail-line.explain {
color: #909399;
}
</style>
@@ -110,7 +110,7 @@ async function load() {
const records = res.data || []
items.value = records
.filter((r) => !filters.examName || (r.exam_name || '').includes(filters.examName))
.slice((page.value - 1) * size.value, page.value.value * size.value)
.slice((page.value - 1) * size.value, page.value * size.value)
total.value = records.length
} catch (e) {
items.value = []
@@ -60,6 +60,79 @@
/>
</div>
</el-card>
<el-dialog
v-model="quizVisible"
:title="result ? '练习结果' : (currentPaper?.name || '开始练习')"
width="820px"
top="4vh"
destroy-on-close
>
<template v-if="result">
<div class="result-summary">
<el-tag :type="result.passed ? 'success' : 'danger'" size="large">
{{ result.score }} / {{ result.total_score }} 分
</el-tag>
<div class="result-meta">
{{ result.passed ? '已通过' : '未通过' }},答对 {{ result.correct_count }} 题,答错 {{ result.wrong_count }} 题
</div>
</div>
<el-divider content-position="left">答题解析</el-divider>
<div v-for="(q, idx) in result.detail || []" :key="q.question_id || idx" class="question-item">
<div class="question-title">
{{ idx + 1 }}. {{ q.stem }}
<el-tag size="small" :type="q.is_correct ? 'success' : 'danger'">
{{ q.is_correct ? '正确' : '错误' }}
</el-tag>
</div>
<div class="detail-line">你的答案:{{ formatUserAnswer(q.user_answer) }}</div>
<div class="detail-line">正确答案:{{ formatAnswer(q.correct_answer) }}</div>
<div v-if="q.explanation" class="detail-line explain">解析:{{ q.explanation }}</div>
</div>
</template>
<template v-else>
<div class="paper-meta">
共 {{ questions.length }} 题,请完成后提交。
</div>
<div v-for="(q, idx) in questions" :key="q.id" class="question-item">
<div class="question-title">{{ idx + 1 }}. {{ q.stem }}</div>
<el-radio-group v-if="q.type === 'single'" v-model="answers[q.id]" class="question-options">
<el-radio v-for="opt in q.options || []" :key="opt.key" :label="opt.key">
{{ opt.key }}. {{ opt.text }}
</el-radio>
</el-radio-group>
<el-checkbox-group v-else-if="q.type === 'multiple'" v-model="answers[q.id]" class="question-options">
<el-checkbox v-for="opt in q.options || []" :key="opt.key" :label="opt.key">
{{ opt.key }}. {{ opt.text }}
</el-checkbox>
</el-checkbox-group>
<el-radio-group v-else-if="q.type === 'judge'" v-model="answers[q.id]" class="question-options">
<el-radio label="true">正确</el-radio>
<el-radio label="false">错误</el-radio>
</el-radio-group>
<el-input
v-else
v-model="answers[q.id]"
type="textarea"
:rows="4"
placeholder="请输入答案"
/>
</div>
</template>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeDialog">{{ result ? '关闭' : '取消' }}</el-button>
<el-button v-if="!result" type="primary" :loading="submitting" @click="submit">提交答案</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
@@ -93,7 +166,7 @@ async function load() {
const exams = res.data || []
items.value = exams
.filter((p) => p.type === 'self_test')
.slice((page.value - 1) * size.value, page.value.value * size.value)
.slice((page.value - 1) * size.value, page.value * size.value)
total.value = exams.filter((p) => p.type === 'self_test').length
} catch (e) {
items.value = []
@@ -118,6 +191,7 @@ async function startExam(p) {
try {
const res = await examStart(p.id)
const d = res.data
result.value = null
sessionId.value = d.session_id
currentPaper.value = { name: d.exam_name }
questions.value = d.questions || []
@@ -150,6 +224,11 @@ async function submit() {
}
}
function closeDialog() {
quizVisible.value = false
result.value = null
}
function formatAnswer(correct) {
if (!correct) return '—'
if (correct.length === 1 && (correct[0] === 'true' || correct[0] === 'false')) {
@@ -158,6 +237,13 @@ function formatAnswer(correct) {
return correct.join('、')
}
function formatUserAnswer(ans) {
if (ans == null || ans === '') return '未作答'
if (Array.isArray(ans)) return ans.join('、')
if (ans === 'true' || ans === 'false') return ans === 'true' ? '正确' : '错误'
return String(ans)
}
onMounted(load)
</script>
@@ -175,4 +261,39 @@ onMounted(load)
display: flex;
justify-content: flex-start;
}
.paper-meta,
.result-summary {
margin-bottom: 12px;
}
.result-meta {
margin-top: 8px;
color: #606266;
}
.question-item {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.question-item:last-child {
border-bottom: none;
}
.question-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
margin-bottom: 10px;
}
.question-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-line {
font-size: 13px;
color: #606266;
margin-bottom: 4px;
}
.detail-line.explain {
color: #909399;
}
</style>
@@ -96,9 +96,9 @@ async function load() {
if (filters.department) {
items.value = allItems
.filter((r) => r.department === filters.department)
.slice((page.value - 1) * size.value, page.value.value * size.value)
.slice((page.value - 1) * size.value, page.value * size.value)
} else {
items.value = allItems.slice((page.value - 1) * size.value, page.value.value * size.value)
items.value = allItems.slice((page.value - 1) * size.value, page.value * size.value)
}
myRank.value = d.my_rank || 0
total.value = d.total || allItems.length
@@ -116,7 +116,7 @@ async function load() {
const records = res.data || []
items.value = records
.filter((c) => !filters.examName || (c.exam_name || '').includes(filters.examName))
.slice((page.value - 1) * size.value, page.value.value * size.value)
.slice((page.value - 1) * size.value, page.value * size.value)
total.value = records.length
} catch (e) {
items.value = []
@@ -112,7 +112,7 @@ async function load() {
if (filters.resolved !== null && m.resolved !== filters.resolved) return false
return true
})
.slice((page.value - 1) * size.value, page.value.value * size.value)
.slice((page.value - 1) * size.value, page.value * size.value)
total.value = records.length
} catch (e) {
items.value = []
@@ -3,7 +3,7 @@
<div class="page-header">
<div class="page-kicker">我的设置</div>
<h1>账号、身份与个人偏好</h1>
<p>这里收口个人级设置,不再把 APP 内部页面混进全局导航入口。</p>
<p>这里收口个人级设置,不再把 xApp 内部页面混进全局导航入口。</p>
</div>
<el-tabs v-model="activeTab" class="settings-tabs">
@@ -31,7 +31,7 @@
<el-descriptions-item label="入职批次">{{ identityInfo.hireBatch }}</el-descriptions-item>
<el-descriptions-item label="身份说明">{{ identityInfo.identityHint }}</el-descriptions-item>
</el-descriptions>
<div class="panel-note">组织身份用于项目、知识库、长程 APP 的权限与默认上下文判断。</div>
<div class="panel-note">组织身份用于项目、知识库、长程 xApp 的权限与默认上下文判断。</div>
</el-card>
</el-tab-pane>
@@ -42,7 +42,7 @@
<el-select v-model="preferences.defaultEntry" style="width: 280px;">
<el-option label="新建任务" value="/home" />
<el-option label="项目" value="/projects" />
<el-option label="APP目录" value="/xapps" />
<el-option label="xApp目录" value="/xapps" />
</el-select>
</el-form-item>
<el-form-item label="主题风格">
@@ -1,504 +0,0 @@
<template>
<div class="admin-overview-page">
<section class="hero-card">
<div class="hero-eyebrow">Governance Center</div>
<h1>后台管理与任务治理</h1>
</section>
<section class="meta-row">
<span v-for="item in compactStats" :key="item.label" class="meta-pill">
<span class="meta-pill-label">{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</span>
</section>
<section class="main-grid">
<article class="panel">
<div class="panel-head">
<div class="panel-title">待处理任务</div>
<div class="panel-caption">把现在最该动手的事项放到前面。</div>
</div>
<div class="focus-list">
<button
v-for="item in focusItems"
:key="item.label"
class="focus-row"
type="button"
@click="router.push(item.pageRoute)"
>
<div>
<div class="metric-title">{{ item.label }}</div>
<div class="metric-desc">{{ item.desc }}</div>
</div>
<div class="focus-right">
<strong>{{ item.value }}</strong>
<span>打开</span>
</div>
</button>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div class="panel-title">治理入口</div>
<div class="panel-caption">按任务语义进入,不再记后台菜单。</div>
</div>
<div class="entry-list">
<button
v-for="domain in governanceDomains"
:key="domain.key"
class="entry-row"
type="button"
@click="router.push(domain.pageRoute)"
>
<div>
<div class="metric-title">{{ domain.title }}</div>
<div class="metric-desc">{{ domain.subtitle }}</div>
</div>
<span class="entry-count">{{ domain.count }}</span>
</button>
</div>
</article>
</section>
<section class="main-grid secondary-grid">
<article class="panel">
<div class="panel-head">
<div class="panel-title">风险提醒</div>
<div class="panel-caption">只保留需要你尽快关注的几项。</div>
</div>
<div class="mini-group">
<div class="mini-group-title">部门</div>
<div v-if="riskDepartments.length" class="metric-list compact-list">
<div v-for="item in riskDepartments" :key="item.department" class="metric-row">
<div>
<div class="metric-title">{{ item.department }}</div>
<div class="metric-desc">{{ item.employee_count }} 人 · 已读 {{ item.company_rate }}%</div>
</div>
<el-tag
size="small"
:type="item.pass_rate >= 80 ? 'success' : item.pass_rate >= 60 ? 'warning' : 'danger'"
effect="plain"
>
{{ item.pass_rate }}%
</el-tag>
</div>
</div>
<div v-else class="empty-tip">暂无部门风险项</div>
</div>
<div class="mini-group">
<div class="mini-group-title">岗位</div>
<div v-if="riskPositions.length" class="metric-list compact-list">
<div v-for="item in riskPositions" :key="item.position_id" class="metric-row">
<div>
<div class="metric-title">{{ item.position_name }}</div>
<div class="metric-desc">{{ item.employee_count }} 人 · 参考 {{ item.taken_count }} 次</div>
</div>
<el-tag
size="small"
:type="item.pass_rate >= 80 ? 'success' : item.pass_rate >= 60 ? 'warning' : 'danger'"
effect="plain"
>
{{ item.pass_rate }}%
</el-tag>
</div>
</div>
<div v-else class="empty-tip">暂无岗位风险项</div>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div class="panel-title">运行状态</div>
<div class="panel-caption">一眼看清系统是否在正常跑。</div>
</div>
<div class="status-pills">
<span class="status-pill">连接器 {{ liveConnectorCount }}</span>
<span class="status-pill">AI 用户 {{ aiUsageSummary.userCount }}</span>
<span class="status-pill">题目 {{ dashboard.question_count }}</span>
<span class="status-pill">考试 {{ dashboard.formal_paper_count }}</span>
</div>
<div v-if="connectorRows.length" class="connector-list compact-list">
<div v-for="item in connectorRows" :key="item.name" class="connector-row">
<div>
<div class="metric-title">{{ item.name }}</div>
<div class="metric-desc">{{ item.desc }}</div>
</div>
<el-tag size="small" :type="item.type" effect="plain">{{ item.status }}</el-tag>
</div>
</div>
<div v-else class="empty-tip">暂无连接器状态</div>
</article>
</section>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getDashboard } from '@/api/system'
import { departmentStats } from '@/api/department'
import { examStatsByPosition } from '@/api/position'
import { getAiUsageUsers } from '@/api/ai'
import { listConnectors } from '@/connectors/api/connector'
const router = useRouter()
const dashboard = ref({
employee_count: 0,
admin_count: 0,
product_count: 0,
course_count: 0,
question_count: 0,
formal_paper_count: 0,
pending_material_count: 0,
recent_new_users: 0,
})
const departmentRows = ref([])
const positionRows = ref([])
const connectorRows = ref([])
const aiUserRows = ref([])
const totalUsers = computed(() => dashboard.value.employee_count + dashboard.value.admin_count)
const liveConnectorCount = computed(() => connectorRows.value.filter((item) => item.live).length)
const aiUsageSummary = computed(() => {
const summary = aiUserRows.value.reduce(
(acc, item) => {
acc.totalCalls += Number(item.total_calls || 0)
acc.totalCredits += Number(item.total_credits || 0)
return acc
},
{ totalCalls: 0, totalCredits: 0 }
)
return {
...summary,
userCount: aiUserRows.value.length,
}
})
const compactStats = computed(() => [
{ label: '成员', value: totalUsers.value },
{ label: '待审批', value: dashboard.value.pending_material_count },
{ label: '连接器', value: liveConnectorCount.value },
{ label: 'AI 调用', value: aiUsageSummary.value.totalCalls },
])
const governanceDomains = computed(() => [
{
key: 'org',
title: '组织治理',
subtitle: '用户、部门、岗位与身份',
count: `${totalUsers.value} 人`,
pageRoute: '/system/users',
},
{
key: 'knowledge',
title: '知识治理',
subtitle: '空间、资料、审批与切片',
count: `${dashboard.value.pending_material_count} 项`,
pageRoute: '/knowledge-management',
},
{
key: 'object',
title: '对象治理',
subtitle: '专员、技能、APP 与连接器',
count: `${liveConnectorCount.value} 个`,
pageRoute: '/console',
},
{
key: 'run',
title: '任务与运行',
subtitle: '考试、学习、AI 用量与异常',
count: `${aiUsageSummary.value.totalCalls} 次`,
pageRoute: '/system/ai-usage',
},
{
key: 'system',
title: '系统治理',
subtitle: '配置、路由、密钥与存储',
count: `${dashboard.value.question_count} 项`,
pageRoute: '/system/config',
},
])
const focusItems = computed(() => [
{
label: '知识审批',
desc: '素材审批与知识源入库待确认',
value: dashboard.value.pending_material_count,
pageRoute: '/knowledge-management',
},
{
label: '用户设岗',
desc: '为成员补齐岗位与组织身份任务',
value: totalUsers.value,
pageRoute: '/system/users',
},
{
label: 'AI 配置',
desc: '检查默认对话路由与密钥状态',
value: aiUsageSummary.value.userCount,
pageRoute: '/system/config',
},
{
label: '连接器健康',
desc: '检查连接器接入数量与控制台运行状态',
value: liveConnectorCount.value,
pageRoute: '/console',
},
])
const riskDepartments = computed(() =>
[...departmentRows.value]
.sort((a, b) => Number(a.pass_rate || 0) - Number(b.pass_rate || 0))
.slice(0, 3)
)
const riskPositions = computed(() =>
[...positionRows.value]
.sort((a, b) => Number(a.pass_rate || 0) - Number(b.pass_rate || 0))
.slice(0, 3)
)
onMounted(async () => {
try {
const [dashboardRes, deptRes, positionRes, aiUsageRes, connectorRes] = await Promise.all([
getDashboard().catch(() => ({ data: {} })),
departmentStats().catch(() => ({ data: { items: [] } })),
examStatsByPosition().catch(() => ({ data: { items: [] } })),
getAiUsageUsers().catch(() => ({ data: { users: [] } })),
listConnectors().catch(() => ({ data: [] })),
])
dashboard.value = { ...dashboard.value, ...(dashboardRes.data || {}) }
departmentRows.value = deptRes.data?.items || []
positionRows.value = positionRes.data?.items || []
aiUserRows.value = aiUsageRes.data?.users || []
connectorRows.value = (connectorRes.data || []).slice(0, 4).map((item) => ({
name: item.label || item.key,
desc: buildConnectorDesc(item),
status: item.mode === 'live' ? '已接入' : '演示中',
type: item.mode === 'live' ? 'success' : 'warning',
live: item.mode === 'live',
}))
} catch (error) {
console.warn('load admin overview failed', error)
}
})
function buildConnectorDesc(item) {
const objectCount = Array.isArray(item.objects) ? item.objects.length : 0
const directionLabel = item.direction === 'output' ? '输出连接器' : '输入连接器'
return `${directionLabel} · ${objectCount} 个对象`
}
</script>
<style scoped>
.admin-overview-page {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 20px 24px;
}
.hero-card {
padding-bottom: 2px;
}
.hero-card,
.panel,
.meta-pill,
.status-pill {
background: #fff;
}
.hero-eyebrow,
.panel-caption,
.meta-pill-label {
font-size: 11px;
color: #8d98a5;
}
.hero-card h1 {
margin: 4px 0 6px;
font-size: 24px;
color: #172533;
}
.hero-card p {
max-width: 72ch;
margin: 0;
font-size: 13px;
line-height: 1.65;
color: #667689;
}
.hero-actions {
display: flex;
gap: 10px;
margin-top: 12px;
}
.meta-row,
.status-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.meta-pill,
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid #e7ebf0;
border-radius: 999px;
color: #5d6f82;
font-size: 12px;
}
.meta-pill strong {
color: #172533;
font-size: 12px;
}
.main-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.panel {
border: 1px solid #e7ebf0;
border-radius: 14px;
padding: 14px 16px;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.panel-title {
font-size: 15px;
font-weight: 700;
color: #172533;
}
.focus-list,
.entry-list,
.metric-list,
.connector-list {
display: flex;
flex-direction: column;
margin-top: 14px;
}
.focus-row,
.entry-row,
.metric-row,
.connector-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid #edf1f5;
}
.focus-row:last-child,
.entry-row:last-child,
.metric-row:last-child,
.connector-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.focus-row,
.entry-row {
appearance: none;
width: 100%;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
padding-top: 12px;
}
.metric-title {
font-size: 13px;
font-weight: 600;
color: #172533;
}
.metric-desc {
margin-top: 2px;
font-size: 12px;
color: #6c7b8b;
}
.focus-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
color: #315fc2;
}
.focus-right strong {
font-size: 18px;
line-height: 1;
}
.focus-right span,
.entry-count {
font-size: 12px;
}
.entry-count {
flex-shrink: 0;
color: #5d6f82;
}
.mini-group + .mini-group {
margin-top: 14px;
}
.mini-group-title {
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: #7b8896;
}
.compact-list {
margin-top: 0;
}
.empty-tip {
font-size: 12px;
color: #9aa6b2;
}
@media (max-width: 1360px) {
.main-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 1080px) {
.admin-overview-page {
padding: 14px 16px 20px;
}
}
</style>
@@ -0,0 +1,513 @@
<template>
<div class="governance-page ai-admin-page">
<section class="governance-hero">
<div class="governance-hero-main">
<h1>6.4 AI后台管理</h1>
</div>
<div class="governance-hero-side">
<div class="governance-hero-side-label">总调用</div>
<strong>{{ usageSummary.total_calls }}</strong>
<span>当前筛选时间窗</span>
</div>
</section>
<section class="governance-section-panel">
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="6.4.1 AI路由配置" name="routes">
<div v-loading="routeLoading">
<div class="governance-summary-grid">
<article class="governance-summary-card">
<span class="governance-summary-label">对话路由</span>
<strong>{{ aiChatRoutes.length }}</strong>
<small>可用于 Agent 与对话场景</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">Embedding 路由</span>
<strong>{{ aiEmbedRoutes.length }}</strong>
<small>可用于检索与向量任务</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">Provider 数</span>
<strong>{{ providerCount }}</strong>
<small>已纳入密钥状态管理</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">默认对话路由</span>
<strong>{{ aiConfig.default_route || '未设置' }}</strong>
<small>保存后热生效</small>
</article>
</div>
<el-alert
title="AI 路由与密钥通过 ai_config.json / ai_secrets.json 文件管理,保存后热生效,无需重启。下拉映射优先于 JSON 编辑器中的对应字段。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px;"
/>
<div class="route-selector-section">
<div class="section-label">Agent → 路由映射</div>
<el-form label-width="150px" class="cfg-form">
<el-form-item label="PathCoach 对话">
<el-select v-model="aiConfig.agent_routes.path_coach" placeholder="选择对话路由" style="width: 100%">
<el-option
v-for="aiRoute in aiChatRoutes"
:key="aiRoute.ai_route_id"
:label="formatAiRouteLabel(aiRoute)"
:value="aiRoute.ai_route_id"
/>
</el-select>
</el-form-item>
<el-form-item label="快捷动作">
<el-select v-model="aiConfig.agent_routes.title_gen" placeholder="选择对话路由" style="width: 100%">
<el-option
v-for="aiRoute in aiChatRoutes"
:key="aiRoute.ai_route_id"
:label="formatAiRouteLabel(aiRoute)"
:value="aiRoute.ai_route_id"
/>
</el-select>
</el-form-item>
<el-form-item label="Embedding">
<el-select v-model="aiConfig.agent_routes.embed_gen" placeholder="选择 Embedding 路由" style="width: 100%">
<el-option
v-for="aiRoute in aiEmbedRoutes"
:key="aiRoute.ai_route_id"
:label="formatAiRouteLabel(aiRoute)"
:value="aiRoute.ai_route_id"
/>
</el-select>
</el-form-item>
<el-form-item label="默认对话路由">
<el-select v-model="aiConfig.default_route" placeholder="选择默认对话路由" style="width: 100%">
<el-option
v-for="aiRoute in aiChatRoutes"
:key="aiRoute.ai_route_id"
:label="formatAiRouteLabel(aiRoute)"
:value="aiRoute.ai_route_id"
/>
</el-select>
</el-form-item>
<el-form-item label="默认 Embedding">
<el-select v-model="aiConfig.default_embed_route" placeholder="选择默认 Embedding 路由" style="width: 100%">
<el-option
v-for="aiRoute in aiEmbedRoutes"
:key="aiRoute.ai_route_id"
:label="formatAiRouteLabel(aiRoute)"
:value="aiRoute.ai_route_id"
/>
</el-select>
</el-form-item>
</el-form>
</div>
<div class="route-selector-section">
<div class="section-label">Provider 密钥状态</div>
<div class="secret-grid">
<div v-for="(ok, provider) in secrets" :key="provider" class="secret-item">
<el-tag :type="ok ? 'success' : 'info'" size="small" effect="plain">{{ ok ? '已配置' : '未配置' }}</el-tag>
<span class="secret-name">{{ provider }}</span>
</div>
</div>
<div class="cfg-desc">密钥明文不回显;配置见 config/ai_secrets.json(仅示例文件入库,真实密钥不入库)</div>
</div>
<el-collapse class="json-collapse">
<el-collapse-item title="原始 JSON 编辑器(ai_config.json)" name="json">
<el-input v-model="aiConfigText" type="textarea" :rows="18" spellcheck="false" class="json-editor" />
</el-collapse-item>
</el-collapse>
<div class="save-bar">
<el-button :loading="reloading" @click="reloadAi">热重载</el-button>
<el-button type="primary" :loading="savingAi" @click="saveAi">保存 AI 配置</el-button>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="6.4.2 AI用量分析" name="usage">
<el-card shadow="never" class="filter-card">
<div class="filter-row">
<span class="filter-label">时间窗</span>
<el-radio-group v-model="days" @change="onDaysChange">
<el-radio-button :value="30">近 30 天</el-radio-button>
<el-radio-button :value="90">近 90 天</el-radio-button>
<el-radio-button :value="365">近 365 天</el-radio-button>
</el-radio-group>
<span class="filter-label">分组</span>
<el-radio-group v-model="groupBy" @change="loadUsage">
<el-radio-button value="month">按月</el-radio-button>
<el-radio-button value="usage_kind">按类型</el-radio-button>
<el-radio-button value="provider">按 Provider</el-radio-button>
</el-radio-group>
</div>
</el-card>
<div class="governance-summary-grid">
<article class="governance-summary-card">
<span class="governance-summary-label">总调用</span>
<strong>{{ usageSummary.total_calls }}</strong>
<small>所有请求次数</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">成功</span>
<strong class="tone-success">{{ usageSummary.success_calls }}</strong>
<small>成功响应次数</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">失败</span>
<strong class="tone-danger">{{ usageSummary.failed_calls }}</strong>
<small>需要排查的失败调用</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">总消耗点数</span>
<strong class="tone-primary">{{ usageSummary.total_credits }}</strong>
<small>组织级成本观察视角</small>
</article>
</div>
<el-row :gutter="16" style="margin-bottom: 16px;">
<el-col :span="14">
<el-card shadow="never">
<template #header><span>按日调用趋势</span></template>
<LineChart :items="dailyItems" />
</el-card>
</el-col>
<el-col :span="5">
<el-card shadow="never">
<template #header><span>成功 / 失败占比</span></template>
<DonutChart
:segments="successSegments"
:size="130"
:stroke="22"
:center-value="usageSummary.success_calls"
center-label="成功次数"
/>
</el-card>
</el-col>
<el-col :span="5">
<el-card shadow="never">
<template #header><span>类型分布</span></template>
<DonutChart
:segments="usageKindSegments"
:size="130"
:stroke="22"
:center-value="usageSummary.total_calls"
center-label="总调用"
/>
</el-card>
</el-col>
</el-row>
<el-card shadow="never">
<template #header><span>分组明细({{ groupLabel }})</span></template>
<el-table :data="usageBuckets" border stripe v-loading="usageLoading">
<el-table-column :label="groupLabel" min-width="140">
<template #default="{ row }">{{ bucketLabel(row.bucket) }}</template>
</el-table-column>
<el-table-column prop="total_calls" label="总调用" width="120" />
<el-table-column prop="success_calls" label="成功" width="100" />
<el-table-column prop="failed_calls" label="失败" width="100" />
<el-table-column prop="total_credits" label="消耗点数" width="120" />
</el-table>
<el-empty v-if="!usageLoading && usageBuckets.length === 0" description="暂无调用记录" />
</el-card>
<el-card shadow="never" style="margin-top: 16px;">
<template #header><span>按用户用量与剩余点数</span></template>
<el-table :data="userRows" border stripe v-loading="userLoading">
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column prop="full_name" label="姓名" width="140" />
<el-table-column label="角色" width="100">
<template #default="{ row }">
<el-tag size="small" :type="row.role === 'admin' ? 'danger' : 'info'">
{{ row.role === 'admin' ? '管理员' : '员工' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="total_calls" label="总调用" width="120" />
<el-table-column prop="total_credits" label="累计消耗" width="120" />
<el-table-column label="剩余点数" width="140">
<template #default="{ row }">
<el-tag size="small" :type="row.ai_points > 0 ? 'success' : 'danger'" effect="plain">
{{ row.ai_points }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
</section>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getAiConfig, getAiUsage, getAiUsageUsers, getSecretsStatus, putAiConfig, reloadAiConfig } from '@/api/ai'
import { listAiChatRoutes, listAiEmbedRoutes } from '@/api/system'
import DonutChart from '@/components/charts/DonutChart.vue'
import LineChart from '@/components/charts/LineChart.vue'
import { formatAiRouteTitle } from '@/utils/aiRouteLabel'
const USAGE_KIND_LABEL = { ai_chat: '对话', chat_message: '对话', text_gen: '快捷动作', embed: '检索' }
const USAGE_KIND_COLORS = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399']
const activeTab = ref('routes')
const routeLoading = ref(false)
const savingAi = ref(false)
const reloading = ref(false)
const aiChatRoutes = ref([])
const aiEmbedRoutes = ref([])
const secrets = ref({})
const aiConfig = reactive({ agent_routes: {}, default_route: '', default_embed_route: '' })
const aiConfigText = ref('')
const days = ref(30)
const groupBy = ref('month')
const usageLoading = ref(false)
const userLoading = ref(false)
const usageSummary = reactive({ total_calls: 0, success_calls: 0, failed_calls: 0, total_credits: 0 })
const usageBuckets = ref([])
const userRows = ref([])
const dayBuckets = ref([])
const usageKindBuckets = ref([])
const providerCount = computed(() => Object.keys(secrets.value || {}).length)
const groupLabel = computed(() => ({ month: '月份', usage_kind: '类型', provider: 'Provider' }[groupBy.value] || '分组'))
const dailyItems = computed(() => dayBuckets.value.map((item) => ({ label: item.bucket, value: item.total_calls })))
const successSegments = computed(() => [
{ label: '成功', value: usageSummary.success_calls, color: '#67c23a' },
{ label: '失败', value: usageSummary.failed_calls, color: '#f56c6c' },
])
const usageKindSegments = computed(() =>
usageKindBuckets.value.map((item, index) => ({
label: USAGE_KIND_LABEL[item.bucket] || item.bucket,
value: item.total_calls,
color: USAGE_KIND_COLORS[index % USAGE_KIND_COLORS.length],
}))
)
function formatAiRouteLabel(aiRoute) {
return formatAiRouteTitle(aiRoute)
}
function bucketLabel(value) {
if (groupBy.value === 'usage_kind') return USAGE_KIND_LABEL[value] || value
return value
}
async function loadRoutes() {
routeLoading.value = true
try {
const [cfgRes, secretRes, chatRes, embedRes] = await Promise.all([
getAiConfig(),
getSecretsStatus(),
listAiChatRoutes().catch(() => ({ data: { routes: [] } })),
listAiEmbedRoutes().catch(() => ({ data: { routes: [] } })),
])
const cfg = cfgRes.data || {}
aiConfig.agent_routes = { ...(cfg.agent_routes || {}) }
aiConfig.default_route = cfg.default_route || ''
aiConfig.default_embed_route = cfg.default_embed_route || ''
aiConfigText.value = JSON.stringify(cfg, null, 2)
secrets.value = secretRes.data?.providers || {}
aiChatRoutes.value = chatRes.data?.routes || []
aiEmbedRoutes.value = embedRes.data?.routes || []
} catch (e) {
secrets.value = {}
aiChatRoutes.value = []
aiEmbedRoutes.value = []
} finally {
routeLoading.value = false
}
}
async function saveAi() {
savingAi.value = true
try {
let config
try {
config = JSON.parse(aiConfigText.value)
} catch {
ElMessage.error('ai_config.json 不是合法 JSON,请检查编辑器内容')
return
}
config.agent_routes = { ...(config.agent_routes || {}), ...aiConfig.agent_routes }
config.default_route = aiConfig.default_route
config.default_embed_route = aiConfig.default_embed_route
await putAiConfig(config)
aiConfigText.value = JSON.stringify(config, null, 2)
ElMessage.success('AI 配置已保存并热生效')
loadRoutes()
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
savingAi.value = false
}
}
async function reloadAi() {
reloading.value = true
try {
await reloadAiConfig()
ElMessage.success('已热重载')
loadRoutes()
} catch (e) {
ElMessage.error(e?.message || '热重载失败')
} finally {
reloading.value = false
}
}
async function loadUsage() {
usageLoading.value = true
try {
const res = await getAiUsage({ days: days.value, group_by: groupBy.value })
const data = res.data || {}
Object.assign(usageSummary, data.summary || {})
usageBuckets.value = data.buckets || []
} catch (e) {
Object.assign(usageSummary, { total_calls: 0, success_calls: 0, failed_calls: 0, total_credits: 0 })
usageBuckets.value = []
} finally {
usageLoading.value = false
}
}
async function loadUsers() {
userLoading.value = true
try {
const res = await getAiUsageUsers()
userRows.value = res.data?.users || []
} catch (e) {
userRows.value = []
} finally {
userLoading.value = false
}
}
async function loadCharts() {
try {
const [dayRes, usageKindRes] = await Promise.all([
getAiUsage({ days: days.value, group_by: 'day' }),
getAiUsage({ days: days.value, group_by: 'usage_kind' }),
])
dayBuckets.value = dayRes.data?.buckets || []
usageKindBuckets.value = usageKindRes.data?.buckets || []
} catch (e) {
dayBuckets.value = []
usageKindBuckets.value = []
}
}
function onDaysChange() {
loadUsage()
loadCharts()
}
onMounted(() => {
loadRoutes()
loadUsage()
loadUsers()
loadCharts()
})
</script>
<style scoped>
.cfg-form {
max-width: 880px;
}
.cfg-desc {
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-top: 4px;
}
.route-selector-section {
background: #fafafa;
border-radius: 6px;
padding: 16px 20px;
margin-bottom: 12px;
}
.section-label {
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
}
.secret-grid {
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
margin-bottom: 8px;
}
.secret-item {
display: flex;
align-items: center;
gap: 8px;
}
.secret-name {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
color: #303133;
}
.json-collapse {
margin-top: 4px;
}
.json-editor :deep(textarea) {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.6;
}
.save-bar {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.filter-card {
margin-bottom: 16px;
}
.filter-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.filter-label {
font-size: 14px;
color: #606266;
margin-left: 12px;
}
.filter-label:first-child {
margin-left: 0;
}
.tone-success {
color: #67c23a;
}
.tone-danger {
color: #f56c6c;
}
.tone-primary {
color: #409eff;
}
</style>
@@ -3,11 +3,7 @@
<section class="governance-hero">
<div class="governance-hero-main">
<div class="governance-eyebrow">运行治理</div>
<h1>AI 用量</h1>
<p>
统一查看组织级 AI 调用量、成功率、用户消耗和 Provider 分布。
这页服务于资源治理、路由优化和异常排查,而不只是简单报表。
</p>
<h1>AI 用量分析</h1>
</div>
<div class="governance-hero-side">
<div class="governance-hero-side-label">总调用</div>
@@ -1,164 +0,0 @@
<template>
<div class="governance-page company-config-page">
<section class="governance-hero">
<div class="governance-hero-main">
<div class="governance-eyebrow">系统治理</div>
<h1>公司配置</h1>
<p>
这里维护组织级基础信息,用于新员工 onboarding、公司介绍 APP、通知触达与系统默认展示。
它不是孤立配置表,而是平台组织身份的一部分。
</p>
<div class="governance-hero-actions">
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
</div>
</div>
<div class="governance-hero-side">
<div class="governance-hero-side-label">配置项</div>
<strong>{{ companyItems.length }}</strong>
<span>组织级基础参数</span>
</div>
</section>
<section class="governance-summary-grid">
<article class="governance-summary-card">
<span class="governance-summary-label">公司名称</span>
<strong>{{ values.company_name || '未设置' }}</strong>
<small>对外展示的组织名称</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">管理员邮箱</span>
<strong>{{ values.admin_email || '未设置' }}</strong>
<small>用于通知和运营联系</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">公司介绍长度</span>
<strong>{{ introLength }}</strong>
<small>字符数,用于公司介绍 APP 内容基础</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">内容状态</span>
<strong>{{ introLength ? '已配置' : '待完善' }}</strong>
<small>建议补齐 onboarding 主文案</small>
</article>
</section>
<section class="governance-section-panel">
<div class="governance-section-head">
<div>
<div class="governance-section-title">组织基础信息</div>
<div class="governance-section-subtitle">这些信息会影响公司介绍、通知触达和组织身份展示。</div>
</div>
</div>
<el-card shadow="never" v-loading="loading">
<el-alert
title="以下为公司基础信息配置,保存后立即生效。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px;"
/>
<el-form label-width="220px" class="cfg-form">
<el-form-item v-for="item in companyItems" :key="item.config_key">
<template #label>
<span class="cfg-key">{{ item.config_key }}</span>
</template>
<el-input
v-if="item.config_key === 'company_intro'"
v-model="values[item.config_key]"
type="textarea"
:rows="8"
/>
<el-input v-else v-model="values[item.config_key]" />
<div class="cfg-desc">{{ item.description }}</div>
</el-form-item>
</el-form>
<div class="save-bar">
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
</div>
<el-empty v-if="!loading && companyItems.length === 0" description="暂无公司配置项" />
</el-card>
</section>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getConfig, updateConfig } from '@/api/system'
const loading = ref(false)
const saving = ref(false)
const configItems = ref([])
const values = reactive({})
const companyItems = computed(() =>
configItems.value.filter((c) =>
['company_intro', 'company_name', 'admin_email'].includes(c.config_key)
)
)
const introLength = computed(() => String(values.company_intro || '').trim().length)
async function load() {
loading.value = true
try {
const cfgRes = await getConfig()
const list = cfgRes.data?.configs || []
configItems.value = list
list.forEach((c) => {
values[c.config_key] = c.config_value || ''
})
} catch (e) {
configItems.value = []
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
const payload = {}
companyItems.value.forEach((c) => {
payload[c.config_key] = values[c.config_key] ?? ''
})
await updateConfig(payload)
ElMessage.success('配置已保存')
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.cfg-form {
max-width: 880px;
}
.cfg-key {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.cfg-desc {
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-top: 4px;
}
.save-bar {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,789 @@
<template>
<div class="governance-page organization-management-page">
<section class="governance-hero">
<div class="governance-hero-main">
<h1>6.2 组织与公司配置</h1>
</div>
</section>
<section class="governance-section-panel">
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="6.2.1 部门管理" name="dept-list">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>部门字典(共 {{ departments.length }} 个)</span>
<el-button type="primary" size="small" @click="openDepartmentCreate">新增部门</el-button>
</div>
</template>
<el-table :data="departments" border stripe v-loading="departmentLoading">
<el-table-column prop="name" label="部门名称" min-width="140" />
<el-table-column prop="description" label="说明" min-width="180">
<template #default="{ row }">{{ row.description || '—' }}</template>
</el-table-column>
<el-table-column prop="member_count" label="成员数" width="90" align="center" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 'active' ? 'success' : 'info'">
{{ row.status === 'active' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openDepartmentEdit(row)">编辑</el-button>
<el-button
:type="row.status === 'active' ? 'warning' : 'success'"
link
size="small"
@click="toggleDepartmentStatus(row)"
>
{{ row.status === 'active' ? '停用' : '启用' }}
</el-button>
<el-button type="danger" link size="small" @click="removeDepartment(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="6.2.2 部门学情" name="dept-stats">
<el-card shadow="never">
<template #header><span>按部门学情统计(员工 {{ statsTotalEmployees }} 人)</span></template>
<el-table :data="departmentStatsRows" border stripe v-loading="departmentStatsLoading">
<el-table-column prop="department" label="部门" min-width="120" />
<el-table-column prop="employee_count" label="人数" width="80" align="center" />
<el-table-column prop="points_total" label="学习积分" width="100" align="right" />
<el-table-column label="公司介绍已读率" width="140" align="center">
<template #default="{ row }">{{ row.company_rate }}%</template>
</el-table-column>
<el-table-column label="人均产品/课程" width="130" align="center">
<template #default="{ row }">{{ row.product_avg }} / {{ row.course_avg }}</template>
</el-table-column>
<el-table-column label="正式考试" width="110" align="center">
<template #default="{ row }">{{ row.formal_passed }}/{{ row.formal_count }} 通过</template>
</el-table-column>
<el-table-column label="通过率" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.pass_rate >= 80 ? 'success' : row.pass_rate >= 60 ? 'warning' : 'danger'">
{{ row.pass_rate }}%
</el-tag>
</template>
</el-table-column>
<el-table-column prop="avg_score" label="平均分" width="90" align="right" />
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="6.2.3 岗位管理" name="position">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>岗位列表(共 {{ positions.length }} 个)</span>
<el-button type="primary" size="small" @click="openPositionCreate">新增岗位</el-button>
</div>
</template>
<el-table :data="pagedPositions" border stripe v-loading="positionLoading">
<el-table-column prop="id" label="编号" width="70" />
<el-table-column prop="code" label="编码" width="120" />
<el-table-column prop="name" label="名称" min-width="160" />
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column label="知识映射" width="100">
<template #default="{ row }">
<el-tag size="small" :type="positionKnowledgeCount[row.id] ? 'success' : 'info'" effect="plain">
{{ positionKnowledgeCount[row.id] || 0 }} 条
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 'active' ? 'success' : 'info'">
{{ labelOf(POSITION_STATUS, row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openPositionEdit(row)">编辑</el-button>
<el-button type="primary" link size="small" @click="openKnowledge(row)">知识映射</el-button>
<el-button type="primary" link size="small" @click="openBlueprint(row)">考试蓝图</el-button>
<el-button type="danger" link size="small" @click="removePosition(row)">停用</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-pagination">
<el-pagination
v-model:current-page="positionPage"
v-model:page-size="positionPageSize"
:page-sizes="[10, 20, 40]"
:total="positions.length"
background
layout="sizes, prev, pager, next"
/>
</div>
</el-card>
</el-tab-pane>
<el-tab-pane label="6.2.4 公司配置" name="company">
<div class="governance-summary-grid">
<article class="governance-summary-card">
<span class="governance-summary-label">公司名称</span>
<strong>{{ companyValues.company_name || '未设置' }}</strong>
<small>对外展示的组织名称</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">管理员邮箱</span>
<strong>{{ companyValues.admin_email || '未设置' }}</strong>
<small>用于通知和运营联系</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">公司介绍长度</span>
<strong>{{ introLength }}</strong>
<small>字符数,用于公司介绍 xApp 内容基础</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">内容状态</span>
<strong>{{ introLength ? '已配置' : '待完善' }}</strong>
<small>建议补齐 onboarding 主文案</small>
</article>
</div>
<el-card shadow="never" v-loading="companyLoading">
<el-alert
title="以下为公司基础信息配置,保存后立即生效。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px;"
/>
<el-form label-width="220px" class="cfg-form">
<el-form-item v-for="item in companyItems" :key="item.config_key">
<template #label>
<span class="cfg-key">{{ item.config_key }}</span>
</template>
<el-input
v-if="item.config_key === 'company_intro'"
v-model="companyValues[item.config_key]"
type="textarea"
:rows="8"
/>
<el-input v-else v-model="companyValues[item.config_key]" />
<div class="cfg-desc">{{ item.description }}</div>
</el-form-item>
</el-form>
<div class="save-bar">
<el-button type="primary" :loading="companySaving" @click="saveCompanyConfig">保存配置</el-button>
</div>
<el-empty v-if="!companyLoading && companyItems.length === 0" description="暂无公司配置项" />
</el-card>
</el-tab-pane>
</el-tabs>
</section>
<el-dialog v-model="departmentDialogVisible" :title="departmentForm.id ? '编辑部门' : '新增部门'" width="480px">
<el-form :model="departmentForm" label-width="90px">
<el-form-item label="部门名称">
<el-input v-model="departmentForm.name" placeholder="如:销售一部" />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="departmentForm.description" type="textarea" :rows="2" placeholder="部门职责说明(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="departmentDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="departmentSaving" @click="saveDepartment">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="positionDialogVisible" :title="positionForm.id ? '编辑岗位' : '新增岗位'" width="520px">
<el-form :model="positionForm" label-width="70px">
<el-form-item label="编码">
<el-input v-model="positionForm.code" placeholder="如:SALES_01" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="positionForm.name" placeholder="如:销售顾问" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="positionForm.description" type="textarea" :rows="3" placeholder="岗位职责说明(可选)" />
</el-form-item>
<el-form-item v-if="positionForm.id" label="状态">
<el-radio-group v-model="positionForm.status">
<el-radio value="active">启用</el-radio>
<el-radio value="inactive">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="positionDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="positionSaving" @click="savePosition">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="knowledgeDialogVisible" :title="`知识映射 — ${currentPosition.name || ''}`" width="880px" top="5vh">
<el-alert type="info" :closable="false" show-icon class="k-alert">
圈定该岗位应掌握的知识范围:至少配置一条「知识域」;可细化到具体课程/产品;级别 L1-L4 用于分级标注。
</el-alert>
<el-table :data="knowledgeItems" border size="small">
<el-table-column label="知识域" width="150">
<template #default="{ row }">
<el-select v-model="row.domain" placeholder="选择知识域">
<el-option v-for="(value, key) in QUESTION_DOMAINS" :key="key" :label="value" :value="key" />
</el-select>
</template>
</el-table-column>
<el-table-column label="课程(可选)" min-width="200">
<template #default="{ row }">
<el-select v-model="row.course_id" placeholder="不限课程" clearable filterable>
<el-option v-for="course in courses" :key="course.id" :label="course.name" :value="course.id" />
</el-select>
</template>
</el-table-column>
<el-table-column label="产品(可选)" min-width="200">
<template #default="{ row }">
<el-select v-model="row.product_id" placeholder="不限产品" clearable filterable>
<el-option v-for="product in products" :key="product.id" :label="product.name" :value="product.id" />
</el-select>
</template>
</el-table-column>
<el-table-column label="级别" width="160">
<template #default="{ row }">
<el-select v-model="row.required_level" placeholder="级别">
<el-option v-for="(value, key) in POSITION_LEVELS" :key="key" :label="value" :value="key" />
</el-select>
</template>
</el-table-column>
<el-table-column label="权重" width="110">
<template #default="{ row }">
<el-input-number v-model="row.weight" :min="0.5" :max="10" :step="0.5" size="small" />
</template>
</el-table-column>
<el-table-column label="必学" width="70">
<template #default="{ row }">
<el-switch v-model="row.is_mandatory" />
</template>
</el-table-column>
<el-table-column label="操作" width="70">
<template #default="{ $index }">
<el-button type="danger" link size="small" @click="knowledgeItems.splice($index, 1)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="dialog-footer-row">
<el-button @click="addKnowledgeItem">新增一行</el-button>
</div>
<template #footer>
<el-button @click="knowledgeDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="knowledgeSaving" @click="saveKnowledge">保存映射</el-button>
</template>
</el-dialog>
<el-dialog v-model="blueprintDialogVisible" :title="`考试蓝图 — ${currentPosition.name || ''}`" width="720px" top="8vh">
<el-alert type="info" :closable="false" show-icon class="k-alert">
蓝图规定岗位考试的题型构成。配置蓝图后,该岗位的考试优先按蓝图组卷。
</el-alert>
<el-table :data="blueprintItems" border size="small">
<el-table-column label="知识域" width="170">
<template #default="{ row }">
<el-select v-model="row.domain" placeholder="不限" clearable>
<el-option v-for="(value, key) in QUESTION_DOMAINS" :key="key" :label="value" :value="key" />
</el-select>
</template>
</el-table-column>
<el-table-column label="题型" width="150">
<template #default="{ row }">
<el-select v-model="row.type" placeholder="题型">
<el-option v-for="(value, key) in QUESTION_TYPES" :key="key" :label="value" :value="key" />
</el-select>
</template>
</el-table-column>
<el-table-column label="题量" width="120">
<template #default="{ row }">
<el-input-number v-model="row.count" :min="1" :max="100" size="small" />
</template>
</el-table-column>
<el-table-column label="操作" width="70">
<template #default="{ $index }">
<el-button type="danger" link size="small" @click="blueprintItems.splice($index, 1)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="dialog-footer-row">
<el-button @click="addBlueprintItem">新增一行</el-button>
<span class="b-summary">蓝图总题量:{{ blueprintTotal }}</span>
</div>
<template #footer>
<el-button @click="blueprintDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="blueprintSaving" @click="saveBlueprint">保存蓝图</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { createDepartment, deleteDepartment, departmentStats, listDepartments, updateDepartment } from '@/api/department'
import { listCourses } from '@/api/courses'
import { listProducts } from '@/api/products'
import { getConfig, updateConfig } from '@/api/system'
import {
createPosition,
deletePosition,
listPositionBlueprint,
listPositionKnowledge,
listPositions,
savePositionBlueprint,
savePositionKnowledge,
updatePosition,
} from '@/api/position'
import { POSITION_LEVELS, POSITION_STATUS, QUESTION_DOMAINS, QUESTION_TYPES, labelOf } from '@/utils/labels'
const activeTab = ref('dept-list')
const departmentLoading = ref(false)
const departments = ref([])
const departmentStatsLoading = ref(false)
const departmentStatsRows = ref([])
const departmentDialogVisible = ref(false)
const departmentSaving = ref(false)
const departmentForm = reactive({ id: null, name: '', description: '' })
const statsTotalEmployees = computed(() => departmentStatsRows.value.reduce((sum, row) => sum + (row.employee_count || 0), 0))
const positionLoading = ref(false)
const positions = ref([])
const positionKnowledgeCount = reactive({})
const positionDialogVisible = ref(false)
const positionSaving = ref(false)
const positionForm = reactive({ id: null, code: '', name: '', description: '', status: 'active' })
const positionPage = ref(1)
const positionPageSize = ref(10)
const pagedPositions = computed(() =>
positions.value.slice((positionPage.value - 1) * positionPageSize.value, positionPage.value * positionPageSize.value)
)
const companyLoading = ref(false)
const companySaving = ref(false)
const companyConfigItems = ref([])
const companyValues = reactive({})
const companyItems = computed(() =>
companyConfigItems.value.filter((c) =>
['company_intro', 'company_name', 'admin_email'].includes(c.config_key)
)
)
const introLength = computed(() => String(companyValues.company_intro || '').trim().length)
const knowledgeDialogVisible = ref(false)
const knowledgeSaving = ref(false)
const blueprintDialogVisible = ref(false)
const blueprintSaving = ref(false)
const currentPosition = ref({})
const knowledgeItems = ref([])
const blueprintItems = ref([])
const courses = ref([])
const products = ref([])
const blueprintTotal = computed(() => blueprintItems.value.reduce((sum, item) => sum + (item.count || 0), 0))
function resetDepartmentForm() {
departmentForm.id = null
departmentForm.name = ''
departmentForm.description = ''
}
function openDepartmentCreate() {
activeTab.value = 'dept-list'
resetDepartmentForm()
departmentDialogVisible.value = true
}
function openDepartmentEdit(row) {
activeTab.value = 'dept-list'
departmentForm.id = row.id
departmentForm.name = row.name
departmentForm.description = row.description || ''
departmentDialogVisible.value = true
}
async function loadDepartments() {
departmentLoading.value = true
try {
const res = await listDepartments({ status: 'all' })
departments.value = res.data || []
} catch (e) {
departments.value = []
} finally {
departmentLoading.value = false
}
}
async function loadDepartmentStats() {
departmentStatsLoading.value = true
try {
const res = await departmentStats()
departmentStatsRows.value = res.data?.items || []
} catch (e) {
departmentStatsRows.value = []
} finally {
departmentStatsLoading.value = false
}
}
async function saveDepartment() {
if (!departmentForm.name.trim()) {
ElMessage.warning('部门名称不能为空')
return
}
departmentSaving.value = true
try {
const payload = { name: departmentForm.name.trim(), description: departmentForm.description }
if (departmentForm.id) {
await updateDepartment(departmentForm.id, payload)
} else {
await createDepartment(payload)
}
ElMessage.success('部门已保存')
departmentDialogVisible.value = false
await Promise.all([loadDepartments(), loadDepartmentStats()])
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
departmentSaving.value = false
}
}
async function toggleDepartmentStatus(row) {
const nextStatus = row.status === 'active' ? 'inactive' : 'active'
try {
await updateDepartment(row.id, { name: row.name, description: row.description, status: nextStatus })
ElMessage.success('已更新状态')
loadDepartments()
} catch (e) {
ElMessage.error(e?.message || '操作失败')
}
}
async function removeDepartment(row) {
await ElMessageBox.confirm(`确定删除部门「${row.name}」吗?有员工归属的部门无法删除。`, '提示', { type: 'warning' })
try {
await deleteDepartment(row.id)
ElMessage.success('已删除')
await Promise.all([loadDepartments(), loadDepartmentStats()])
} catch (e) {
if (e !== 'cancel') ElMessage.error(e?.message || '删除失败')
}
}
function resetPositionForm() {
positionForm.id = null
positionForm.code = ''
positionForm.name = ''
positionForm.description = ''
positionForm.status = 'active'
}
function openPositionCreate() {
activeTab.value = 'position'
resetPositionForm()
positionDialogVisible.value = true
}
function openPositionEdit(row) {
activeTab.value = 'position'
resetPositionForm()
positionForm.id = row.id
positionForm.code = row.code
positionForm.name = row.name
positionForm.description = row.description || ''
positionForm.status = row.status
positionDialogVisible.value = true
}
async function loadPositions() {
positionLoading.value = true
try {
const res = await listPositions({ status: 'all' })
positions.value = res.data || []
await Promise.all(positions.value.map((item) => loadKnowledgeCount(item.id)))
} catch (e) {
positions.value = []
} finally {
positionLoading.value = false
}
}
async function loadKnowledgeCount(positionId) {
try {
const res = await listPositionKnowledge(positionId)
positionKnowledgeCount[positionId] = (res.data || []).length
} catch (e) {
positionKnowledgeCount[positionId] = 0
}
}
async function savePosition() {
if (!positionForm.name.trim()) {
ElMessage.warning('岗位名称不能为空')
return
}
if (!positionForm.id && !positionForm.code.trim()) {
ElMessage.warning('岗位编码不能为空')
return
}
positionSaving.value = true
try {
const payload = {
code: positionForm.code.trim(),
name: positionForm.name.trim(),
description: positionForm.description,
}
if (positionForm.id) {
payload.status = positionForm.status
await updatePosition(positionForm.id, payload)
} else {
await createPosition(payload)
}
ElMessage.success('岗位已保存')
positionDialogVisible.value = false
loadPositions()
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
positionSaving.value = false
}
}
async function removePosition(row) {
await ElMessageBox.confirm(`确定停用岗位「${row.name}」吗?`, '提示', { type: 'warning' })
try {
await deletePosition(row.id)
ElMessage.success('岗位已停用')
loadPositions()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e?.message || '操作失败')
}
}
async function loadRefs() {
try {
const [courseRes, productRes] = await Promise.all([listCourses(), listProducts()])
courses.value = courseRes.data || []
products.value = productRes.data || []
} catch (e) {
courses.value = []
products.value = []
}
}
async function openKnowledge(row) {
activeTab.value = 'position'
currentPosition.value = row
knowledgeItems.value = []
knowledgeDialogVisible.value = true
try {
const res = await listPositionKnowledge(row.id)
knowledgeItems.value = (res.data || []).map((item) => ({
domain: item.domain,
course_id: item.course_id ?? null,
product_id: item.product_id ?? null,
required_level: item.required_level,
weight: item.weight,
is_mandatory: item.is_mandatory,
}))
} catch (e) {
knowledgeItems.value = []
}
}
function addKnowledgeItem() {
knowledgeItems.value.push({
domain: 'product',
course_id: null,
product_id: null,
required_level: 'L1',
weight: 1,
is_mandatory: true,
})
}
async function saveKnowledge() {
const items = knowledgeItems.value.map((item) => ({
domain: item.domain,
course_id: item.course_id ?? null,
product_id: item.product_id ?? null,
required_level: item.required_level,
weight: item.weight,
is_mandatory: item.is_mandatory,
}))
if (!items.length) {
ElMessage.warning('请至少配置一条知识映射')
return
}
if (items.some((item) => !item.domain)) {
ElMessage.warning('存在未选择知识域的条目')
return
}
knowledgeSaving.value = true
try {
await savePositionKnowledge(currentPosition.value.id, items)
ElMessage.success('知识映射已保存')
knowledgeDialogVisible.value = false
loadKnowledgeCount(currentPosition.value.id)
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
knowledgeSaving.value = false
}
}
async function openBlueprint(row) {
activeTab.value = 'position'
currentPosition.value = row
blueprintItems.value = []
blueprintDialogVisible.value = true
try {
const res = await listPositionBlueprint(row.id)
blueprintItems.value = (res.data || []).map((item) => ({
domain: item.domain || '',
type: item.type,
count: item.count,
}))
} catch (e) {
blueprintItems.value = []
}
}
function addBlueprintItem() {
blueprintItems.value.push({ domain: '', type: 'single', count: 5 })
}
async function loadCompanyConfig() {
companyLoading.value = true
try {
const cfgRes = await getConfig()
const list = cfgRes.data?.configs || []
companyConfigItems.value = list
list.forEach((item) => {
companyValues[item.config_key] = item.config_value || ''
})
} catch (e) {
companyConfigItems.value = []
} finally {
companyLoading.value = false
}
}
async function saveCompanyConfig() {
companySaving.value = true
try {
const payload = {}
companyItems.value.forEach((item) => {
payload[item.config_key] = companyValues[item.config_key] ?? ''
})
await updateConfig(payload)
ElMessage.success('公司配置已保存')
loadCompanyConfig()
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
companySaving.value = false
}
}
async function saveBlueprint() {
const items = blueprintItems.value.map((item) => ({
domain: item.domain || '',
type: item.type,
count: item.count,
}))
if (!items.length) {
ElMessage.warning('请至少配置一条蓝图')
return
}
if (items.some((item) => !item.type || !item.count)) {
ElMessage.warning('存在未选择题型或未填题量的条目')
return
}
blueprintSaving.value = true
try {
await savePositionBlueprint(currentPosition.value.id, items)
ElMessage.success('考试蓝图已保存')
blueprintDialogVisible.value = false
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
blueprintSaving.value = false
}
}
onMounted(() => {
loadDepartments()
loadDepartmentStats()
loadPositions()
loadRefs()
loadCompanyConfig()
})
</script>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.table-pagination {
margin-top: 16px;
display: flex;
justify-content: flex-start;
}
.cfg-form {
max-width: 880px;
}
.cfg-key {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.cfg-desc {
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-top: 4px;
}
.k-alert {
margin-bottom: 12px;
}
.dialog-footer-row {
margin-top: 12px;
display: flex;
align-items: center;
gap: 16px;
}
.b-summary {
font-size: 13px;
color: #909399;
}
.save-bar {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -2,31 +2,22 @@
<div class="governance-page system-config-page">
<section class="governance-hero">
<div class="governance-hero-main">
<div class="governance-eyebrow">系统治理</div>
<h1>系统配置</h1>
<p>维护文件存储、身份会话、AI 路由与 Provider 密钥状态。</p>
<h1>6.3 系统配置</h1>
</div>
</section>
<section class="governance-section-panel">
<div class="governance-section-head">
<div>
<div class="governance-section-title">系统治理</div>
<div class="governance-section-subtitle">文件、会话与 AI 配置。</div>
</div>
</div>
<section class="governance-section-panel">
<el-card shadow="never" class="config-panel" v-loading="loading">
<el-alert
title="以下参数会直接影响系统运行行为。普通系统参数保存后立即生效;AI 配置支持热生效,无需重启。"
title="以下参数会直接影响系统运行行为,保存后立即生效。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px;"
/>
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="文件与存储" name="file">
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="6.3.1 文件与存储" name="file">
<el-form label-width="220px" class="cfg-form">
<el-form-item v-for="item in fileItems" :key="item.config_key">
<template #label>
@@ -38,7 +29,7 @@
</el-form>
</el-tab-pane>
<el-tab-pane label="身份与会话" name="other">
<el-tab-pane label="6.3.2 身份与会话" name="other">
<el-form label-width="220px" class="cfg-form">
<el-form-item v-for="item in otherItems" :key="item.config_key">
<template #label>
@@ -49,77 +40,9 @@
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="AI 路由与密钥" name="ai">
<div v-loading="aiLoading">
<el-alert
title="AI 路由与密钥通过 ai_config.json / ai_secrets.json 文件管理,保存后热生效、无需重启。下拉映射优先于 JSON 编辑器中的对应字段。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px;"
/>
<!-- Agent → 路由映射 -->
<div class="route-selector-section">
<div class="section-label">Agent → 路由映射</div>
<el-form label-width="150px" class="cfg-form">
<el-form-item label="PathCoach 对话">
<el-select v-model="aiConfig.agent_routes.path_coach" placeholder="选择对话路由" style="width:100%">
<el-option v-for="aiRoute in aiChatRoutes" :key="aiRoute.ai_route_id" :label="formatAiRouteLabel(aiRoute)" :value="aiRoute.ai_route_id" />
</el-select>
</el-form-item>
<el-form-item label="快捷动作">
<el-select v-model="aiConfig.agent_routes.title_gen" placeholder="选择对话路由" style="width:100%">
<el-option v-for="aiRoute in aiChatRoutes" :key="aiRoute.ai_route_id" :label="formatAiRouteLabel(aiRoute)" :value="aiRoute.ai_route_id" />
</el-select>
</el-form-item>
<el-form-item label="Embedding">
<el-select v-model="aiConfig.agent_routes.embed_gen" placeholder="选择 Embedding 路由" style="width:100%">
<el-option v-for="aiRoute in aiEmbedRoutes" :key="aiRoute.ai_route_id" :label="formatAiRouteLabel(aiRoute)" :value="aiRoute.ai_route_id" />
</el-select>
</el-form-item>
<el-form-item label="默认对话路由">
<el-select v-model="aiConfig.default_route" placeholder="选择默认对话路由" style="width:100%">
<el-option v-for="aiRoute in aiChatRoutes" :key="aiRoute.ai_route_id" :label="formatAiRouteLabel(aiRoute)" :value="aiRoute.ai_route_id" />
</el-select>
</el-form-item>
<el-form-item label="默认 Embedding">
<el-select v-model="aiConfig.default_embed_route" placeholder="选择默认 Embedding 路由" style="width:100%">
<el-option v-for="aiRoute in aiEmbedRoutes" :key="aiRoute.ai_route_id" :label="formatAiRouteLabel(aiRoute)" :value="aiRoute.ai_route_id" />
</el-select>
</el-form-item>
</el-form>
</div>
<!-- 密钥状态 -->
<div class="route-selector-section">
<div class="section-label">Provider 密钥状态</div>
<div class="secret-grid">
<div v-for="(ok, provider) in secrets" :key="provider" class="secret-item">
<el-tag :type="ok ? 'success' : 'info'" size="small" effect="plain">{{ ok ? '已配置' : '未配置' }}</el-tag>
<span class="secret-name">{{ provider }}</span>
</div>
</div>
<div class="cfg-desc">密钥明文不回显;配置见 config/ai_secrets.json(仅示例文件入库,真实密钥不入库)</div>
</div>
<!-- JSON 编辑器 -->
<el-collapse class="json-collapse">
<el-collapse-item title="原始 JSON 编辑器(ai_config.json)" name="json">
<el-input v-model="aiConfigText" type="textarea" :rows="18" spellcheck="false" class="json-editor" />
</el-collapse-item>
</el-collapse>
<div class="save-bar">
<el-button :loading="reloading" @click="reloadAi">热重载</el-button>
<el-button type="primary" :loading="savingAi" @click="saveAi">保存 AI 配置</el-button>
</div>
</div>
</el-tab-pane>
</el-tabs>
<div class="save-bar" v-if="activeTab !== 'ai'">
<div class="save-bar">
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
</div>
@@ -132,9 +55,7 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getConfig, updateConfig, listAiChatRoutes, listAiEmbedRoutes } from '@/api/system'
import { getAiConfig, putAiConfig, reloadAiConfig, getSecretsStatus } from '@/api/ai'
import { formatAiRouteShortLabel } from '@/utils/aiRouteLabel'
import { getConfig, updateConfig } from '@/api/system'
const loading = ref(false)
const saving = ref(false)
@@ -142,16 +63,6 @@ const configItems = ref([])
const values = reactive({})
const activeTab = ref('file')
// AI 配置
const aiLoading = ref(false)
const savingAi = ref(false)
const reloading = ref(false)
const aiChatRoutes = ref([])
const aiEmbedRoutes = ref([])
const secrets = ref({})
const aiConfig = reactive({ agent_routes: {}, default_route: '', default_embed_route: '' })
const aiConfigText = ref('')
// ── Tab 分组 ──
const fileItems = computed(() =>
@@ -166,10 +77,6 @@ const otherItems = computed(() =>
)
)
function formatAiRouteLabel(aiRoute) {
return formatAiRouteShortLabel(aiRoute)
}
// ── 数据加载 ──
async function load() {
@@ -188,37 +95,13 @@ async function load() {
}
}
async function loadAi() {
aiLoading.value = true
try {
const [cfgRes, secretRes, chatRes, embedRes] = await Promise.all([
getAiConfig(),
getSecretsStatus(),
listAiChatRoutes().catch(() => ({ data: { routes: [] } })),
listAiEmbedRoutes().catch(() => ({ data: { routes: [] } })),
])
const cfg = cfgRes.data || {}
aiConfig.agent_routes = { ...(cfg.agent_routes || {}) }
aiConfig.default_route = cfg.default_route || ''
aiConfig.default_embed_route = cfg.default_embed_route || ''
aiConfigText.value = JSON.stringify(cfg, null, 2)
secrets.value = secretRes.data?.providers || {}
aiChatRoutes.value = chatRes.data?.routes || []
aiEmbedRoutes.value = embedRes.data?.routes || []
} catch (e) {
// ignore
} finally {
aiLoading.value = false
}
}
// ── 保存 ──
async function save() {
saving.value = true
try {
const payload = {}
configItems.value.forEach((c) => {
;[...fileItems.value, ...otherItems.value].forEach((c) => {
payload[c.config_key] = values[c.config_key] ?? ''
})
await updateConfig(payload)
@@ -230,45 +113,8 @@ async function save() {
}
}
async function saveAi() {
savingAi.value = true
try {
let cfg
try {
cfg = JSON.parse(aiConfigText.value)
} catch {
ElMessage.error('ai_config.json 不是合法 JSON,请检查编辑器内容')
return
}
cfg.agent_routes = { ...(cfg.agent_routes || {}), ...aiConfig.agent_routes }
cfg.default_route = aiConfig.default_route
cfg.default_embed_route = aiConfig.default_embed_route
await putAiConfig(cfg)
aiConfigText.value = JSON.stringify(cfg, null, 2)
ElMessage.success('AI 配置已保存并热生效')
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
savingAi.value = false
}
}
async function reloadAi() {
reloading.value = true
try {
await reloadAiConfig()
ElMessage.success('已热重载')
await loadAi()
} catch (e) {
ElMessage.error(e?.message || '热重载失败')
} finally {
reloading.value = false
}
}
onMounted(() => {
load()
loadAi()
})
</script>
@@ -288,42 +134,6 @@ onMounted(() => {
line-height: 1.5;
margin-top: 4px;
}
.route-selector-section {
background: #fafafa;
border-radius: 6px;
padding: 16px 20px;
margin-bottom: 12px;
}
.section-label {
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
}
.secret-grid {
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
margin-bottom: 8px;
}
.secret-item {
display: flex;
align-items: center;
gap: 8px;
}
.secret-name {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
color: #303133;
}
.json-collapse {
margin-top: 4px;
}
.json-editor :deep(textarea) {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.6;
}
.save-bar {
margin-top: 20px;
display: flex;
@@ -2,26 +2,13 @@
<div class="governance-page user-management-page">
<section class="governance-hero">
<div class="governance-hero-main">
<div class="governance-eyebrow">组织治理</div>
<h1>用户管理</h1>
<p>维护组织成员、岗位归属和学习推进。</p>
<div class="governance-hero-actions">
<el-button type="primary" @click="openCreate">新增用户</el-button>
<el-button @click="activeTab = 'progress'">查看学习进度</el-button>
</div>
<h1>6.1 用户管理</h1>
</div>
</section>
<section class="governance-section-panel">
<div class="governance-section-head">
<div>
<div class="governance-section-title">成员治理</div>
<div class="governance-section-subtitle">账号、设岗与学习推进。</div>
</div>
</div>
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="用户列表" name="users">
<section class="governance-section-panel">
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="6.1.1 用户列表" name="users">
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filters" size="default">
<el-form-item label="用户名">
@@ -130,7 +117,7 @@
</el-card>
</el-tab-pane>
<el-tab-pane label="学习进度" name="progress">
<el-tab-pane label="6.1.2 学习进度" name="progress">
<el-row :gutter="16" style="margin-bottom: 16px;">
<el-col :span="8">
<el-card shadow="never">
@@ -221,7 +221,7 @@ function parseContext(value) {
function lookupSpecialistLabel(key) {
const normalized = String(key || '').trim()
if (!normalized) return '通用助手'
if (!normalized) return '通用专员'
const skill = skillCatalog.getByKey(normalized)
if (skill) return skill.label
const specialist = specialistCatalog.getByKey(normalized)
@@ -133,7 +133,7 @@
<el-button type="warning" link size="small" @click="toggleStatus(row)">
{{ row.state === 'active' ? '停用' : '启用' }}
</el-button>
<el-button type="danger" link size="small" @click="remove(row)">删除</el-button>
<el-button type="danger" link size="small" :disabled="row.key === GENERAL_ASSISTANT_KEY" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -146,7 +146,7 @@
<el-input v-model="form.label" placeholder="例如:知识运营专员" />
</el-form-item>
<el-form-item label="专员 Key">
<el-input v-model="form.key" placeholder="例如:knowledge-operations" />
<el-input v-model="form.key" :disabled="isSystemGeneralSpecialist" placeholder="例如:knowledge-operations" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="form.tier" style="width: 100%">
@@ -480,12 +480,14 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import {
createSpecialist,
deleteSpecialist,
getSpecialistRemovePlan,
getSpecialistSummary,
listSpecialists,
updateSpecialist,
} from '@/specialists/api/specialist'
import { staticSpecialistCatalog } from '@/specialists/core/builtin'
import { useAuthStore } from '@/store/auth'
import { useTaskRuntimeStore } from '@/store/taskRuntime'
import { GENERAL_ASSISTANT_KEY, useTaskRuntimeStore } from '@/store/taskRuntime'
import { openSpecialistInWorkbench } from '@/utils/workbenchNavigation'
import { WORKBENCH_ROUTE } from '@/config/workbench'
@@ -545,9 +547,15 @@ const form = reactive({
sort_order: 0,
})
const isSystemGeneralSpecialist = computed(() => form.key === GENERAL_ASSISTANT_KEY)
const builtinSpecialistLabelMap = new Map(
staticSpecialistCatalog.map((item) => [item.key, item.label])
)
const tableItems = computed(() => remoteItems.value.map(item => ({
...item,
object_entry_route: item.object_entry_route || WORKBENCH_ROUTE,
label: displaySpecialistLabel(item),
object_entry_route: item.object_entry_route || WORKBENCH_ROUTE,
tierLabel: item.tier === 'generic' ? '通用' : '行业',
specialistModeLabel: item.specialist_mode === 'adw' ? 'ADW' : 'DW',
})))
@@ -559,7 +567,7 @@ function normalizeSpecialistCard(item) {
return {
id: item.id,
key: item.key,
title: item.label,
title: displaySpecialistLabel(item),
tag: item.market_tag,
desc: item.summary,
meta: `${item.version || 'v1.0'} · ${item.connector_scope || `${item.stage} · ${item.work_status}`}`,
@@ -573,6 +581,22 @@ function normalizeSpecialistCard(item) {
}
}
function displaySpecialistLabel(item) {
return builtinSpecialistLabelMap.get(item?.key) || item?.label
}
function summarizeSpecialistRemovePlan(planPayload) {
const payload = planPayload?.data || planPayload || {}
const plan = payload?.plan || {}
const refs = plan?.RuntimeRefs || {}
return [
`删除对象:${payload?.label || plan?.Label || payload?.key || '-'}`,
`对象类型:${payload?.is_builtin ? '内置专员' : '运行时专员'}`,
`引用情况:xApp ${refs?.XAppKeys?.length || 0},项目 ${refs?.ProjectIDs?.length || 0},任务 ${refs?.TaskCount || 0},运行 ${refs?.TaskRunCount || 0},工件 ${refs?.ArtifactCount || 0},调用日志 ${refs?.AICallCount || 0}`,
`退场策略:${plan?.Policy || '未设置'}`,
].join('\n')
}
function resetForm() {
form.id = null
form.key = ''
@@ -627,7 +651,7 @@ function openSpecialistWorkspace(row) {
function openEdit(row) {
form.id = row.id
form.key = row.key
form.label = row.label
form.label = displaySpecialistLabel(row)
form.tier = row.tier
form.specialist_mode = row.specialist_mode || 'dw'
form.object_entry_route = row.object_entry_route || WORKBENCH_ROUTE
@@ -748,8 +772,18 @@ async function toggleStatus(row) {
}
async function remove(row) {
if (row.key === GENERAL_ASSISTANT_KEY) {
ElMessage.warning('通用专员是系统保留专员,不能删除')
return
}
try {
await ElMessageBox.confirm(`确定删除专员「${row.label}」吗?删除后将不再出现在市场和首页。`, '删除专员', { type: 'warning' })
const planRes = await getSpecialistRemovePlan(row.id)
const summaryText = summarizeSpecialistRemovePlan(planRes)
if (planRes?.data?.plan?.Blocked) {
await ElMessageBox.alert(`${summaryText}\n\n当前仍有运行时引用,暂时不能删除。`, '删除前检查', { type: 'warning' })
return
}
await ElMessageBox.confirm(`${summaryText}\n\n确定删除专员「${displaySpecialistLabel(row)}」吗?删除后将不再出现在市场和首页。`, '删除专员', { type: 'warning' })
await deleteSpecialist(row.id)
ElMessage.success('专员已删除')
await loadAll()
@@ -126,22 +126,22 @@ const detail = computed(() => {
if (resolvedCatalogType.value === 'xapps') {
const xapp = xappCatalog.getByKey(resolvedObjectKey.value)
if (!xapp) return notFoundDetail('APP')
if (!xapp) return notFoundDetail('xApp')
const isOrchestrated = xapp.xappMode === 'orchestrated'
return {
title: xapp.label,
display_code: xapp.display_code,
eailogic_code: xapp.eailogic_code,
subtitle: isOrchestrated ? '更偏向组织流程与协同推进' : '更偏向直接执行具体内容',
metaText: `${xapp.marketTag} · ${isOrchestrated ? '编排型 APP' : '执行型 APP'}`,
badge: 'APP',
metaText: `${xapp.marketTag} · ${isOrchestrated ? '编排型 xApp' : '执行型 xApp'}`,
badge: 'xApp',
avatar: xapp.label.slice(0, 1),
color: xapp.color || '#7c8cff',
description: xapp.description,
tags: normalizeArray(xapp.tags),
sceneTitle: '适合什么时候用',
sceneText: xapp.summary,
promptTitle: 'APP 帮你做',
promptTitle: 'xApp 帮你做',
prompts: normalizeArray(xapp.prompts),
}
}
@@ -59,6 +59,14 @@
<button class="catalog-tool-btn" type="button" @click.stop="toggleFavorite(card)">
{{ isXAppFavorite(card) ? '已收藏' : '收藏' }}
</button>
<button
v-if="isAdmin && !card.isCustomXApp"
class="catalog-tool-btn danger"
type="button"
@click.stop="removeManagedObject(card)"
>
删除
</button>
<button
v-if="card.isCustomXApp"
class="catalog-tool-btn"
@@ -94,6 +102,15 @@
<span>{{ card.actionLabel || (pageType === 'xapps' ? '立即打开' : '查看详情') }}</span>
<span aria-hidden="true">→</span>
</div>
<div v-if="isAdmin && pageType === 'skills'" class="catalog-card-tools skill-tools">
<button
class="catalog-tool-btn danger"
type="button"
@click.stop="removeManagedObject(card)"
>
删除
</button>
</div>
</article>
</div>
@@ -187,12 +204,15 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listConnectors } from '@/connectors/api/connector'
import { buildSpecialistCatalogCards, getSpecialistSectionLabels } from '@/specialists/core/presentation'
import { useAuthStore } from '@/store/auth'
import { useXAppCatalogStore } from '@/xapps/store/xappCatalog'
import { useXAppCenterStore } from '@/xapps/store/xappCenter'
import { deleteXAppDefinition, getXAppRemovePlan } from '@/xapps/api/xappDefinition'
import { useSkillCatalogStore } from '@/skills/store/skillCatalog'
import { deleteSkillDefinition, getSkillRemovePlan } from '@/skills/api/skill'
import { useSpecialistCatalogStore } from '@/specialists/store/specialistCatalog'
import { withObjectCodes } from '@/utils/objectCode'
import ObjectCatalogDetailPage from '@/views/workbench/ObjectCatalogDetailPage.vue'
@@ -218,10 +238,12 @@ const props = defineProps({
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const xappCatalog = useXAppCatalogStore()
const xappCenter = useXAppCenterStore()
const skillCatalog = useSkillCatalogStore()
const specialistCatalog = useSpecialistCatalogStore()
const isAdmin = computed(() => auth.isAdmin)
const remoteConnectors = ref([])
const searchQuery = ref('')
@@ -269,7 +291,7 @@ const isDetailOpen = computed(() => Boolean(currentDetailKey.value))
const pageTitle = computed(() => {
if (String(props.forcedTitle || '').trim()) return String(props.forcedTitle).trim()
if (pageType.value === 'skills') return '技能目录'
if (pageType.value === 'xapps') return 'APP目录'
if (pageType.value === 'xapps') return 'xApp目录'
if (pageType.value === 'connectors') return '连接器目录'
return '专员目录'
})
@@ -277,7 +299,7 @@ const pageTitle = computed(() => {
const pageDesc = computed(() => {
if (String(props.forcedDescription || '').trim()) return String(props.forcedDescription).trim()
if (pageType.value === 'skills') return '这里汇总平台当前可用的技能入口,适合从对象层查看技能定义、定位与产出边界。'
if (pageType.value === 'xapps') return '这里汇总平台内建 APP、成品办公应用和自定义应用,可继续进入对象详情或直接打开使用。'
if (pageType.value === 'xapps') return '这里汇总平台内建 xApp、成品办公应用和自定义应用,可继续进入对象详情或直接打开使用。'
if (pageType.value === 'connectors') return '这里汇总当前已接入和待接入的连接器对象,方便查看方向、对象范围和接入状态。'
return ''
})
@@ -285,7 +307,7 @@ const pageDesc = computed(() => {
const searchPlaceholder = computed(() => {
if (String(props.forcedSearchPlaceholder || '').trim()) return String(props.forcedSearchPlaceholder).trim()
if (pageType.value === 'skills') return '搜索技能名称、定位或能力标签'
if (pageType.value === 'xapps') return '搜索APP名称、定位或能力标签'
if (pageType.value === 'xapps') return '搜索 xApp 名称、定位或能力标签'
if (pageType.value === 'connectors') return '搜索连接器名称、方向或对象范围'
return '搜索专员名称、定位或能力标签'
})
@@ -297,20 +319,23 @@ const catalogXApps = computed(() => xappCatalog.xapps.map((item) => buildXAppCar
const cards = computed(() => {
if (pageType.value === 'skills') {
return skillCatalog.skills.map((skill) => ({
key: skill.key,
display_code: skill.display_code,
eailogic_code: skill.eailogic_code,
title: skill.label,
badge: '技能',
kind: '任务入口',
desc: skill.interactionCard?.tagline || skill.caption,
color: skill.color,
installState: 'installed',
meta: [],
config: [],
searchTokens: ['对象定义', 'Chat-First 技能', ...(skill.skillTags || [])],
}))
return skillCatalog.skills
.filter((skill) => skill.key !== 'smart-assistant')
.map((skill) => ({
id: skill.id,
key: skill.key,
display_code: skill.display_code,
eailogic_code: skill.eailogic_code,
title: skill.label,
badge: '技能',
kind: '任务入口',
desc: skill.interactionCard?.tagline || skill.caption,
color: skill.color,
installState: 'installed',
meta: [],
config: [],
searchTokens: ['对象定义', 'Chat-First 技能', ...(skill.skillTags || [])],
}))
}
if (pageType.value === 'xapps') {
@@ -386,7 +411,7 @@ const catalogSections = computed(() => {
{
key: 'installed',
title: '全部应用',
desc: '平台内建 APP、成品办公应用和你的自定义应用都会收在这里。',
desc: '平台内建 xApp、成品办公应用和你的自定义应用都会收在这里。',
emptyText: '暂无可用应用。',
items: source,
},
@@ -474,12 +499,12 @@ function sectionLabels(type) {
}
if (type === 'xapps') {
return {
installedTitle: '已安装的APP',
installedDesc: '这些 APP 已经接入当前平台,可继续查看其对象定义与详情。',
installedEmpty: '暂无已安装的APP',
pendingTitle: '待安装的APP',
pendingDesc: '这些 APP 还未作为已安装对象接入,后续可继续补充目录项。',
pendingEmpty: '暂无待安装的APP',
installedTitle: '已安装的 xApp',
installedDesc: '这些 xApp 已经接入当前平台,可继续查看其对象定义与详情。',
installedEmpty: '暂无已安装的 xApp',
pendingTitle: '待安装的 xApp',
pendingDesc: '这些 xApp 还未作为已安装对象接入,后续可继续补充目录项。',
pendingEmpty: '暂无待安装的 xApp',
}
}
if (type === 'connectors') {
@@ -497,6 +522,7 @@ function sectionLabels(type) {
function buildXAppCard(item) {
return {
id: item.id,
key: item.key,
display_code: item.display_code,
eailogic_code: item.eailogic_code,
@@ -630,6 +656,80 @@ function removeCustom(card) {
xappCenter.removeCustomXApp(card?.key)
ElMessage.success('已删除自定义应用')
}
function summarizeSkillRemovePlan(planPayload) {
const payload = planPayload?.data || planPayload || {}
const plan = payload?.plan || {}
const refs = plan?.RuntimeRefs || {}
return [
`删除对象:${payload?.label || plan?.Label || payload?.key || '-'}`,
`对象类型:${payload?.is_builtin ? '内置技能' : '运行时技能'}`,
`引用情况:专员 ${refs?.SpecialistKeys?.length || 0},xApp ${refs?.XAppKeys?.length || 0},项目 ${refs?.ProjectIDs?.length || 0}`,
`退场策略:${plan?.Policy || '未设置'}`,
].join('\n')
}
function summarizeXAppRemovePlan(planPayload) {
const payload = planPayload?.data || planPayload || {}
const plan = payload?.plan || {}
const refs = plan?.RuntimeRefs || {}
return [
`删除对象:${payload?.label || plan?.Label || payload?.key || '-'}`,
`对象类型:xApp`,
`引用情况:收藏 ${refs?.FavoriteUserIDs?.length || 0},最近使用 ${refs?.RecentUserIDs?.length || 0},自定义应用 ${refs?.CustomUserIDs?.length || 0}`,
`退场策略:${plan?.Policy || '未设置'}`,
].join('\n')
}
async function removeManagedObject(card) {
if (!isAdmin.value) return
if (pageType.value === 'skills') {
if (!card?.id) {
ElMessage.warning('当前技能缺少定义 ID,暂时不能删除')
return
}
try {
const planRes = await getSkillRemovePlan(card.id)
const summaryText = summarizeSkillRemovePlan(planRes)
if (planRes?.data?.plan?.Blocked) {
await ElMessageBox.alert(`${summaryText}\n\n当前仍有运行时引用,暂时不能删除。`, '删除前检查', { type: 'warning' })
return
}
await ElMessageBox.confirm(`${summaryText}\n\n确定删除技能「${card.title}」吗?`, '删除技能', { type: 'warning' })
await deleteSkillDefinition(card.id)
ElMessage.success('技能已删除')
await skillCatalog.hydrate(true)
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error?.message || '删除技能失败')
}
}
return
}
if (pageType.value === 'xapps') {
if (!card?.id) {
ElMessage.warning('自定义应用请在“我的应用”里删除')
return
}
try {
const planRes = await getXAppRemovePlan(card.id)
const summaryText = summarizeXAppRemovePlan(planRes)
if (planRes?.data?.plan?.Blocked) {
await ElMessageBox.alert(`${summaryText}\n\n当前仍有运行时引用,暂时不能删除。`, '删除前检查', { type: 'warning' })
return
}
await ElMessageBox.confirm(`${summaryText}\n\n确定删除应用「${card.title}」吗?`, '删除应用', { type: 'warning' })
await deleteXAppDefinition(card.id)
ElMessage.success('应用已删除')
await xappCatalog.hydrate(true)
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error?.message || '删除应用失败')
}
}
}
}
</script>
<style scoped>
@@ -257,7 +257,7 @@ function objectBindingSummary(label) {
}
function specialistLabel(key) {
if (!key || key === GENERAL_ASSISTANT_KEY) return '通用助手'
if (!key || key === GENERAL_ASSISTANT_KEY) return '通用专员'
return projectStore.specialistLabelOf(key)
}
@@ -198,6 +198,12 @@ import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus'
import { sendChatMessage } from '@/api/chatMessage'
import {
createOfficialAccountTask,
executeOfficialAccountWorkflowStep,
getOfficialAccountWorkflow,
updateOfficialAccountTask,
} from '@/api/officialAccount'
import { uploadMedia } from '@/api/media'
import ChatLayout from '@/components/chat/ChatLayout.vue'
import ChatInputBar from '@/components/chat/ChatInputBar.vue'
@@ -261,10 +267,11 @@ const aiRouteStore = useAiRouteStore()
const { selectedAiChatRouteId } = storeToRefs(aiRouteStore)
const composerUploads = ref([])
const MAX_UPLOAD_FILES_PER_BATCH = 100
const OFFICIAL_ACCOUNT_KEY = 'wechat-official-account'
const defaultGeneralSpecialistPresentation = Object.freeze({
kind: 'specialist',
label: '通用助手',
label: '通用专员',
greeting: '博昇AI数字员工,您说,我做',
tagline: '今天帮你做些什么?描述你的任务,或先挂载技能与材料',
openingPrompt: '直接告诉我目标,或者把材料贴给我,我来帮你起步。',
@@ -430,11 +437,194 @@ function buildLocalSkillReply(result, definition) {
}
}
function isOfficialAccountSpecialistActive() {
return String(currentSpecialistKey.value || '').trim() === OFFICIAL_ACCOUNT_KEY
}
function buildOfficialAccountSeedForm(text) {
const normalizedText = String(text || '').trim()
const cleanedKeyword = normalizedText
.replace(/[“”"'`]/g, '')
.replace(/帮我|请|麻烦|直接|立刻|现在|给我|来一篇|生成一篇|写一篇|写篇|做一篇|做篇|输出一篇|写个/gu, ' ')
.replace(/公众号(文章|推文)?|推文|文章|内容|文案/gu, ' ')
.replace(/推广|宣传/gu, ' ')
.replace(/关于|有关|一篇/gu, ' ')
.replace(/[,。!?、,.!?]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
return {
business_domain: '',
keyword: cleanedKeyword || normalizedText,
audience: '潜在客户与行业合作方',
goal: normalizedText.includes('推广')
? '输出一篇适合推广转化的公众号文章'
: '输出一篇可发布的公众号文章',
tone: '专业但好懂',
requirements: normalizedText,
outline_style: '问题拆解型',
outline_words: 600,
content_target_words: 1400,
max_content_images: 0,
}
}
function buildOfficialAccountTaskPlan(workflow) {
const steps = [
{ key: 'topic_recommendation', order: 1, title: '选题', desc: '生成候选选题' },
{ key: 'title_generation', order: 2, title: '标题', desc: '生成标题并确定方向' },
{ key: 'outline_generation', order: 3, title: '提纲', desc: '生成文章结构提纲' },
{ key: 'content_creation', order: 4, title: '正文', desc: '生成正文草稿' },
]
return {
title: '公众号创作专员流程',
steps: steps.map((item) => ({
id: item.key,
order: item.order,
title: item.title,
desc: workflow?.steps?.[item.key]?.status === 'completed'
? `${item.desc} · 已完成`
: item.desc,
})),
}
}
function buildOfficialAccountReply(payload) {
const workflow = payload?.workflow || {}
const shared = workflow.shared || {}
const topic = shared.selected_topic || ''
const title = shared.selected_title || ''
const outline = shared.outline || ''
const content = shared.content || ''
const summaryLines = [
'已按公众号创作专员流程推进,右栏工作流和产物会同步更新。',
topic ? `选题:${topic}` : '',
title ? `标题:${title}` : '',
'',
content || outline || '本轮还没有生成出正文草稿。',
].filter(Boolean)
return {
role: 'assistant',
content: summaryLines.join('\n'),
task_plan: buildOfficialAccountTaskPlan(workflow),
timestamp: new Date().toLocaleTimeString(),
}
}
async function runOfficialAccountConversation(text, onProgress = () => {}) {
const form = buildOfficialAccountSeedForm(text)
onProgress('公众号创作专员已接单,正在准备创作任务...')
let taskId = Number(currentTaskId.value) || 0
if (!taskId) {
const createRes = await createOfficialAccountTask(form)
const data = createRes?.data || null
const task = data?.task || null
if (!task?.id) throw new Error('公众号创作任务创建失败')
taskId = Number(task.id)
await taskRuntime.loadMyTasks()
taskRuntime.openTask(taskId, task.specialist_key || OFFICIAL_ACCOUNT_KEY)
await taskRuntime.loadTaskDetail(taskId)
} else {
onProgress('正在更新当前创作任务配置...')
await updateOfficialAccountTask(taskId, form)
await taskRuntime.loadMyTasks()
await taskRuntime.loadTaskDetail(taskId)
}
let workflowRes = await getOfficialAccountWorkflow(taskId)
let workflow = workflowRes?.data || null
onProgress('正在生成选题...')
await executeOfficialAccountWorkflowStep(taskId, 'topic_recommendation', {
requirements: form.requirements,
})
await taskRuntime.loadMyTasks()
await taskRuntime.loadTaskDetail(taskId)
workflowRes = await getOfficialAccountWorkflow(taskId)
workflow = workflowRes?.data || null
const selectedTopic = workflow?.workflow?.shared?.selected_topic
|| workflow?.workflow?.shared?.topic_candidates?.[0]?.topic
|| ''
onProgress(selectedTopic ? `正在基于选题生成标题:${selectedTopic}` : '正在生成标题...')
await executeOfficialAccountWorkflowStep(taskId, 'title_generation', {
topic: selectedTopic,
requirements: form.requirements,
})
await taskRuntime.loadMyTasks()
await taskRuntime.loadTaskDetail(taskId)
workflowRes = await getOfficialAccountWorkflow(taskId)
workflow = workflowRes?.data || null
const selectedTitle = workflow?.workflow?.shared?.selected_title
|| workflow?.workflow?.shared?.title_candidates?.[0]
|| workflow?.workflow?.steps?.title_generation?.output?.titles?.[0]?.title
|| ''
onProgress(selectedTitle ? `正在生成提纲:${selectedTitle}` : '正在生成提纲...')
await executeOfficialAccountWorkflowStep(taskId, 'outline_generation', {
title: selectedTitle,
requirements: form.requirements,
})
await taskRuntime.loadMyTasks()
await taskRuntime.loadTaskDetail(taskId)
workflowRes = await getOfficialAccountWorkflow(taskId)
workflow = workflowRes?.data || null
const outline = workflow?.workflow?.shared?.outline || ''
onProgress('正在生成正文草稿...')
await executeOfficialAccountWorkflowStep(taskId, 'content_creation', {
outline,
requirements: form.requirements,
})
workflowRes = await getOfficialAccountWorkflow(taskId)
workflow = workflowRes?.data || null
await taskRuntime.loadMyTasks()
await taskRuntime.loadTaskDetail(taskId)
return buildOfficialAccountReply(workflow)
}
async function send() {
if (!inputText.value.trim() || sending.value) return
const text = inputText.value
if (isOfficialAccountSpecialistActive()) {
messages.value.push({
role: 'user',
content: text,
contentBlocks: composerBlocks.value,
timestamp: new Date().toLocaleTimeString(),
})
inputText.value = ''
sending.value = true
const progressMessage = {
role: 'assistant',
content: '公众号创作专员已接单,正在处理...',
timestamp: new Date().toLocaleTimeString(),
}
messages.value.push(progressMessage)
try {
const reply = await runOfficialAccountConversation(text, (progressText) => {
progressMessage.content = progressText
progressMessage.timestamp = new Date().toLocaleTimeString()
})
progressMessage.content = reply.content
progressMessage.task_plan = reply.task_plan
progressMessage.timestamp = reply.timestamp
await nextTick()
scrollToEnd()
} catch (error) {
progressMessage.content = `公众号创作专员执行失败:${error?.message || '请稍后重试'}`
progressMessage.timestamp = new Date().toLocaleTimeString()
ElMessage.error(error?.message || '公众号创作专员执行失败')
} finally {
sending.value = false
}
return
}
// 这条画布上还没有任务(刚点过「新建任务」,或压根没点开过任务)——
// 任务在这时候才落库,标题就是这句话,专员用 + 里先选的那个。
// 建失败就把话原样留在输入框里返回:此刻一个字都还没发出去,
@@ -7,3 +7,19 @@ export function listXAppDefinitions(params) {
export function getXAppDefinitionByKey(key) {
return http.get(`/xapps/by-key/${key}`)
}
export function createXAppDefinition(data) {
return http.post('/admin/xapps', data)
}
export function updateXAppDefinition(id, data) {
return http.put(`/admin/xapps/${id}`, data)
}
export function deleteXAppDefinition(id) {
return http.delete(`/admin/xapps/${id}`)
}
export function getXAppRemovePlan(id) {
return http.get(`/admin/xapps/${id}/remove-plan`)
}
@@ -2,9 +2,9 @@
<div class="xapp-shell">
<section class="xapp-shell__hero">
<div>
<div class="xapp-shell__eyebrow">长程 APP</div>
<h2>内部考试APP</h2>
<p>把练习、自测、正式考试、记录、证书和学习档案统一收在同一个 xapp 运行面,避免考试链路继续与主架构平行散开。</p>
<div class="xapp-shell__eyebrow">长程 xApp</div>
<h2>内部考试 xApp</h2>
<p>把练习、自测、正式考试、记录、证书和学习档案统一收在同一个 xApp 运行面,让考试链路从入口开始就有状态感。</p>
</div>
<div class="xapp-shell__badge">EAI-X</div>
</section>
@@ -23,20 +23,68 @@
</div>
<section class="xapp-shell__content">
<router-view />
<template v-if="activeRoute === INTERNAL_EXAM_MODULE_ROUTES.home">
<section class="xapp-shell__overview">
<article v-for="item in summaryCards" :key="item.label" class="summary-card">
<div class="summary-card__label">{{ item.label }}</div>
<div class="summary-card__value">{{ item.value }}</div>
<div class="summary-card__hint">{{ item.hint }}</div>
</article>
</section>
<section class="xapp-shell__panel-grid">
<article class="panel-card">
<div class="panel-card__title">模块总览</div>
<button
v-for="item in moduleCards"
:key="item.route"
type="button"
class="module-row"
@click="router.push(item.route)"
>
<div>
<div class="module-row__title">{{ item.label }}</div>
<div class="module-row__desc">{{ item.desc }}</div>
</div>
<div class="module-row__meta">{{ item.meta }}</div>
</button>
</article>
<article class="panel-card panel-card--status">
<div class="panel-card__title">当前状态</div>
<div class="status-list">
<div v-for="item in statusItems" :key="item.label" class="status-item">
<div class="status-item__label">{{ item.label }}</div>
<div class="status-item__value">{{ item.value }}</div>
</div>
</div>
<div class="panel-card__tip">{{ statusTip }}</div>
</article>
</section>
</template>
<router-view v-else />
</section>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { listExams, examRecords } from '@/api/exam'
import { myCertificates } from '@/api/certificate'
import { myProfile } from '@/api/profile'
import { INTERNAL_EXAM_MODULE_ROUTES } from '@/xapps/config/xappRoutes'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const examItems = ref([])
const recordCount = ref(0)
const certificateCount = ref(0)
const learningPoints = ref(0)
const tabs = [
{ label: '概览', route: INTERNAL_EXAM_MODULE_ROUTES.home },
{ label: '练习自测', route: INTERNAL_EXAM_MODULE_ROUTES.selfTest },
{ label: '正式考试', route: INTERNAL_EXAM_MODULE_ROUTES.formal },
{ label: '考试记录', route: INTERNAL_EXAM_MODULE_ROUTES.myRecords },
@@ -47,10 +95,99 @@ const tabs = [
{ label: '学习档案', route: INTERNAL_EXAM_MODULE_ROUTES.myProfile },
]
const selfTestCount = computed(() => examItems.value.filter((item) => item.type === 'self_test').length)
const formalCount = computed(() => examItems.value.filter((item) => item.type === 'formal').length)
const moduleCards = computed(() => [
{
label: '练习自测',
route: INTERNAL_EXAM_MODULE_ROUTES.selfTest,
desc: '进入自测试卷列表,支持反复练习。',
meta: `${selfTestCount.value} 份试卷`,
},
{
label: '正式考试',
route: INTERNAL_EXAM_MODULE_ROUTES.formal,
desc: '进入正式考试列表,提交后沉淀成绩与记录。',
meta: `${formalCount.value} 场考试`,
},
{
label: '考试记录',
route: INTERNAL_EXAM_MODULE_ROUTES.myRecords,
desc: '查看历史成绩、答题详情和复盘结果。',
meta: `${recordCount.value} 条记录`,
},
{
label: '证书与档案',
route: INTERNAL_EXAM_MODULE_ROUTES.myCertificates,
desc: '查看证书、学习积分和个人学习画像。',
meta: `${certificateCount.value} 张证书`,
},
])
const summaryCards = computed(() => [
{
label: '自测试卷',
value: selfTestCount.value,
hint: selfTestCount.value ? '可进入练习自测开始刷题' : '当前还没有配置自测试卷',
},
{
label: '正式考试',
value: formalCount.value,
hint: formalCount.value ? '正式考试已挂到当前 xApp' : '正式考试模块还没有考试内容',
},
{
label: '考试记录',
value: recordCount.value,
hint: recordCount.value ? '已有考试记录沉淀' : '还没有任何考试记录',
},
{
label: '学习积分',
value: learningPoints.value,
hint: learningPoints.value ? '学习档案已有积分沉淀' : '当前积分仍为零或尚未返回',
},
])
const statusItems = computed(() => [
{ label: '题库状态', value: selfTestCount.value || formalCount.value ? '已接入' : '空模块' },
{ label: '成绩记录', value: recordCount.value ? '已有沉淀' : '未开始' },
{ label: '证书沉淀', value: certificateCount.value ? '已生成' : '暂无' },
])
const statusTip = computed(() => {
if (loading.value) return '正在刷新考试 xApp 状态。'
if (!selfTestCount.value && !formalCount.value) {
return '当前考试 xApp 真正发空的根因是考试内容本身还没配置,外层页面再漂亮也托不起来。'
}
if (!recordCount.value) return '考试内容已经接入,但用户侧还没有形成成绩记录。'
return '考试链路已经开始沉淀,可继续强化复盘、证书和画像联动。'
})
const activeRoute = computed(() => {
const path = route.path || ''
return tabs.find((item) => path.startsWith(item.route))?.route || INTERNAL_EXAM_MODULE_ROUTES.selfTest
if (path === INTERNAL_EXAM_MODULE_ROUTES.home) return INTERNAL_EXAM_MODULE_ROUTES.home
return tabs.slice(1).find((item) => path.startsWith(item.route))?.route || INTERNAL_EXAM_MODULE_ROUTES.selfTest
})
async function loadSummary() {
loading.value = true
try {
const [examRes, recordRes, certRes, profileRes] = await Promise.allSettled([
listExams(),
examRecords(),
myCertificates(),
myProfile(),
])
examItems.value = examRes.status === 'fulfilled' ? (examRes.value?.data || []) : []
recordCount.value = recordRes.status === 'fulfilled' ? (recordRes.value?.data?.length || 0) : 0
certificateCount.value = certRes.status === 'fulfilled' ? (certRes.value?.data?.length || 0) : 0
learningPoints.value = profileRes.status === 'fulfilled' ? (profileRes.value?.data?.learning_points || 0) : 0
} finally {
loading.value = false
}
}
onMounted(loadSummary)
</script>
<style scoped>
@@ -105,6 +242,128 @@ const activeRoute = computed(() => {
font-weight: 700;
}
.xapp-shell__overview {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.summary-card {
padding: 16px;
border: 1px solid #dbf3e4;
border-radius: 16px;
background: #ffffff;
}
.summary-card__label {
font-size: 12px;
color: #8b97a6;
}
.summary-card__value {
margin-top: 8px;
font-size: 28px;
font-weight: 700;
color: #1f2937;
line-height: 1.1;
}
.summary-card__hint {
margin-top: 8px;
font-size: 13px;
line-height: 1.6;
color: #5f6f82;
}
.xapp-shell__panel-grid {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
gap: 12px;
}
.panel-card {
padding: 18px;
border: 1px solid #dbf3e4;
border-radius: 18px;
background: #ffffff;
}
.panel-card__title {
margin-bottom: 12px;
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.panel-card__tip {
margin-top: 14px;
font-size: 13px;
line-height: 1.7;
color: #5f6f82;
}
.module-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 0;
border: none;
border-top: 1px solid #edf6f0;
background: transparent;
text-align: left;
cursor: pointer;
}
.module-row:first-of-type {
border-top: none;
padding-top: 4px;
}
.module-row__title {
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.module-row__desc {
margin-top: 4px;
font-size: 13px;
line-height: 1.6;
color: #5f6f82;
}
.module-row__meta {
flex-shrink: 0;
font-size: 12px;
color: #15803d;
}
.status-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.status-item {
padding: 14px 12px;
border-radius: 14px;
background: #f3fbf6;
}
.status-item__label {
font-size: 12px;
color: #8b97a6;
}
.status-item__value {
margin-top: 6px;
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.xapp-shell__tabs {
display: flex;
flex-wrap: wrap;
@@ -129,5 +388,33 @@ const activeRoute = computed(() => {
.xapp-shell__content {
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
@media (max-width: 1100px) {
.xapp-shell__overview {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.xapp-shell__panel-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.xapp-shell {
padding: 16px;
}
.xapp-shell__hero {
flex-direction: column;
}
.xapp-shell__overview,
.status-list {
grid-template-columns: 1fr;
}
}
</style>
@@ -2,9 +2,9 @@
<div class="xapp-shell">
<section class="xapp-shell__hero">
<div>
<div class="xapp-shell__eyebrow">长程 APP</div>
<h2>内部培训APP</h2>
<p>把销售培训、产品知识和公司介绍收在同一个 xapp 下,不再作为并行页面散落在主架构外侧。</p>
<div class="xapp-shell__eyebrow">长程 xApp</div>
<h2>内部培训 xApp</h2>
<p>把销售培训、产品知识和公司介绍收在同一个 xApp 里,让培训入口、内容状态和学习动作都落在同一运行面。</p>
</div>
<div class="xapp-shell__badge">EAI-X</div>
</section>
@@ -23,31 +23,166 @@
</div>
<section class="xapp-shell__content">
<router-view />
<template v-if="activeRoute === INTERNAL_TRAINING_MODULE_ROUTES.home">
<section class="xapp-shell__overview">
<article v-for="item in summaryCards" :key="item.label" class="summary-card">
<div class="summary-card__label">{{ item.label }}</div>
<div class="summary-card__value">{{ item.value }}</div>
<div class="summary-card__hint">{{ item.hint }}</div>
</article>
</section>
<section class="xapp-shell__panel-grid">
<article class="panel-card">
<div class="panel-card__title">模块总览</div>
<button
v-for="item in moduleCards"
:key="item.route"
type="button"
class="module-row"
@click="router.push(item.route)"
>
<div>
<div class="module-row__title">{{ item.label }}</div>
<div class="module-row__desc">{{ item.desc }}</div>
</div>
<div class="module-row__meta">{{ item.meta }}</div>
</button>
</article>
<article class="panel-card panel-card--status">
<div class="panel-card__title">当前状态</div>
<div class="status-list">
<div v-for="item in statusItems" :key="item.label" class="status-item">
<div class="status-item__label">{{ item.label }}</div>
<div class="status-item__value">{{ item.value }}</div>
</div>
</div>
<div class="panel-card__tip">{{ statusTip }}</div>
</article>
</section>
</template>
<router-view v-else />
</section>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { listCourses } from '@/api/courses'
import { listProducts } from '@/api/products'
import { getCompanyTrain } from '@/api/company'
import { INTERNAL_TRAINING_MODULE_ROUTES } from '@/xapps/config/xappRoutes'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const courseCount = ref(0)
const productCount = ref(0)
const companyMaterialCount = ref(0)
const companyContentReady = ref(false)
const tabs = [
{ label: '概览', route: INTERNAL_TRAINING_MODULE_ROUTES.home },
{ label: '销售培训', route: INTERNAL_TRAINING_MODULE_ROUTES.courses },
{ label: '产品知识', route: INTERNAL_TRAINING_MODULE_ROUTES.products },
{ label: '公司介绍', route: INTERNAL_TRAINING_MODULE_ROUTES.company },
]
const moduleCards = computed(() => [
{
label: '销售培训',
route: INTERNAL_TRAINING_MODULE_ROUTES.courses,
desc: '承接课程清单、培训话术和陪练资料。',
meta: `${courseCount.value} 门课程`,
},
{
label: '产品知识',
route: INTERNAL_TRAINING_MODULE_ROUTES.products,
desc: '承接产品清单、收费结构、标签和对外介绍。',
meta: `${productCount.value} 个产品`,
},
{
label: '公司介绍',
route: INTERNAL_TRAINING_MODULE_ROUTES.company,
desc: '承接公司介绍正文、课件素材和预览内容。',
meta: `${companyMaterialCount.value} 份素材`,
},
])
const summaryCards = computed(() => [
{
label: '销售课程',
value: courseCount.value,
hint: courseCount.value ? '可直接进入课程列表学习' : '当前还没有课程数据',
},
{
label: '产品条目',
value: productCount.value,
hint: productCount.value ? '产品知识已接入当前 xApp' : '产品模块还没有内容',
},
{
label: '公司素材',
value: companyMaterialCount.value,
hint: companyMaterialCount.value ? '视频与课件素材已挂载' : '公司素材尚未挂载',
},
{
label: '正文状态',
value: companyContentReady.value ? '已配置' : '待补充',
hint: companyContentReady.value ? '公司介绍正文已可阅读' : '公司介绍只有素材,正文仍为空',
},
])
const statusItems = computed(() => [
{ label: '课程模块', value: courseCount.value ? '已接入' : '待补充' },
{ label: '产品模块', value: productCount.value ? '已接入' : '空模块' },
{ label: '公司正文', value: companyContentReady.value ? '已接入' : '空正文' },
])
const statusTip = computed(() => {
if (loading.value) return '正在刷新培训 xApp 状态。'
if (!productCount.value && !companyContentReady.value) {
return '当前最空的是产品知识和公司正文,这也是训练 xApp 观感发空的主要来源。'
}
if (!productCount.value) return '产品知识模块还没有内容,建议优先补产品条目。'
if (!companyContentReady.value) return '公司介绍已挂素材,但正文还没配,建议补一版标准介绍。'
return '培训 xApp 三个模块都已有内容承接,可以继续做精细化编排。'
})
const activeRoute = computed(() => {
const path = route.path || ''
if (path === INTERNAL_TRAINING_MODULE_ROUTES.home) return INTERNAL_TRAINING_MODULE_ROUTES.home
if (path.startsWith(INTERNAL_TRAINING_MODULE_ROUTES.products)) return INTERNAL_TRAINING_MODULE_ROUTES.products
if (path.startsWith(INTERNAL_TRAINING_MODULE_ROUTES.company)) return INTERNAL_TRAINING_MODULE_ROUTES.company
return INTERNAL_TRAINING_MODULE_ROUTES.courses
})
async function loadSummary() {
loading.value = true
try {
const [courseRes, productRes, companyRes] = await Promise.allSettled([
listCourses(),
listProducts(),
getCompanyTrain(),
])
courseCount.value = courseRes.status === 'fulfilled' ? (courseRes.value?.data?.length || 0) : 0
productCount.value = productRes.status === 'fulfilled' ? (productRes.value?.data?.length || 0) : 0
if (companyRes.status === 'fulfilled') {
companyMaterialCount.value = companyRes.value?.data?.medias?.length || 0
companyContentReady.value = Boolean((companyRes.value?.data?.content || '').trim())
} else {
companyMaterialCount.value = 0
companyContentReady.value = false
}
} finally {
loading.value = false
}
}
onMounted(loadSummary)
</script>
<style scoped>
@@ -102,6 +237,128 @@ const activeRoute = computed(() => {
font-weight: 700;
}
.xapp-shell__overview {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.summary-card {
padding: 16px;
border: 1px solid #ece8ff;
border-radius: 16px;
background: #ffffff;
}
.summary-card__label {
font-size: 12px;
color: #8b97a6;
}
.summary-card__value {
margin-top: 8px;
font-size: 28px;
font-weight: 700;
color: #1f2937;
line-height: 1.1;
}
.summary-card__hint {
margin-top: 8px;
font-size: 13px;
line-height: 1.6;
color: #5f6f82;
}
.xapp-shell__panel-grid {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
gap: 12px;
}
.panel-card {
padding: 18px;
border: 1px solid #ece8ff;
border-radius: 18px;
background: #ffffff;
}
.panel-card__title {
margin-bottom: 12px;
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.panel-card__tip {
margin-top: 14px;
font-size: 13px;
line-height: 1.7;
color: #5f6f82;
}
.module-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 0;
border: none;
border-top: 1px solid #f1f3f7;
background: transparent;
text-align: left;
cursor: pointer;
}
.module-row:first-of-type {
border-top: none;
padding-top: 4px;
}
.module-row__title {
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.module-row__desc {
margin-top: 4px;
font-size: 13px;
line-height: 1.6;
color: #5f6f82;
}
.module-row__meta {
flex-shrink: 0;
font-size: 12px;
color: #7c3aed;
}
.status-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.status-item {
padding: 14px 12px;
border-radius: 14px;
background: #f8f7ff;
}
.status-item__label {
font-size: 12px;
color: #8b97a6;
}
.status-item__value {
margin-top: 6px;
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.xapp-shell__tabs {
display: flex;
flex-wrap: wrap;
@@ -126,5 +383,33 @@ const activeRoute = computed(() => {
.xapp-shell__content {
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
@media (max-width: 1100px) {
.xapp-shell__overview {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.xapp-shell__panel-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.xapp-shell {
padding: 16px;
}
.xapp-shell__hero {
flex-direction: column;
}
.xapp-shell__overview,
.status-list {
grid-template-columns: 1fr;
}
}
</style>
@@ -2,12 +2,12 @@
<div class="xapp-directory-page">
<section class="hero-card">
<div>
<div class="hero-eyebrow">APP 目录</div>
<h2>APP目录</h2>
<div class="hero-eyebrow">xApp 目录</div>
<h2>xApp目录</h2>
</div>
<div class="hero-tip">
<strong>{{ xappCards.length }}</strong>
<span>个已接入 APP</span>
<span>个已接入 xApp</span>
</div>
</section>
@@ -45,7 +45,7 @@
</div>
<div class="xapp-actions">
<el-button type="primary" @click="router.push(xapp.pageRoute)">进入 APP</el-button>
<el-button type="primary" @click="router.push(xapp.pageRoute)">进入 xApp</el-button>
<el-button text @click="router.push(xapp.pageRoute)">查看运行面</el-button>
</div>
</article>
@@ -62,10 +62,10 @@ const router = useRouter()
const xappCards = [
{
key: 'internal-training',
title: '内部培训APP',
title: '内部培训 xApp',
subtitle: '销售培训 / 产品知识 / 公司介绍',
badge: '已接入',
desc: '面向内部培训与知识导览的统一 APP,下面承接销售培训、产品知识和公司介绍三个运行模块。',
desc: '面向内部培训与知识导览的统一 xApp,下面承接销售培训、产品知识和公司介绍三个运行模块。',
pageRoute: INTERNAL_TRAINING_XAPP_ROOT,
color: '#5b7cff',
meta: ['统一培训入口', '多模块', '持续学习'],
@@ -77,7 +77,7 @@ const xappCards = [
},
{
key: 'internal-exam',
title: '内部考试APP',
title: '内部考试 xApp',
subtitle: '练习 / 正式考试 / 记录 / 证书',
badge: '已接入',
desc: '面向内部培训后的考核与评估场景,统一承接练习、自测、正式考试、成绩记录和证书结果。',