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:
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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
-2
@@ -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: '面向内部培训后的考核与评估场景,统一承接练习、自测、正式考试、成绩记录和证书结果。',
|
||||
|
||||
Reference in New Issue
Block a user