feat: 专员岗位说明书与技能绑定打通(SY23 P0–P4)
工作台对话此前根本不读专员表:smart_assistant.go 里两条写死的 prompt, 专员表零参与,所以「选哪个专员说话都一样」。本次把 「专员 → 岗位说明书 → 技能」接进 system prompt。 P0 修 bug batch/extract 与 contract/review 直接把 nil 当路由传给 AI 层, 取 primary.RouteID 时空指针,这两个接口必 500。 llm.go 抽出 buildRouteChain,nil 主路由改为显式报错。 P1 数据层 specialist 表加 rule_file_markdown(岗位说明书正文)与 allowed_skills (绑定技能,顺序即优先级)。写入侧用 model.ValidSkillKeys 校验, 非法 key / 重复项直接 400,并规范化成紧凑 JSON 落库。 播种 11 份岗位说明书初稿与技能绑定,沿用「存在则只补空字段」, 管理员改过的内容不会被重启覆盖。 P2 打通链路 请求体加 task_id 与 specialist_key。后端按任务反查专员(专员是任务的 字段,任务优先),查不到安静退回通用助手而不是报错打断对话。 ai_call_log 加 specialist_key,用来回答「这条回答是谁说的」。 P3 注入 prompt system prompt 改为 基础角色 + 【当前专员】+【可用技能】+【岗位说明书】, 说明书放最后(离用户消息最近,优先级最高)。选了专员就不再自称 「通用助手」——身份冲突正是老毛病的成因。 P4 前端 对话带上当前任务与专员;专员详情页显示绑定的技能、读哪些信源、 可以动什么。 验证 go build / go vet / go test 全绿,45 项测试通过;前端构建通过。 另在开发库副本上验过真实升级路径(AutoMigrate 补三列 + 播种补齐 11 个专员),源库未被改动。 新增防漂移测试:后端技能清单与前端 availableSkills 不一致即红灯; 专员改名而说明书没同步也会红灯(这类静默失效最难查)。 注:llm.go / credits.go / seed.go / smart_assistant.go / api/specialist.go 同时含有并行进行中的改动(路由健康上报、清理调试埋点、技能与动作种子), 与本方案交织在同一批行内,无法单独拆出,一并随本次提交。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,625 @@
|
||||
<template>
|
||||
<div class="catalog-detail-page">
|
||||
<section v-if="showBackButton" class="detail-toolbar">
|
||||
<button class="back-btn" type="button" @click="emit('close')">返回目录</button>
|
||||
</section>
|
||||
|
||||
<section class="detail-modal-card" :style="{ '--accent': detail.color || '#409eff' }">
|
||||
<div class="detail-header">
|
||||
<div class="detail-avatar">{{ detail.avatar }}</div>
|
||||
|
||||
<div class="detail-header-main">
|
||||
<div class="detail-title">{{ detail.title }}</div>
|
||||
<div v-if="detail.display_code || detail.eailogic_code" class="detail-code-row">
|
||||
<span v-if="detail.display_code" class="detail-code-chip">{{ detail.display_code }}</span>
|
||||
<span v-if="detail.eailogic_code" class="detail-code-chip subtle">{{ detail.eailogic_code }}</span>
|
||||
</div>
|
||||
<div v-if="detail.subtitle" class="detail-subtitle">{{ detail.subtitle }}</div>
|
||||
<div v-if="detail.metaText" class="detail-meta-text">{{ detail.metaText }}</div>
|
||||
</div>
|
||||
|
||||
<span v-if="detail.badge" class="detail-badge">{{ detail.badge }}</span>
|
||||
</div>
|
||||
|
||||
<p class="detail-description">{{ detail.description }}</p>
|
||||
|
||||
<div v-if="detail.tags.length" class="detail-tags">
|
||||
<span v-for="tag in detail.tags" :key="tag" class="detail-tag">{{ tag }}</span>
|
||||
</div>
|
||||
|
||||
<section v-if="detail.sceneText" class="detail-section">
|
||||
<div class="detail-section-title">{{ detail.sceneTitle }}</div>
|
||||
<p class="detail-section-text">{{ detail.sceneText }}</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-for="section in detail.extraSections"
|
||||
:key="section.title"
|
||||
class="detail-section"
|
||||
>
|
||||
<div class="detail-section-title">{{ section.title }}</div>
|
||||
<div class="detail-bullet-list">
|
||||
<div v-for="item in section.items" :key="item" class="detail-bullet-item">{{ item }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="detail.prompts.length" class="detail-section">
|
||||
<div class="detail-section-title">{{ detail.promptTitle }}</div>
|
||||
<div class="detail-prompt-list">
|
||||
<button
|
||||
v-for="prompt in detail.prompts"
|
||||
:key="prompt"
|
||||
class="detail-prompt-button"
|
||||
type="button"
|
||||
>
|
||||
“ {{ prompt }} ”
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { listConnectors } from '@/api/connector'
|
||||
import { getSpecialistByKey } from '@/api/specialist'
|
||||
import { availableSkills, businessApps } from '@/config/workbench'
|
||||
import { productizedApps } from '@/config/productizedApps'
|
||||
|
||||
const props = defineProps({
|
||||
catalogType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
objectKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
showBackButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const remoteConnectors = ref([])
|
||||
// 专员的技能绑定与信源只有后端有(前端 businessApps 是静态配置)。
|
||||
// 拉不到就退回只显示前端已知的内容,页面不会因此空掉或报错。
|
||||
const remoteSpecialist = ref(null)
|
||||
|
||||
const catalogApps = [
|
||||
{
|
||||
key: 'knowledge-hub',
|
||||
display_code: '应01',
|
||||
eailogic_code: 'EAI-A-01',
|
||||
label: '知识库',
|
||||
marketTag: '内建 APP',
|
||||
workerType: 'adw',
|
||||
tier: 'platform',
|
||||
color: '#5b7cff',
|
||||
summary: '组织级默认存在的知识底座 APP,为专家、技能与其它 APP 提供知识支持。',
|
||||
description: '知识库是平台里的知识底座,用来沉淀资料、规则和证据,并为专家、技能和 APP 提供统一知识支持。',
|
||||
prompts: ['我想先整理这批知识资料', '帮我看看这部分内容适合怎么入库', '知识库更适合承接哪些任务'],
|
||||
tags: ['知识沉淀', '证据引用', '组织底座'],
|
||||
},
|
||||
{
|
||||
key: 'internal-exam',
|
||||
display_code: '应02',
|
||||
eailogic_code: 'EAI-A-02',
|
||||
label: '内部考试APP',
|
||||
marketTag: '长程 APP',
|
||||
workerType: 'adw',
|
||||
tier: 'business',
|
||||
color: '#29b36a',
|
||||
summary: '围绕练习、自测、正式考试、记录、证书和岗位应学组织的内部考试 APP。',
|
||||
description: '内部考试 APP 用来承接训练、练习、考试、成绩记录和证书结果,适合需要完整过程管理的学习场景。',
|
||||
prompts: ['我想组织一轮内部考试', '帮我看看这个考试流程怎么搭', '这个场景适合放到内部考试 APP 吗'],
|
||||
tags: ['训练流程', '考试记录', '结果沉淀'],
|
||||
},
|
||||
{
|
||||
key: 'internal-training',
|
||||
display_code: '应03',
|
||||
eailogic_code: 'EAI-A-03',
|
||||
label: '内部培训APP',
|
||||
marketTag: '长程 APP',
|
||||
workerType: 'worker',
|
||||
tier: 'business',
|
||||
color: '#9b6bff',
|
||||
summary: '统一承接销售培训、产品知识和公司介绍三个培训模块的内部培训 APP。',
|
||||
description: '内部培训 APP 用来把销售培训、产品知识和公司介绍收在同一个对象下面,避免把同一条培训链拆成多个 APP。',
|
||||
prompts: ['我想搭一个内部培训 APP', '帮我梳理销售培训、产品知识和公司介绍的关系', '这个培训主题应该挂到内部培训 APP 吗'],
|
||||
tags: ['课程组织', '产品知识', '组织导览'],
|
||||
},
|
||||
].concat(productizedApps.map((item) => ({
|
||||
key: item.key,
|
||||
display_code: item.display_code,
|
||||
eailogic_code: item.eailogic_code,
|
||||
label: item.label,
|
||||
marketTag: item.badge,
|
||||
workerType: item.skillKey ? 'worker' : 'adw',
|
||||
tier: 'business',
|
||||
color: item.color,
|
||||
summary: item.summary,
|
||||
description: item.description,
|
||||
prompts: item.prompts,
|
||||
tags: item.tags,
|
||||
})))
|
||||
|
||||
const resolvedCatalogType = computed(() => String(props.catalogType || 'specialists'))
|
||||
const resolvedObjectKey = computed(() => decodeURIComponent(String(props.objectKey || '')))
|
||||
|
||||
function withFallbackCodes(items, { displayPrefix, logicPrefix, start = 1 }) {
|
||||
return items.map((item, index) => {
|
||||
const sequence = String(start + index).padStart(2, '0')
|
||||
return {
|
||||
...item,
|
||||
display_code: item.display_code || `${displayPrefix}${sequence}`,
|
||||
eailogic_code: item.eailogic_code || `EAI-${logicPrefix}-${sequence}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const detail = computed(() => {
|
||||
if (resolvedCatalogType.value === 'skills') {
|
||||
const skill = availableSkills.find((item) => item.key === resolvedObjectKey.value)
|
||||
if (!skill) return notFoundDetail('技能')
|
||||
return {
|
||||
title: skill.label,
|
||||
display_code: skill.display_code,
|
||||
eailogic_code: skill.eailogic_code,
|
||||
subtitle: skill.roleCard?.relationshipToUser || '你的技能助手',
|
||||
metaText: '已安装 · 在对话里直接挂载',
|
||||
badge: '技能',
|
||||
avatar: skill.label.slice(0, 1),
|
||||
color: skill.color || '#409eff',
|
||||
description:
|
||||
skill.roleCard?.greeting ||
|
||||
`这个技能主要负责 ${skill.caption || skill.label},适合你已经知道目标、希望直接开工的时候使用。`,
|
||||
tags: normalizeArray(skill.capabilityTags).length
|
||||
? normalizeArray(skill.capabilityTags)
|
||||
: buildTags(skill.caption, skill.roleCard?.tagline),
|
||||
sceneTitle: '适合什么时候用',
|
||||
sceneText: buildSkillUseCase(skill),
|
||||
extraSections: buildSkillExtraSections(skill),
|
||||
promptTitle: '你可以直接这样发起',
|
||||
prompts: normalizeArray(skill.roleCard?.starterPrompts),
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedCatalogType.value === 'apps') {
|
||||
const app = catalogApps.find((item) => item.key === resolvedObjectKey.value)
|
||||
if (!app) return notFoundDetail('APP')
|
||||
return {
|
||||
title: app.label,
|
||||
display_code: app.display_code,
|
||||
eailogic_code: app.eailogic_code,
|
||||
subtitle: app.workerType === 'adw' ? '更偏向组织流程与协同推进' : '更偏向直接执行具体内容',
|
||||
metaText: `${app.marketTag} · ${app.workerType === 'adw' ? '流程推进型' : '执行处理型'}`,
|
||||
badge: 'APP',
|
||||
avatar: app.label.slice(0, 1),
|
||||
color: app.color || '#7c8cff',
|
||||
description: app.description,
|
||||
tags: normalizeArray(app.tags),
|
||||
sceneTitle: '适合什么时候用',
|
||||
sceneText: app.summary,
|
||||
promptTitle: 'APP 帮你做',
|
||||
prompts: normalizeArray(app.prompts),
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedCatalogType.value === 'connectors') {
|
||||
const connector = withFallbackCodes(remoteConnectors.value, { displayPrefix: '连', logicPrefix: 'C' })
|
||||
.find((item) => item.key === resolvedObjectKey.value)
|
||||
if (!connector) return notFoundDetail('连接器')
|
||||
return {
|
||||
title: connector.label || connector.key,
|
||||
display_code: connector.display_code,
|
||||
eailogic_code: connector.eailogic_code,
|
||||
subtitle: connector.mode === 'live' ? '已经接入真实实例' : '当前还是目录项',
|
||||
metaText: `${connector.mode === 'live' ? '已接入' : '待接入'} · ${connectorKind(connector.direction)}`,
|
||||
badge: '连接器',
|
||||
avatar: (connector.label || connector.key || '连').slice(0, 1),
|
||||
color: connector.mode === 'live' ? '#67c23a' : '#909399',
|
||||
description:
|
||||
connector.mode === 'live'
|
||||
? '这个连接器已经接入真实系统,可以把外部对象或数据源带进平台里使用。'
|
||||
: '这个连接器现在还是目录项,用来说明未来可以接入什么系统、访问哪些对象。',
|
||||
tags: normalizeArray(connector.objects).slice(0, 6),
|
||||
sceneTitle: '适合什么时候看',
|
||||
sceneText:
|
||||
connector.mode === 'live'
|
||||
? '当你关心它能访问哪些对象、数据往哪个方向流,以及是否已经能被任务链路调用时,就适合来看这个连接器。'
|
||||
: '当你在做目录规划或接入设计,想确认未来可连接什么系统时,就适合来看这个连接器。',
|
||||
promptTitle: '你可以这样查看',
|
||||
extraSections: [],
|
||||
prompts: normalizeArray([
|
||||
`这个连接器现在能访问哪些对象`,
|
||||
`它适合连接什么系统`,
|
||||
`它现在是已接入还是待接入`,
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
const specialist = businessApps.find((item) => item.key === resolvedObjectKey.value)
|
||||
if (!specialist) return notFoundDetail('专员')
|
||||
return {
|
||||
title: specialist.label,
|
||||
display_code: specialist.display_code,
|
||||
eailogic_code: specialist.eailogic_code,
|
||||
subtitle: specialist.roleCard?.relationshipToUser || '你的协作搭档',
|
||||
metaText: `${specialist.tier === 'industry' ? '行业专员' : '通用专员'} · ${specialist.workerType === 'adw' ? '流程推进型' : '执行处理型'} · ${specialist.marketTag || '可用'}`,
|
||||
badge: '专员',
|
||||
avatar: specialist.label.slice(0, 1),
|
||||
color: specialist.color || '#409eff',
|
||||
description:
|
||||
specialist.roleCard?.greeting ||
|
||||
`这个专员主要负责 ${specialist.roleCard?.tagline || specialist.label},可以陪你把事情往前推进。`,
|
||||
tags: buildTags(specialist.roleCard?.tagline, specialist.summary),
|
||||
sceneTitle: '适合什么时候用',
|
||||
sceneText: buildSpecialistUseCase(specialist),
|
||||
extraSections: buildSpecialistExtraSections(remoteSpecialist.value),
|
||||
promptTitle: '专员帮你做',
|
||||
prompts: normalizeArray(specialist.roleCard?.starterPrompts),
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (resolvedCatalogType.value === 'connectors') {
|
||||
try {
|
||||
const res = await listConnectors()
|
||||
remoteConnectors.value = Array.isArray(res?.data) ? res.data : []
|
||||
} catch (error) {
|
||||
console.warn('load connector detail failed', error)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (resolvedCatalogType.value === 'specialists') {
|
||||
const key = resolvedObjectKey.value.trim()
|
||||
if (!key) return
|
||||
try {
|
||||
const res = await getSpecialistByKey(key)
|
||||
remoteSpecialist.value = res?.data || null
|
||||
} catch (error) {
|
||||
console.warn('load specialist detail failed', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function connectorKind(direction) {
|
||||
if (direction === 'output') return '输出连接器'
|
||||
if (direction === 'input') return '输入连接器'
|
||||
return '双向连接器'
|
||||
}
|
||||
|
||||
function notFoundDetail(typeLabel) {
|
||||
return {
|
||||
title: `${typeLabel}不存在`,
|
||||
subtitle: '',
|
||||
metaText: '',
|
||||
badge: '未找到',
|
||||
avatar: '?',
|
||||
color: '#909399',
|
||||
description: `当前目录中不存在 key = ${resolvedObjectKey.value} 的${typeLabel}对象。`,
|
||||
tags: [],
|
||||
sceneTitle: '',
|
||||
sceneText: '',
|
||||
extraSections: [],
|
||||
promptTitle: '',
|
||||
prompts: [],
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeArray(values) {
|
||||
return (Array.isArray(values) ? values : [])
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function buildTags(...sources) {
|
||||
const collected = sources
|
||||
.flatMap((source) => splitKeywords(source))
|
||||
.filter(Boolean)
|
||||
|
||||
return [...new Set(collected)].slice(0, 6)
|
||||
}
|
||||
|
||||
function splitKeywords(value) {
|
||||
return String(value || '')
|
||||
.split(/[\/、,,和与·]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item && item.length <= 12)
|
||||
}
|
||||
|
||||
function buildSpecialistUseCase(specialist) {
|
||||
const openingPrompt = String(specialist.roleCard?.openingPrompt || '').trim()
|
||||
if (openingPrompt) {
|
||||
return `当你已经有目标、材料,或者已经知道当前卡点时,就适合找它介入。${openingPrompt}`
|
||||
}
|
||||
return '当你想找一个专门的对象来协助推进、整理和交付时,就适合让它介入。'
|
||||
}
|
||||
|
||||
function buildSkillUseCase(skill) {
|
||||
const openingPrompt = String(skill.roleCard?.openingPrompt || '').trim()
|
||||
const caption = String(skill.caption || skill.label || '').trim()
|
||||
if (openingPrompt) {
|
||||
return `当你已经明确想做“${caption}”,并且希望它直接开工、尽快给出产物时,就适合挂载这个技能。${openingPrompt}`
|
||||
}
|
||||
return `当你希望围绕“${caption}”直接进入执行,而不是先做大量讨论时,就适合挂载这个技能。`
|
||||
}
|
||||
|
||||
// buildSpecialistExtraSections 组装专员详情页的补充段落。
|
||||
// 数据全部来自后端 specialist 记录(技能绑定 / 信源 / 权限),
|
||||
// 拉不到就返回空数组 —— 与改动前表现一致,不会因为接口失败而露出半个页面。
|
||||
function buildSpecialistExtraSections(remote) {
|
||||
if (!remote) return []
|
||||
const sections = []
|
||||
|
||||
// 它会调用的技能:key 是后端绑定,label 从前端技能目录取。
|
||||
// 取不到 label 就直接显示 key —— 宁可露出一个生 key,也不要静默少一项,
|
||||
// 少一项会让「专员为什么没做这件事」变得无法解释。
|
||||
const skillKeys = parseAllowedSkills(remote.allowed_skills)
|
||||
if (skillKeys.length) {
|
||||
sections.push({
|
||||
title: '它会调用的技能',
|
||||
items: skillKeys.map((key) => {
|
||||
const label = availableSkills.find((item) => item.key === key)?.label
|
||||
return label ? `${label}(${key})` : key
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// 信源与权限在库里是「、」分隔的整串
|
||||
const infoSources = splitChineseList(remote.info_sources)
|
||||
if (infoSources.length) {
|
||||
sections.push({ title: '它读哪些信源', items: infoSources })
|
||||
}
|
||||
const permissions = splitChineseList(remote.permission_scope)
|
||||
if (permissions.length) {
|
||||
sections.push({ title: '它可以动什么', items: permissions })
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
function parseAllowedSkills(raw) {
|
||||
if (Array.isArray(raw)) return raw.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
const text = String(raw || '').trim()
|
||||
if (!text) return []
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
return Array.isArray(parsed)
|
||||
? parsed.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: []
|
||||
} catch {
|
||||
// 后端存的是 JSON 数组;解析不了说明数据坏了,按「没绑定」处理,
|
||||
// 不要在详情页抛错。
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function splitChineseList(raw) {
|
||||
return String(raw || '')
|
||||
.split(/[、,,\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function buildSkillExtraSections(skill) {
|
||||
const workflow = normalizeArray((skill.workflowSchema || []).map((item) => item.title))
|
||||
const outputs = normalizeArray((skill.artifactSchema || []).map((item) => {
|
||||
const title = String(item.title || '').trim()
|
||||
const fileFormat = String(item.fileFormat || '').trim()
|
||||
return fileFormat ? `${title}(.${fileFormat})` : title
|
||||
}))
|
||||
const sections = []
|
||||
if (workflow.length) sections.push({ title: '它会这样推进', items: workflow })
|
||||
if (outputs.length) sections.push({ title: '它会优先产出', items: outputs })
|
||||
return sections
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.catalog-detail-page {
|
||||
padding: 11px;
|
||||
}
|
||||
|
||||
.detail-toolbar {
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #2b63d9;
|
||||
border-radius: 999px;
|
||||
padding: 6px 11px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-modal-card {
|
||||
padding: 15px;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding-bottom: 11px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.detail-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: none;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--accent) 14%, white);
|
||||
color: var(--accent);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-header-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.detail-subtitle {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: #4f5d6b;
|
||||
}
|
||||
|
||||
.detail-code-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.detail-code-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: #f3f6fb;
|
||||
color: #344054;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-code-chip.subtle {
|
||||
background: #f7f8fa;
|
||||
color: #667085;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-meta-text {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: #8a96a3;
|
||||
}
|
||||
|
||||
.detail-badge {
|
||||
flex: none;
|
||||
border-radius: 999px;
|
||||
padding: 4px 9px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, white);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.detail-description {
|
||||
margin: 11px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: #3f4b57;
|
||||
}
|
||||
|
||||
.detail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.detail-tag {
|
||||
border-radius: 999px;
|
||||
padding: 4px 9px;
|
||||
border: 1px solid #e7ecf5;
|
||||
background: transparent;
|
||||
color: #58677a;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.detail-section-text {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: #4f5d6b;
|
||||
}
|
||||
|
||||
.detail-prompt-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.detail-bullet-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.detail-bullet-item {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #4f5d6b;
|
||||
}
|
||||
|
||||
.detail-bullet-item::before {
|
||||
content: '· ';
|
||||
color: #8a96a3;
|
||||
}
|
||||
|
||||
.detail-prompt-button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid #e7ecf5;
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
color: #2f3a45;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.detail-prompt-button:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 28%, #d9e3ef);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.catalog-detail-page {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail-modal-card {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user