mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ui): enterprise scenario workbench combining contract review, account intel, approvals, and audit
This commit is contained in:
parent
8d01efbbb1
commit
093925e191
1
.gitignore
vendored
1
.gitignore
vendored
@ -98,6 +98,7 @@ CLAUDE.md
|
||||
|
||||
# Codex CLI local artifacts
|
||||
.codex/
|
||||
.superpowers/
|
||||
|
||||
# Sync tooling local state (generated each run; report is intentionally tracked)
|
||||
scripts/.*-sync-state.json
|
||||
|
||||
@ -360,6 +360,7 @@ export default {
|
||||
agentContext: 'Agent Context',
|
||||
skills: 'Skills',
|
||||
wiki: 'Wiki KB',
|
||||
enterprise: 'Enterprise',
|
||||
tools: 'Tools',
|
||||
plugins: 'Plugins',
|
||||
core: 'Core',
|
||||
@ -2412,6 +2413,73 @@ export default {
|
||||
hint: 'Default: <code>admin</code> / <code>admin123</code>',
|
||||
failed: 'Login failed. Please check your credentials.',
|
||||
},
|
||||
enterprise: {
|
||||
eyebrow: 'Enterprise',
|
||||
title: 'Enterprise AI Workbench',
|
||||
subtitle: 'Verifiable judgment with cited evidence, owned approvals, and a complete audit trail — AI inside enterprise workflows, not yet another chatbox.',
|
||||
tabs: { overview: 'Overview', contract: 'Contract Review', account: 'Account Intel', approvals: 'Approvals', audit: 'Audit' },
|
||||
risk: { high: 'High', medium: 'Medium', low: 'Low' },
|
||||
status: { aiReviewed: 'AI Reviewed', pendingLegal: 'Legal Pending', approved: 'Approved', signal: 'Signal' },
|
||||
overview: {
|
||||
metricPending: 'Pending', metricHighRisk: 'High Risk', metricAccounts: 'Accounts', metricCited: 'Cited',
|
||||
queueTitle: 'Work Queue',
|
||||
queueDesc: 'Today\'s items for legal / sales / compliance, each carrying an AI judgment and source citation.',
|
||||
evidenceTitle: 'Recent Evidence',
|
||||
evidenceDesc: 'Source quotes, playbook clauses, and historical precedents the AI cited.',
|
||||
coverageNote: 'of this week\'s judgments link to a verifiable source',
|
||||
tagSource: 'source', tagPlaybook: 'playbook', tagPrecedent: 'precedent',
|
||||
filterAll: 'All', filterHighRisk: 'High Risk', filterPending: 'Legal Pending', filterToday: 'Today',
|
||||
pipelineTitle: 'Contract Review Pipeline',
|
||||
pipelineDesc: 'Standard flow from upload to archive — every step leaves a trace.',
|
||||
step1: 'Upload', step1Desc: 'Legal or sales drops a PDF',
|
||||
step2: 'Extract', step2Desc: 'OCR / text extraction',
|
||||
step3: 'AI Review', step3Desc: 'Clause-level risk + playbook diff',
|
||||
step4: 'Legal Review', step4Desc: 'Human in the loop',
|
||||
step5: 'Approve / Archive', step5Desc: 'Approval chain + audit',
|
||||
},
|
||||
contract: {
|
||||
listTitle: 'Contracts', filterAll: 'All', filterHigh: 'High', filterPending: 'Pending',
|
||||
clausesFound: 'clause deviations', deviation: 'Deviation', suggestion: 'AI suggested rewrite',
|
||||
approvalChain: 'Approval Chain',
|
||||
btnApprove: 'Approve', btnReject: 'Reject', btnRequestRevision: 'Request revision',
|
||||
evidenceTitle: 'Evidence', sourceContract: 'Source contract', playbookRule: 'Playbook rule', precedents: 'Precedents',
|
||||
confidence: 'Confidence', model: 'Model',
|
||||
},
|
||||
account: {
|
||||
listTitle: 'Accounts', filterAll: 'All', filterHot: 'Hot', filterCooling: 'Cooling',
|
||||
opportunityScore: 'Opportunity Score', aum: 'Size',
|
||||
keyPeople: 'Key People',
|
||||
timelineTitle: 'Account Timeline',
|
||||
tlAll: 'All', tlPublic: 'Public', tlMeeting: 'Meetings', tlDoc: 'Docs',
|
||||
evidence: 'Evidence',
|
||||
actionsTitle: 'Recommended Actions',
|
||||
actionsSub: 'Next steps inferred from timeline signals, ranked by priority.',
|
||||
btnDoIt: 'Do it', btnSnooze: 'Snooze',
|
||||
stage: { prospect: 'Prospect', qualified: 'Qualified', proposal: 'Proposal', negotiation: 'Negotiation', won: 'Won' },
|
||||
role: { decider: 'Decider', influencer: 'Influencer', user: 'User', procurement: 'Procurement' },
|
||||
eventKind: { news: 'News', hiring: 'Hiring', funding: 'Funding', product: 'Product', meeting: 'Meeting', doc: 'Doc', competitor: 'Competitor' },
|
||||
priority: { high: 'High', medium: 'Medium', low: 'Low' },
|
||||
},
|
||||
approvals: {
|
||||
queueTitle: 'My Approval Queue',
|
||||
queueDesc: 'Items waiting on your sign-off. AI judgments are ready; your call.',
|
||||
filterMine: 'Mine', filterAll: 'All', filterHigh: 'High Risk',
|
||||
requester: 'Requested by', target: 'Target', reason: 'Reason', evidence: 'Evidence',
|
||||
btnApprove: 'Approve', btnReject: 'Reject', btnDelegate: 'Delegate',
|
||||
kind: { contract: 'Contract', tool: 'Tool', transformation: 'Transform', access: 'Access' },
|
||||
slaTitle: 'SLA Monitor',
|
||||
slaDesc: 'Response time and auto-approval rate — granular data exposes approval bottlenecks.',
|
||||
slaAvg: 'Avg response', slaWithin: 'Within SLA', slaOverdue: 'Overdue', slaAutoApproved: 'Auto-approved',
|
||||
slaAutoNote: 'Matched allowlist rule',
|
||||
},
|
||||
audit: {
|
||||
title: 'Audit Trail',
|
||||
desc: 'Every AI judgment, human action, and tool call carries a timestamp and a piece of evidence. This is the minimum bar for AI in regulated workflows.',
|
||||
scopeAll: 'All events', scopeContract: 'Contract', scopeAccount: 'Account', scopeTool: 'AI / Tools',
|
||||
evidence: 'Linked evidence',
|
||||
kind: { review: 'AI Review', approve: 'Approve', reject: 'Reject', tool: 'Tool call', access: 'Access', modify: 'Modify', agent: 'Agent action' },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
title: 'Channels',
|
||||
desc: 'Connect your agents to messaging platforms and APIs',
|
||||
|
||||
@ -363,6 +363,7 @@ export default {
|
||||
agentContext: '智能体上下文',
|
||||
skills: '技能',
|
||||
wiki: 'Wiki 知识库',
|
||||
enterprise: '企业场景',
|
||||
tools: '工具',
|
||||
plugins: '插件',
|
||||
datasources: '数据源',
|
||||
@ -2424,6 +2425,161 @@ export default {
|
||||
hint: '默认账号: <code>admin</code> / <code>admin123</code>',
|
||||
failed: '登录失败,请检查账号密码',
|
||||
},
|
||||
enterprise: {
|
||||
eyebrow: '企业场景',
|
||||
title: '企业 AI 工作台',
|
||||
subtitle: '把 AI 判断、证据引用、审批责任一体化 —— 让 AI 进入企业流程,而不是又一个聊天框。',
|
||||
tabs: {
|
||||
overview: '总览',
|
||||
contract: '合同审查',
|
||||
account: '客户情报',
|
||||
approvals: '审批',
|
||||
audit: '审计',
|
||||
},
|
||||
risk: { high: '高风险', medium: '中等', low: '低' },
|
||||
status: {
|
||||
aiReviewed: 'AI 已审',
|
||||
pendingLegal: '法务待审',
|
||||
approved: '已批准',
|
||||
signal: '情报信号',
|
||||
},
|
||||
overview: {
|
||||
metricPending: '待处理',
|
||||
metricHighRisk: '高风险',
|
||||
metricAccounts: '关注客户',
|
||||
metricCited: '引用覆盖',
|
||||
queueTitle: '工作队列',
|
||||
queueDesc: '今天需要法务、销售、合规处理的事项;每条都附带 AI 判断和证据出处',
|
||||
evidenceTitle: '近期证据',
|
||||
evidenceDesc: 'AI 判断引用的原文、Playbook 条款、历史先例',
|
||||
coverageNote: '本周 AI 判断的引用覆盖率',
|
||||
tagSource: '原文',
|
||||
tagPlaybook: 'Playbook',
|
||||
tagPrecedent: '先例',
|
||||
filterAll: '全部',
|
||||
filterHighRisk: '高风险',
|
||||
filterPending: '待法务',
|
||||
filterToday: '今天',
|
||||
pipelineTitle: '合同审查流水',
|
||||
pipelineDesc: '从上传到归档的标准流程,每一步都留痕',
|
||||
step1: '上传',
|
||||
step1Desc: '法务或销售拖拽合同 PDF',
|
||||
step2: '提取',
|
||||
step2Desc: 'OCR / 文本提取',
|
||||
step3: 'AI 审查',
|
||||
step3Desc: '条款级风险与 Playbook 偏离',
|
||||
step4: '法务复核',
|
||||
step4Desc: '人工最终把关',
|
||||
step5: '审批 / 归档',
|
||||
step5Desc: '审批链 + 审计留痕',
|
||||
},
|
||||
contract: {
|
||||
listTitle: '合同列表',
|
||||
filterAll: '全部',
|
||||
filterHigh: '高风险',
|
||||
filterPending: '待审',
|
||||
clausesFound: '条款偏离',
|
||||
deviation: '偏离',
|
||||
suggestion: 'AI 建议改写',
|
||||
approvalChain: '审批链',
|
||||
btnApprove: '批准',
|
||||
btnReject: '驳回',
|
||||
btnRequestRevision: '要求修订',
|
||||
evidenceTitle: '证据',
|
||||
sourceContract: '原合同',
|
||||
playbookRule: 'Playbook 条款',
|
||||
precedents: '历史先例',
|
||||
confidence: '置信度',
|
||||
model: '模型',
|
||||
},
|
||||
account: {
|
||||
listTitle: '客户列表',
|
||||
filterAll: '全部',
|
||||
filterHot: '高分',
|
||||
filterCooling: '降温',
|
||||
opportunityScore: '机会分',
|
||||
aum: '体量',
|
||||
keyPeople: '关键人',
|
||||
timelineTitle: '账户时间线',
|
||||
tlAll: '全部',
|
||||
tlPublic: '公开信号',
|
||||
tlMeeting: '会议',
|
||||
tlDoc: '内部资料',
|
||||
evidence: '证据',
|
||||
actionsTitle: '推荐动作',
|
||||
actionsSub: 'AI 根据时间线信号生成的下一步建议;按优先级排序',
|
||||
btnDoIt: '去执行',
|
||||
btnSnooze: '稍后',
|
||||
stage: {
|
||||
prospect: '潜在',
|
||||
qualified: '确认意向',
|
||||
proposal: '方案中',
|
||||
negotiation: '谈判中',
|
||||
won: '已成交',
|
||||
},
|
||||
role: {
|
||||
decider: '决策者',
|
||||
influencer: '影响者',
|
||||
user: '使用者',
|
||||
procurement: '采购',
|
||||
},
|
||||
eventKind: {
|
||||
news: '新闻',
|
||||
hiring: '招聘',
|
||||
funding: '融资',
|
||||
product: '产品',
|
||||
meeting: '会议',
|
||||
doc: '资料',
|
||||
competitor: '竞品',
|
||||
},
|
||||
priority: { high: '高优', medium: '中优', low: '低优' },
|
||||
},
|
||||
approvals: {
|
||||
queueTitle: '我的审批队列',
|
||||
queueDesc: '需要你点头才能继续的事项 —— AI 判断已就绪,等你把关',
|
||||
filterMine: '我的',
|
||||
filterAll: '全部',
|
||||
filterHigh: '高风险',
|
||||
requester: '发起人',
|
||||
target: '对象',
|
||||
reason: '原因',
|
||||
evidence: '证据',
|
||||
btnApprove: '批准',
|
||||
btnReject: '驳回',
|
||||
btnDelegate: '转交',
|
||||
kind: {
|
||||
contract: '合同',
|
||||
tool: '工具',
|
||||
transformation: '加工器',
|
||||
access: '权限',
|
||||
},
|
||||
slaTitle: 'SLA 监控',
|
||||
slaDesc: '审批响应时间与自动批准比例 —— 数据越细,越容易发现"卡审批"的瓶颈',
|
||||
slaAvg: '平均响应',
|
||||
slaWithin: '按时完成',
|
||||
slaOverdue: '逾期',
|
||||
slaAutoApproved: '已自动批准',
|
||||
slaAutoNote: '满足白名单规则',
|
||||
},
|
||||
audit: {
|
||||
title: '审计追溯',
|
||||
desc: '每一次 AI 判断、人工操作、工具调用都有时间戳和证据。这是企业部署 AI 的最低门槛。',
|
||||
scopeAll: '全部事件',
|
||||
scopeContract: '合同相关',
|
||||
scopeAccount: '客户相关',
|
||||
scopeTool: 'AI / 工具',
|
||||
evidence: '关联证据',
|
||||
kind: {
|
||||
review: 'AI 审查',
|
||||
approve: '批准',
|
||||
reject: '驳回',
|
||||
tool: '工具调用',
|
||||
access: '权限',
|
||||
modify: '修改',
|
||||
agent: 'Agent 行为',
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
title: '渠道管理',
|
||||
desc: '将数字员工连接到各消息平台和 API',
|
||||
|
||||
@ -39,6 +39,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/Wiki/index.vue'),
|
||||
meta: { title: 'Wiki' },
|
||||
},
|
||||
{
|
||||
path: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
component: () => import('@/views/Enterprise/index.vue'),
|
||||
meta: { title: 'Enterprise Scenarios' },
|
||||
},
|
||||
{
|
||||
path: 'memory',
|
||||
name: 'Memory',
|
||||
|
||||
502
mateclaw-ui/src/views/Enterprise/AccountIntel.vue
Normal file
502
mateclaw-ui/src/views/Enterprise/AccountIntel.vue
Normal file
@ -0,0 +1,502 @@
|
||||
<template>
|
||||
<div class="account-shell">
|
||||
<aside class="account-list mc-surface-card">
|
||||
<header class="list-head">
|
||||
<h3 class="list-title">{{ t('enterprise.account.listTitle') }}</h3>
|
||||
<div class="list-filters">
|
||||
<button v-for="f in listFilters" :key="f.key"
|
||||
class="chip" :class="{ active: listFilter === f.key }"
|
||||
@click="listFilter = f.key">
|
||||
{{ f.label }}
|
||||
<span v-if="f.count != null" class="chip-count">{{ f.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ul class="account-cards">
|
||||
<li v-for="a in filteredAccounts" :key="a.id"
|
||||
class="account-card" :class="{ active: a.id === selectedId }"
|
||||
@click="selectedId = a.id">
|
||||
<div class="account-logo" :style="{ background: a.color }">{{ a.short }}</div>
|
||||
<div class="account-body">
|
||||
<div class="account-name">{{ a.name }}</div>
|
||||
<div class="account-meta">{{ a.industry }} · {{ a.region }}</div>
|
||||
<div class="account-score-row">
|
||||
<div class="score-bar"><div class="score-fill" :style="{ width: a.score + '%' }"></div></div>
|
||||
<span class="score-text">{{ a.score }}</span>
|
||||
</div>
|
||||
<div class="account-stage">{{ stageLabel(a.stage) }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<section class="account-center mc-surface-card">
|
||||
<header class="center-head">
|
||||
<div class="center-headline">
|
||||
<h2 class="center-title">{{ selectedAccount.name }}</h2>
|
||||
<div class="center-sub">
|
||||
<span>{{ selectedAccount.industry }}</span>
|
||||
<span class="dot"></span>
|
||||
<span>{{ selectedAccount.region }}</span>
|
||||
<span class="dot"></span>
|
||||
<span>{{ stageLabel(selectedAccount.stage) }}</span>
|
||||
<span class="dot"></span>
|
||||
<span>{{ t('enterprise.account.aum') }} {{ selectedAccount.aum }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-pill" :class="scoreToneFor(selectedAccount.score)">
|
||||
<span class="score-pill-label">{{ t('enterprise.account.opportunityScore') }}</span>
|
||||
<span class="score-pill-value">{{ selectedAccount.score }}</span>
|
||||
<span class="score-pill-delta">{{ selectedAccount.scoreDelta }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="people-strip">
|
||||
<div class="people-label">{{ t('enterprise.account.keyPeople') }}</div>
|
||||
<div class="people-row">
|
||||
<div v-for="p in selectedAccount.people" :key="p.name" class="person">
|
||||
<div class="person-avatar">{{ p.short }}</div>
|
||||
<div class="person-body">
|
||||
<div class="person-name">{{ p.name }}</div>
|
||||
<div class="person-role">{{ p.role }}</div>
|
||||
</div>
|
||||
<span class="person-tag" :class="`tag-${p.kind}`">{{ kindLabel(p.kind) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-wrap">
|
||||
<div class="timeline-head">
|
||||
<div class="timeline-title">{{ t('enterprise.account.timelineTitle') }}</div>
|
||||
<div class="timeline-tabs">
|
||||
<button v-for="f in tlFilters" :key="f.key"
|
||||
class="chip" :class="{ active: tlFilter === f.key }"
|
||||
@click="tlFilter = f.key">{{ f.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="timeline">
|
||||
<li v-for="ev in filteredTimeline" :key="ev.id" class="tl-item">
|
||||
<div class="tl-bullet" :class="`tl-${ev.kind}`"></div>
|
||||
<div class="tl-body">
|
||||
<div class="tl-row">
|
||||
<span class="tl-kind" :class="`kind-${ev.kind}`">{{ kindLabelEvent(ev.kind) }}</span>
|
||||
<span class="tl-time">{{ ev.at }}</span>
|
||||
</div>
|
||||
<div class="tl-summary">{{ ev.summary }}</div>
|
||||
<div class="tl-evidence">
|
||||
<span class="ev-label">{{ t('enterprise.account.evidence') }}</span>
|
||||
<span>{{ ev.source }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="account-actions mc-surface-card">
|
||||
<header class="actions-head">
|
||||
<h3 class="list-title">{{ t('enterprise.account.actionsTitle') }}</h3>
|
||||
<p class="actions-sub">{{ t('enterprise.account.actionsSub') }}</p>
|
||||
</header>
|
||||
|
||||
<ul class="action-list">
|
||||
<li v-for="(a, i) in selectedAccount.actions" :key="i" class="action-item" :class="`priority-${a.priority}`">
|
||||
<div class="action-head">
|
||||
<span class="action-priority" :class="`priority-${a.priority}`">{{ priorityLabel(a.priority) }}</span>
|
||||
<span class="action-when">{{ a.when }}</span>
|
||||
</div>
|
||||
<div class="action-title">{{ a.title }}</div>
|
||||
<div class="action-rationale">{{ a.rationale }}</div>
|
||||
<div class="action-foot">
|
||||
<button class="btn-action">{{ t('enterprise.account.btnDoIt') }}</button>
|
||||
<button class="btn-action btn-action--ghost">{{ t('enterprise.account.btnSnooze') }}</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const listFilter = ref<'all' | 'hot' | 'cooling'>('all')
|
||||
const tlFilter = ref<'all' | 'public' | 'meeting' | 'doc'>('all')
|
||||
|
||||
const listFilters = computed(() => [
|
||||
{ key: 'all', label: t('enterprise.account.filterAll'), count: 14 },
|
||||
{ key: 'hot', label: t('enterprise.account.filterHot'), count: 4 },
|
||||
{ key: 'cooling', label: t('enterprise.account.filterCooling'), count: 3 },
|
||||
])
|
||||
|
||||
const tlFilters = computed(() => [
|
||||
{ key: 'all', label: t('enterprise.account.tlAll') },
|
||||
{ key: 'public', label: t('enterprise.account.tlPublic') },
|
||||
{ key: 'meeting', label: t('enterprise.account.tlMeeting') },
|
||||
{ key: 'doc', label: t('enterprise.account.tlDoc') },
|
||||
])
|
||||
|
||||
interface Person { name: string; role: string; kind: 'decider' | 'influencer' | 'user' | 'procurement'; short: string }
|
||||
interface Event { id: string; kind: 'news' | 'hiring' | 'funding' | 'product' | 'meeting' | 'doc' | 'competitor'; summary: string; source: string; at: string }
|
||||
interface Action { priority: 'high' | 'medium' | 'low'; when: string; title: string; rationale: string }
|
||||
|
||||
interface Account {
|
||||
id: string
|
||||
name: string
|
||||
short: string
|
||||
industry: string
|
||||
region: string
|
||||
stage: 'prospect' | 'qualified' | 'proposal' | 'negotiation' | 'won'
|
||||
aum: string
|
||||
score: number
|
||||
scoreDelta: string
|
||||
color: string
|
||||
people: Person[]
|
||||
events: Event[]
|
||||
actions: Action[]
|
||||
}
|
||||
|
||||
const accounts: Account[] = [
|
||||
{
|
||||
id: 'acme',
|
||||
name: 'Acme Corp',
|
||||
short: 'AC',
|
||||
industry: '电商 SaaS',
|
||||
region: '北美 · SF',
|
||||
stage: 'negotiation',
|
||||
aum: '¥12M / 年',
|
||||
score: 82,
|
||||
scoreDelta: '+8 (本周)',
|
||||
color: 'linear-gradient(135deg, #d96d46, #ebb08f)',
|
||||
people: [
|
||||
{ name: '陈博士', role: 'CTO(新上任)', kind: 'decider', short: '陈' },
|
||||
{ name: 'Marcus Lee', role: 'VP Engineering', kind: 'influencer', short: 'M' },
|
||||
{ name: '张工', role: 'Senior Architect', kind: 'user', short: '张' },
|
||||
{ name: 'Janet Wu', role: 'Procurement', kind: 'procurement', short: 'J' },
|
||||
],
|
||||
events: [
|
||||
{ id: 'e1', kind: 'hiring', summary: 'Acme 在 LinkedIn 招聘 Privacy Counsel(4 个职位),暗示其加速合规建设', source: 'LinkedIn 信号 · 自动抓取', at: '6 小时前' },
|
||||
{ id: 'e2', kind: 'news', summary: 'CEO 在 Acme 季度会上发言:「2026 H1 我们要把基础设施全部上云」', source: '官方季报 · 第 4 页', at: '今天 10:30' },
|
||||
{ id: 'e3', kind: 'meeting', summary: '与陈博士第二轮电话会议:CTO 明确关心数据驻留 + 私有部署能力', source: '会议纪要 · 销售记录', at: '昨天 16:00' },
|
||||
{ id: 'e4', kind: 'competitor', summary: 'Acme 在采购系统中出现 Zerto 的 POC 记录,需高度警惕', source: '竞品信号 · Crunchbase', at: '前天' },
|
||||
{ id: 'e5', kind: 'doc', summary: '内部资料库上传了 Acme 2025 年技术架构白皮书,识别出 7 个潜在切入点', source: '客户库 · Acme.pdf', at: '前天' },
|
||||
{ id: 'e6', kind: 'funding', summary: 'Acme 完成 D 轮融资 $80M,准备扩张到欧洲', source: 'TechCrunch · 报道', at: '上周' },
|
||||
],
|
||||
actions: [
|
||||
{ priority: 'high', when: '今天', title: '把私有部署方案 PDF 发给陈博士', rationale: '陈博士在最近一次会议明确关心数据驻留;上次会议的 follow-up 还没发' },
|
||||
{ priority: 'high', when: '本周', title: '约 Marcus Lee 一次技术评审会', rationale: 'VP Eng 是关键影响者;Zerto POC 信号意味着我们时间窗口在缩短' },
|
||||
{ priority: 'medium', when: '本周', title: '把 Acme 案例对标到 BlueWave 上线案例', rationale: '同业(电商 SaaS)有类似业务量级,可作为 case study' },
|
||||
{ priority: 'low', when: '下周', title: '回应 Acme 季报中的"上云"信号,发布一篇 thought leadership', rationale: '内容营销渠道可放大该机会信号' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bluewave',
|
||||
name: 'BlueWave Tech',
|
||||
short: 'BW',
|
||||
industry: '金融科技',
|
||||
region: '香港',
|
||||
stage: 'won',
|
||||
aum: '¥8M / 年',
|
||||
score: 91,
|
||||
scoreDelta: '+2',
|
||||
color: 'linear-gradient(135deg, #184a45, #4a8c84)',
|
||||
people: [], events: [], actions: [],
|
||||
},
|
||||
{
|
||||
id: 'finchen',
|
||||
name: 'FinChen 金融',
|
||||
short: 'FC',
|
||||
industry: '银行业',
|
||||
region: '上海',
|
||||
stage: 'qualified',
|
||||
aum: '潜在 ¥20M',
|
||||
score: 64,
|
||||
scoreDelta: '-3',
|
||||
color: 'linear-gradient(135deg, #9b7d6c, #d9cec2)',
|
||||
people: [], events: [], actions: [],
|
||||
},
|
||||
{
|
||||
id: 'zerto-prospect',
|
||||
name: 'Vertex Logistics',
|
||||
short: 'VL',
|
||||
industry: '物流',
|
||||
region: '深圳',
|
||||
stage: 'prospect',
|
||||
aum: '潜在 ¥4M',
|
||||
score: 38,
|
||||
scoreDelta: '-12',
|
||||
color: 'linear-gradient(135deg, #cdbdad, #ebe3db)',
|
||||
people: [], events: [], actions: [],
|
||||
},
|
||||
]
|
||||
|
||||
const filteredAccounts = computed(() => {
|
||||
switch (listFilter.value) {
|
||||
case 'hot': return accounts.filter(a => a.score >= 70)
|
||||
case 'cooling': return accounts.filter(a => a.scoreDelta.startsWith('-'))
|
||||
default: return accounts
|
||||
}
|
||||
})
|
||||
|
||||
const selectedId = ref<string>(accounts[0].id)
|
||||
const selectedAccount = computed(() => accounts.find(a => a.id === selectedId.value) ?? accounts[0])
|
||||
|
||||
const filteredTimeline = computed(() => {
|
||||
const evs = selectedAccount.value.events
|
||||
if (tlFilter.value === 'all') return evs
|
||||
if (tlFilter.value === 'meeting') return evs.filter(e => e.kind === 'meeting')
|
||||
if (tlFilter.value === 'doc') return evs.filter(e => e.kind === 'doc')
|
||||
// 'public' = news/hiring/funding/product/competitor
|
||||
return evs.filter(e => ['news', 'hiring', 'funding', 'product', 'competitor'].includes(e.kind))
|
||||
})
|
||||
|
||||
function stageLabel(s: Account['stage']): string { return t(`enterprise.account.stage.${s}`) }
|
||||
function priorityLabel(p: 'high' | 'medium' | 'low'): string { return t(`enterprise.account.priority.${p}`) }
|
||||
function kindLabel(k: Person['kind']): string { return t(`enterprise.account.role.${k}`) }
|
||||
function kindLabelEvent(k: Event['kind']): string { return t(`enterprise.account.eventKind.${k}`) }
|
||||
|
||||
function scoreToneFor(score: number): string {
|
||||
if (score >= 75) return 'tone-good'
|
||||
if (score >= 50) return 'tone-warn'
|
||||
return 'tone-bad'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.account-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 340px;
|
||||
gap: 14px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.account-list, .account-center, .account-actions {
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.account-list { padding: 16px 0; }
|
||||
|
||||
/* === list pane === */
|
||||
.list-head { padding: 0 18px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.list-title { font-size: 14px; font-weight: 700; color: var(--mc-text-primary); margin: 0; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.list-filters { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.chip {
|
||||
border: 1px solid var(--mc-border-light);
|
||||
background: var(--mc-bg-elevated);
|
||||
color: var(--mc-text-secondary);
|
||||
font-size: 11px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.chip.active { background: var(--mc-primary-bg); color: var(--mc-primary-hover); border-color: var(--mc-primary); }
|
||||
.chip-count { opacity: 0.65; }
|
||||
|
||||
.account-cards { list-style: none; margin: 0; padding: 0 0 16px; overflow-y: auto; flex: 1; }
|
||||
.account-card {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.account-card:hover { background: var(--mc-bg-muted); }
|
||||
.account-card.active { background: var(--mc-primary-bg); border-left-color: var(--mc-primary); }
|
||||
.account-logo {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.account-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.account-name { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); }
|
||||
.account-meta { font-size: 11px; color: var(--mc-text-tertiary); }
|
||||
.account-score-row { display: flex; gap: 6px; align-items: center; margin-top: 2px; }
|
||||
.score-bar { flex: 1; height: 4px; background: var(--mc-bg-sunken); border-radius: 2px; overflow: hidden; }
|
||||
.score-fill { height: 100%; background: linear-gradient(90deg, var(--mc-primary), var(--mc-primary-light)); }
|
||||
.score-text { font-size: 11px; font-weight: 700; color: var(--mc-primary-hover); }
|
||||
.account-stage {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--mc-bg-muted);
|
||||
color: var(--mc-text-secondary);
|
||||
align-self: flex-start;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* === center pane === */
|
||||
.center-head { display: flex; gap: 12px; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; padding-bottom: 12px; border-bottom: 1px solid var(--mc-border-light); }
|
||||
.center-headline { display: flex; flex-direction: column; gap: 4px; }
|
||||
.center-title { font-size: 18px; font-weight: 700; color: var(--mc-text-primary); margin: 0; }
|
||||
.center-sub { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--mc-text-secondary); flex-wrap: wrap; }
|
||||
.dot { width: 3px; height: 3px; background: currentColor; border-radius: 50%; opacity: 0.5; }
|
||||
|
||||
.score-pill {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--mc-bg-muted);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
}
|
||||
.score-pill.tone-good { border-color: #84cc16; background: #f7fee7; }
|
||||
.score-pill.tone-warn { border-color: #fbbf24; background: #fffbeb; }
|
||||
.score-pill.tone-bad { border-color: #ef4444; background: #fef2f2; }
|
||||
.score-pill-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--mc-text-tertiary); }
|
||||
.score-pill-value { font-size: 22px; font-weight: 700; color: var(--mc-text-primary); }
|
||||
.score-pill-delta { font-size: 11px; font-weight: 600; color: #15803d; }
|
||||
.score-pill.tone-bad .score-pill-delta { color: #b91c1c; }
|
||||
|
||||
/* === people === */
|
||||
.people-strip { background: var(--mc-bg-muted); border-radius: 10px; padding: 10px 12px; }
|
||||
.people-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--mc-text-tertiary); margin-bottom: 8px; }
|
||||
.people-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; }
|
||||
.person {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
background: var(--mc-bg-elevated);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.person-avatar {
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--mc-primary-bg);
|
||||
color: var(--mc-primary-hover);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.person-body { flex: 1; min-width: 0; }
|
||||
.person-name { font-size: 12px; font-weight: 600; color: var(--mc-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.person-role { font-size: 11px; color: var(--mc-text-tertiary); }
|
||||
.person-tag { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 2px 6px; border-radius: 4px; white-space: nowrap; }
|
||||
.tag-decider { background: #fee2e2; color: #b91c1c; }
|
||||
.tag-influencer { background: #fef3c7; color: #b45309; }
|
||||
.tag-user { background: #dbeafe; color: #1e40af; }
|
||||
.tag-procurement { background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
|
||||
|
||||
/* === timeline === */
|
||||
.timeline-wrap { display: flex; flex-direction: column; flex: 1; min-height: 0; gap: 10px; }
|
||||
.timeline-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.timeline-title { font-size: 14px; font-weight: 700; color: var(--mc-text-primary); }
|
||||
.timeline-tabs { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
.timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
.tl-item { display: grid; grid-template-columns: 12px 1fr; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--mc-border-light); }
|
||||
.tl-item:last-child { border-bottom: none; }
|
||||
.tl-bullet {
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.tl-news { background: var(--mc-text-secondary); }
|
||||
.tl-hiring { background: #2563eb; }
|
||||
.tl-funding { background: #15803d; }
|
||||
.tl-product { background: var(--mc-accent); }
|
||||
.tl-meeting { background: var(--mc-primary); }
|
||||
.tl-doc { background: #f59e0b; }
|
||||
.tl-competitor { background: #b91c1c; }
|
||||
|
||||
.tl-body { display: flex; flex-direction: column; gap: 4px; }
|
||||
.tl-row { display: flex; gap: 8px; align-items: center; }
|
||||
.tl-kind {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.kind-news, .kind-competitor { background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
|
||||
.kind-competitor { background: #fee2e2; color: #b91c1c; }
|
||||
.kind-hiring { background: #dbeafe; color: #1e40af; }
|
||||
.kind-funding { background: #dcfce7; color: #15803d; }
|
||||
.kind-product { background: var(--mc-accent-soft); color: var(--mc-accent); }
|
||||
.kind-meeting { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
|
||||
.kind-doc { background: #fef3c7; color: #b45309; }
|
||||
|
||||
.tl-time { font-size: 11px; color: var(--mc-text-tertiary); font-family: var(--mc-font-mono); }
|
||||
.tl-summary { font-size: 13px; color: var(--mc-text-primary); line-height: 1.5; }
|
||||
.tl-evidence { font-size: 11px; color: var(--mc-text-tertiary); display: flex; gap: 6px; align-items: baseline; }
|
||||
.ev-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
/* === actions pane === */
|
||||
.actions-head { display: flex; flex-direction: column; gap: 4px; }
|
||||
.actions-sub { font-size: 11px; color: var(--mc-text-tertiary); margin: 0; line-height: 1.45; }
|
||||
.action-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; flex: 1; }
|
||||
.action-item {
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: var(--mc-bg-elevated);
|
||||
border-left-width: 4px;
|
||||
}
|
||||
.action-item.priority-high { border-left-color: #ef4444; }
|
||||
.action-item.priority-medium { border-left-color: #f59e0b; }
|
||||
.action-item.priority-low { border-left-color: var(--mc-border); }
|
||||
|
||||
.action-head { display: flex; justify-content: space-between; gap: 8px; align-items: center; }
|
||||
.action-priority {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.action-priority.priority-high { background: #fee2e2; color: #b91c1c; }
|
||||
.action-priority.priority-medium { background: #fef3c7; color: #b45309; }
|
||||
.action-priority.priority-low { background: var(--mc-bg-muted); color: var(--mc-text-secondary); }
|
||||
.action-when { font-size: 11px; color: var(--mc-text-tertiary); }
|
||||
.action-title { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); line-height: 1.45; }
|
||||
.action-rationale { font-size: 12px; color: var(--mc-text-secondary); line-height: 1.5; }
|
||||
.action-foot { display: flex; gap: 6px; margin-top: 4px; }
|
||||
.btn-action {
|
||||
background: var(--mc-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-action:hover { background: var(--mc-primary-hover); }
|
||||
.btn-action--ghost { background: transparent; color: var(--mc-text-secondary); border: 1px solid var(--mc-border); }
|
||||
.btn-action--ghost:hover { background: var(--mc-bg-muted); }
|
||||
|
||||
@media (max-width: 1200px) { .account-shell { grid-template-columns: 240px 1fr 280px; } }
|
||||
@media (max-width: 1000px) { .account-shell { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
260
mateclaw-ui/src/views/Enterprise/Approvals.vue
Normal file
260
mateclaw-ui/src/views/Enterprise/Approvals.vue
Normal file
@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div class="approvals-shell">
|
||||
<article class="panel mc-surface-card">
|
||||
<header class="panel-head">
|
||||
<div>
|
||||
<h3 class="panel-title">{{ t('enterprise.approvals.queueTitle') }}</h3>
|
||||
<p class="panel-desc">{{ t('enterprise.approvals.queueDesc') }}</p>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<button v-for="f in filters" :key="f.key"
|
||||
class="chip" :class="{ active: filter === f.key }"
|
||||
@click="filter = f.key">
|
||||
{{ f.label }}
|
||||
<span class="chip-count">{{ f.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ul class="approval-list">
|
||||
<li v-for="a in filteredItems" :key="a.id" class="approval-item">
|
||||
<div class="approval-main">
|
||||
<div class="approval-head-row">
|
||||
<span class="approval-kind" :class="`kind-${a.kind}`">{{ kindLabel(a.kind) }}</span>
|
||||
<span class="approval-risk" :class="`risk-${a.risk}`">{{ riskLabel(a.risk) }}</span>
|
||||
<span class="approval-eta">{{ a.sla }}</span>
|
||||
</div>
|
||||
<div class="approval-title">{{ a.title }}</div>
|
||||
<div class="approval-meta">
|
||||
<span><strong>{{ t('enterprise.approvals.requester') }}</strong> {{ a.requester }}</span>
|
||||
<span class="dot"></span>
|
||||
<span><strong>{{ t('enterprise.approvals.target') }}</strong> {{ a.target }}</span>
|
||||
<span class="dot"></span>
|
||||
<span><strong>{{ t('enterprise.approvals.reason') }}</strong> {{ a.reason }}</span>
|
||||
</div>
|
||||
<div class="approval-evidence">
|
||||
<span class="ev-label">{{ t('enterprise.approvals.evidence') }}</span>
|
||||
<span>{{ a.evidence }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="approval-actions">
|
||||
<button class="btn-action">{{ t('enterprise.approvals.btnApprove') }}</button>
|
||||
<button class="btn-action btn-action--ghost">{{ t('enterprise.approvals.btnReject') }}</button>
|
||||
<button class="btn-action btn-action--ghost">{{ t('enterprise.approvals.btnDelegate') }}</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="panel mc-surface-card sla-panel">
|
||||
<header class="panel-head">
|
||||
<div>
|
||||
<h3 class="panel-title">{{ t('enterprise.approvals.slaTitle') }}</h3>
|
||||
<p class="panel-desc">{{ t('enterprise.approvals.slaDesc') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="sla-grid">
|
||||
<div class="sla-card">
|
||||
<div class="sla-value">2.4h</div>
|
||||
<div class="sla-label">{{ t('enterprise.approvals.slaAvg') }}</div>
|
||||
<div class="sla-delta good">-18% vs 上周</div>
|
||||
</div>
|
||||
<div class="sla-card">
|
||||
<div class="sla-value">96%</div>
|
||||
<div class="sla-label">{{ t('enterprise.approvals.slaWithin') }}</div>
|
||||
<div class="sla-delta good">+3pp</div>
|
||||
</div>
|
||||
<div class="sla-card">
|
||||
<div class="sla-value">2</div>
|
||||
<div class="sla-label">{{ t('enterprise.approvals.slaOverdue') }}</div>
|
||||
<div class="sla-delta bad">+1</div>
|
||||
</div>
|
||||
<div class="sla-card">
|
||||
<div class="sla-value">7</div>
|
||||
<div class="sla-label">{{ t('enterprise.approvals.slaAutoApproved') }}</div>
|
||||
<div class="sla-delta">{{ t('enterprise.approvals.slaAutoNote') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const filter = ref<'mine' | 'all' | 'high'>('mine')
|
||||
|
||||
const filters = computed(() => [
|
||||
{ key: 'mine', label: t('enterprise.approvals.filterMine'), count: 3 },
|
||||
{ key: 'all', label: t('enterprise.approvals.filterAll'), count: 5 },
|
||||
{ key: 'high', label: t('enterprise.approvals.filterHigh'), count: 2 },
|
||||
])
|
||||
|
||||
interface Item {
|
||||
id: string
|
||||
kind: 'contract' | 'tool' | 'transformation' | 'access'
|
||||
risk: 'high' | 'medium' | 'low'
|
||||
title: string
|
||||
requester: string
|
||||
target: string
|
||||
reason: string
|
||||
evidence: string
|
||||
sla: string
|
||||
mine: boolean
|
||||
}
|
||||
|
||||
const items: Item[] = [
|
||||
{ id: '1', kind: 'contract', risk: 'high', title: 'Acme MSA 续约审批',
|
||||
requester: '李法务', target: 'Acme Corp (买方)', reason: '赔偿条款偏离 Playbook',
|
||||
evidence: 'AI 已审:3 条偏离 · Legal Playbook v3.1', sla: 'SLA 4h 内', mine: true },
|
||||
{ id: '2', kind: 'tool', risk: 'high', title: 'web_search 调用授权',
|
||||
requester: '销售情报员 Agent', target: 'Acme Corp 公司信息抓取', reason: '触发金融行业敏感关键词',
|
||||
evidence: '工具守卫规则 R-082 · 历史误报率 < 2%', sla: 'SLA 1h 内', mine: true },
|
||||
{ id: '3', kind: 'transformation', risk: 'medium', title: '风险点提取 transformation 批量应用',
|
||||
requester: '王法务', target: '37 份新增合同', reason: '批量任务超过 10 份阈值',
|
||||
evidence: '历史成功率 94% · 单次平均耗时 23s', sla: 'SLA 2h 内', mine: true },
|
||||
{ id: '4', kind: 'access', risk: 'low', title: 'BlueWave KB 读取权限',
|
||||
requester: '新员工 周明', target: 'BlueWave 客户库(含 PII)', reason: '团队入职流程',
|
||||
evidence: '入职单 #2026-0421 · 主管 王经理已 OK', sla: 'SLA 1d 内', mine: false },
|
||||
{ id: '5', kind: 'contract', risk: 'medium', title: 'FinChen NDA 审批',
|
||||
requester: '李法务', target: 'FinChen 金融', reason: '仲裁地条款',
|
||||
evidence: 'AI 已审:1 条偏离', sla: 'SLA 8h 内', mine: false },
|
||||
]
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
if (filter.value === 'mine') return items.filter(i => i.mine)
|
||||
if (filter.value === 'high') return items.filter(i => i.risk === 'high')
|
||||
return items
|
||||
})
|
||||
|
||||
function kindLabel(k: Item['kind']): string { return t(`enterprise.approvals.kind.${k}`) }
|
||||
function riskLabel(r: 'high' | 'medium' | 'low'): string {
|
||||
return r === 'high' ? t('enterprise.risk.high')
|
||||
: r === 'medium' ? t('enterprise.risk.medium')
|
||||
: t('enterprise.risk.low')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.approvals-shell { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
.panel { padding: 18px 20px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.panel-head { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.panel-title { font-size: 16px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 2px; }
|
||||
.panel-desc { font-size: 12px; color: var(--mc-text-secondary); margin: 0; max-width: 480px; line-height: 1.5; }
|
||||
|
||||
.filter-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.chip {
|
||||
border: 1px solid var(--mc-border-light);
|
||||
background: var(--mc-bg-elevated);
|
||||
color: var(--mc-text-secondary);
|
||||
font-size: 12px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.chip.active { background: var(--mc-primary-bg); color: var(--mc-primary-hover); border-color: var(--mc-primary); }
|
||||
.chip-count { font-size: 11px; opacity: 0.7; }
|
||||
|
||||
/* === approval list === */
|
||||
.approval-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.approval-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: var(--mc-bg-muted);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--mc-border-light);
|
||||
}
|
||||
|
||||
.approval-main { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.approval-head-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.approval-kind {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.kind-contract { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
|
||||
.kind-tool { background: #fef3c7; color: #b45309; }
|
||||
.kind-transformation { background: var(--mc-accent-soft); color: var(--mc-accent); }
|
||||
.kind-access { background: #dbeafe; color: #1e40af; }
|
||||
|
||||
.approval-risk {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.risk-high { background: #fee2e2; color: #b91c1c; }
|
||||
.risk-medium { background: #fef3c7; color: #b45309; }
|
||||
.risk-low { background: #dcfce7; color: #15803d; }
|
||||
|
||||
.approval-eta { font-size: 11px; color: var(--mc-text-tertiary); font-family: var(--mc-font-mono); margin-left: auto; }
|
||||
|
||||
.approval-title { font-size: 14px; font-weight: 600; color: var(--mc-text-primary); line-height: 1.45; }
|
||||
.approval-meta { font-size: 12px; color: var(--mc-text-secondary); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.approval-meta strong { font-weight: 600; color: var(--mc-text-tertiary); margin-right: 4px; }
|
||||
.dot { width: 3px; height: 3px; background: currentColor; border-radius: 50%; opacity: 0.5; }
|
||||
.approval-evidence {
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-secondary);
|
||||
background: var(--mc-bg-elevated);
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.ev-label { font-size: 10px; font-weight: 700; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
.approval-actions { display: flex; flex-direction: column; gap: 6px; align-self: center; }
|
||||
.btn-action {
|
||||
background: var(--mc-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 7px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-action:hover { background: var(--mc-primary-hover); }
|
||||
.btn-action--ghost { background: transparent; color: var(--mc-text-secondary); border: 1px solid var(--mc-border); }
|
||||
.btn-action--ghost:hover { background: var(--mc-bg-muted); }
|
||||
|
||||
/* === SLA panel === */
|
||||
.sla-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.sla-card {
|
||||
padding: 14px 16px;
|
||||
background: var(--mc-bg-elevated);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.sla-value { font-size: 24px; font-weight: 700; color: var(--mc-text-primary); line-height: 1; }
|
||||
.sla-label { font-size: 11px; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px; }
|
||||
.sla-delta { font-size: 11px; color: var(--mc-text-secondary); margin-top: 4px; }
|
||||
.sla-delta.good { color: #15803d; }
|
||||
.sla-delta.bad { color: #b91c1c; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.sla-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.approval-item { grid-template-columns: 1fr; }
|
||||
.approval-actions { flex-direction: row; }
|
||||
}
|
||||
</style>
|
||||
186
mateclaw-ui/src/views/Enterprise/Audit.vue
Normal file
186
mateclaw-ui/src/views/Enterprise/Audit.vue
Normal file
@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class="audit-shell">
|
||||
<article class="panel mc-surface-card">
|
||||
<header class="panel-head">
|
||||
<div>
|
||||
<h3 class="panel-title">{{ t('enterprise.audit.title') }}</h3>
|
||||
<p class="panel-desc">{{ t('enterprise.audit.desc') }}</p>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<button v-for="f in scopeFilters" :key="f.key"
|
||||
class="chip" :class="{ active: scope === f.key }"
|
||||
@click="scope = f.key">{{ f.label }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ol class="audit-trail">
|
||||
<li v-for="e in filteredEvents" :key="e.id" class="audit-event">
|
||||
<div class="event-time">
|
||||
<div class="time-stamp">{{ e.time }}</div>
|
||||
<div class="time-date">{{ e.date }}</div>
|
||||
</div>
|
||||
<div class="event-marker" :class="`marker-${e.kind}`"></div>
|
||||
<div class="event-body">
|
||||
<div class="event-head">
|
||||
<span class="event-kind" :class="`kind-${e.kind}`">{{ kindLabel(e.kind) }}</span>
|
||||
<span class="event-actor">{{ e.actor }}</span>
|
||||
<span v-if="e.system" class="event-system">{{ e.system }}</span>
|
||||
</div>
|
||||
<div class="event-summary">{{ e.summary }}</div>
|
||||
<div v-if="e.evidence" class="event-evidence">
|
||||
<span class="ev-label">{{ t('enterprise.audit.evidence') }}</span>
|
||||
<span>{{ e.evidence }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const scope = ref<'all' | 'contract' | 'account' | 'tool'>('all')
|
||||
|
||||
const scopeFilters = computed(() => [
|
||||
{ key: 'all', label: t('enterprise.audit.scopeAll') },
|
||||
{ key: 'contract', label: t('enterprise.audit.scopeContract') },
|
||||
{ key: 'account', label: t('enterprise.audit.scopeAccount') },
|
||||
{ key: 'tool', label: t('enterprise.audit.scopeTool') },
|
||||
])
|
||||
|
||||
interface Event { id: string; time: string; date: string; kind: 'review' | 'approve' | 'reject' | 'tool' | 'access' | 'modify' | 'agent'; actor: string; system?: string; summary: string; evidence?: string }
|
||||
|
||||
const events: Event[] = [
|
||||
{ id: '1', time: '11:24', date: '今天', kind: 'approve', actor: '法务总监 张律师', system: 'Acme MSA 续约',
|
||||
summary: '批准 Acme Corp MSA v3.2 续约(赔偿条款已修正为 12 个月服务费上限)',
|
||||
evidence: '关联运行:run #4 · 合同审查 case#msa-acme-2026q2 · 偏离 playbook 项已全部解决' },
|
||||
{ id: '2', time: '11:18', date: '今天', kind: 'modify', actor: '李法务', system: 'Acme MSA 续约',
|
||||
summary: '修改第 8.2 条赔偿条款,采纳 AI 建议(阶梯式上限)',
|
||||
evidence: 'AI 建议引用:Zerto MSA 2025 案例 · Playbook v3.1 第 4.2 节' },
|
||||
{ id: '3', time: '10:42', date: '今天', kind: 'review', actor: 'AI 法务审查员', system: 'Acme MSA 续约',
|
||||
summary: '完成条款级审查(3 条偏离,1 高 / 1 中 / 1 低)',
|
||||
evidence: '模型:Claude Sonnet 4.6 · 输入:Acme MSA v3.2.pdf 14 页 · 耗时 47s · token 12,830 / 3,210' },
|
||||
{ id: '4', time: '10:30', date: '今天', kind: 'tool', actor: '销售情报员 Agent', system: 'Acme Corp 账户',
|
||||
summary: '调用 web_search 抓取 Acme 季报,提取上云战略关键句',
|
||||
evidence: '工具守卫批准 #T-2026-114 · 数据驻留:中国大陆' },
|
||||
{ id: '5', time: '09:15', date: '今天', kind: 'agent', actor: '法务审查员 Agent', system: 'Vendor A NDA',
|
||||
summary: '识别 NDA 第 5.1 条非常规竞业条款(5 年),自动发起改写沟通邮件草稿',
|
||||
evidence: 'Playbook 规则:NDA 禁止竞业条款 · 1 次工具调用:draft_email_template' },
|
||||
{ id: '6', time: '08:30', date: '今天', kind: 'review', actor: 'AI 法务审查员', system: 'Vendor A NDA',
|
||||
summary: '完成审查(1 条高风险)',
|
||||
evidence: '模型:Claude Sonnet 4.6 · 输入:vendor-a-nda-v1.pdf 4 页 · 耗时 18s' },
|
||||
{ id: '7', time: '16:00', date: '昨天', kind: 'review', actor: 'AI 合同对比员', system: 'Q2 供应商对比',
|
||||
summary: '生成 5 家供应商横向对比报告,识别 3 处显著条款差异',
|
||||
evidence: '输入:5 份合同 · 输出 page#vendor-comparison-q2 · 耗时 2m 14s' },
|
||||
{ id: '8', time: '15:42', date: '昨天', kind: 'access', actor: '周明', system: 'BlueWave 客户库',
|
||||
summary: '请求 BlueWave 客户库读取权限',
|
||||
evidence: '审批单:access #A-2026-0419 · 主管已批准' },
|
||||
{ id: '9', time: '14:20', date: '昨天', kind: 'reject', actor: '王法务', system: 'FinChen NDA v0.9',
|
||||
summary: '驳回 FinChen NDA v0.9,要求重新修订仲裁地条款',
|
||||
evidence: 'AI 已审:3 条偏离 · 驳回理由:仲裁地不能为对方注册地' },
|
||||
]
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
switch (scope.value) {
|
||||
case 'contract': return events.filter(e => e.system?.includes('MSA') || e.system?.includes('NDA') || e.system?.includes('对比'))
|
||||
case 'account': return events.filter(e => e.system?.includes('Corp') || e.system?.includes('客户库') || e.system?.includes('账户'))
|
||||
case 'tool': return events.filter(e => e.kind === 'tool' || e.kind === 'agent')
|
||||
default: return events
|
||||
}
|
||||
})
|
||||
|
||||
function kindLabel(k: Event['kind']): string { return t(`enterprise.audit.kind.${k}`) }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audit-shell { display: flex; flex-direction: column; }
|
||||
|
||||
.panel { padding: 18px 20px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.panel-head { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.panel-title { font-size: 16px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 2px; }
|
||||
.panel-desc { font-size: 12px; color: var(--mc-text-secondary); margin: 0; max-width: 480px; line-height: 1.5; }
|
||||
|
||||
.filter-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.chip {
|
||||
border: 1px solid var(--mc-border-light);
|
||||
background: var(--mc-bg-elevated);
|
||||
color: var(--mc-text-secondary);
|
||||
font-size: 12px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip.active { background: var(--mc-primary-bg); color: var(--mc-primary-hover); border-color: var(--mc-primary); }
|
||||
|
||||
/* === audit trail === */
|
||||
.audit-trail { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
||||
.audit-event {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 16px 1fr;
|
||||
gap: 14px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--mc-border-light);
|
||||
position: relative;
|
||||
}
|
||||
.audit-event:last-child { border-bottom: none; }
|
||||
.audit-event::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 79px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--mc-border-light);
|
||||
}
|
||||
.audit-event:first-child::before { top: 14px; }
|
||||
.audit-event:last-child::before { bottom: calc(100% - 24px); }
|
||||
|
||||
.event-time { text-align: right; padding-top: 2px; }
|
||||
.time-stamp { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); font-family: var(--mc-font-mono); }
|
||||
.time-date { font-size: 11px; color: var(--mc-text-tertiary); }
|
||||
|
||||
.event-marker {
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%;
|
||||
margin-top: 6px;
|
||||
border: 3px solid var(--mc-bg);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.marker-review { background: var(--mc-text-secondary); }
|
||||
.marker-approve { background: #15803d; }
|
||||
.marker-reject { background: #b91c1c; }
|
||||
.marker-tool { background: #f59e0b; }
|
||||
.marker-access { background: #1e40af; }
|
||||
.marker-modify { background: var(--mc-primary); }
|
||||
.marker-agent { background: var(--mc-accent); }
|
||||
|
||||
.event-body { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.event-head { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.event-kind {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.kind-review { background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
|
||||
.kind-approve { background: #dcfce7; color: #15803d; }
|
||||
.kind-reject { background: #fee2e2; color: #b91c1c; }
|
||||
.kind-tool { background: #fef3c7; color: #b45309; }
|
||||
.kind-access { background: #dbeafe; color: #1e40af; }
|
||||
.kind-modify { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
|
||||
.kind-agent { background: var(--mc-accent-soft); color: var(--mc-accent); }
|
||||
|
||||
.event-actor { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); }
|
||||
.event-system { font-size: 12px; color: var(--mc-text-tertiary); padding: 2px 8px; background: var(--mc-bg-muted); border-radius: 4px; }
|
||||
|
||||
.event-summary { font-size: 13px; color: var(--mc-text-primary); line-height: 1.55; }
|
||||
.event-evidence { font-size: 11px; color: var(--mc-text-tertiary); display: flex; gap: 6px; align-items: baseline; line-height: 1.5; flex-wrap: wrap; }
|
||||
.ev-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--mc-text-secondary); white-space: nowrap; }
|
||||
</style>
|
||||
564
mateclaw-ui/src/views/Enterprise/ContractReview.vue
Normal file
564
mateclaw-ui/src/views/Enterprise/ContractReview.vue
Normal file
@ -0,0 +1,564 @@
|
||||
<template>
|
||||
<div class="review-shell">
|
||||
<aside class="review-list mc-surface-card">
|
||||
<header class="list-head">
|
||||
<h3 class="list-title">{{ t('enterprise.contract.listTitle') }}</h3>
|
||||
<div class="list-filters">
|
||||
<button v-for="f in listFilters" :key="f.key"
|
||||
class="chip" :class="{ active: listFilter === f.key }"
|
||||
@click="listFilter = f.key">
|
||||
{{ f.label }}
|
||||
<span v-if="f.count != null" class="chip-count">{{ f.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ul class="case-list">
|
||||
<li v-for="c in filteredCases" :key="c.id"
|
||||
class="case-item" :class="{ active: c.id === selectedId }"
|
||||
@click="selectedId = c.id">
|
||||
<span class="risk-pill" :class="`risk-${c.risk}`">{{ riskLabel(c.risk) }}</span>
|
||||
<div class="case-main">
|
||||
<div class="case-title">{{ c.title }}</div>
|
||||
<div class="case-meta">
|
||||
<span>{{ c.counterparty }}</span>
|
||||
<span class="dot"></span>
|
||||
<span>{{ c.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<section class="review-center mc-surface-card">
|
||||
<header class="center-head">
|
||||
<div class="center-headline">
|
||||
<h2 class="center-title">{{ selectedCase.title }}</h2>
|
||||
<div class="center-sub">
|
||||
<span>{{ selectedCase.counterparty }}</span>
|
||||
<span class="dot"></span>
|
||||
<span>{{ selectedCase.value }}</span>
|
||||
<span class="dot"></span>
|
||||
<span>{{ selectedCase.line }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="center-status-row">
|
||||
<span class="status-chip" :class="`status-${selectedCase.status}`">
|
||||
{{ statusLabel(selectedCase.status) }}
|
||||
</span>
|
||||
<span class="status-counter">{{ selectedCase.clauses.length }} {{ t('enterprise.contract.clausesFound') }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="clause-list">
|
||||
<article v-for="cl in selectedCase.clauses" :key="cl.id"
|
||||
class="clause-card" :class="`risk-${cl.risk}`"
|
||||
:data-active="cl.id === activeClauseId"
|
||||
@click="activeClauseId = cl.id">
|
||||
<div class="clause-head">
|
||||
<span class="risk-pill" :class="`risk-${cl.risk}`">{{ riskLabel(cl.risk) }}</span>
|
||||
<span class="clause-type">{{ cl.type }}</span>
|
||||
<span class="clause-loc">{{ cl.location }}</span>
|
||||
</div>
|
||||
<blockquote class="clause-quote">「{{ cl.quote }}」</blockquote>
|
||||
<div class="clause-deviation">
|
||||
<span class="dev-label">{{ t('enterprise.contract.deviation') }}</span>
|
||||
<span>{{ cl.deviation }}</span>
|
||||
</div>
|
||||
<div class="clause-suggest">
|
||||
<span class="suggest-label">{{ t('enterprise.contract.suggestion') }}</span>
|
||||
<div class="suggest-text">{{ cl.suggestion }}</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<footer class="approval-chain">
|
||||
<div class="chain-title">{{ t('enterprise.contract.approvalChain') }}</div>
|
||||
<ol class="chain-list">
|
||||
<li v-for="(step, i) in selectedCase.chain" :key="i"
|
||||
class="chain-step" :class="{ done: step.done, active: !step.done && firstActiveChain === i }">
|
||||
<div class="chain-bullet">{{ step.done ? '✓' : i + 1 }}</div>
|
||||
<div class="chain-body">
|
||||
<div class="chain-who">{{ step.who }}</div>
|
||||
<div class="chain-note">{{ step.note }}</div>
|
||||
<div v-if="step.at" class="chain-at">{{ step.at }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="chain-actions">
|
||||
<button class="btn-secondary">{{ t('enterprise.contract.btnReject') }}</button>
|
||||
<button class="btn-secondary">{{ t('enterprise.contract.btnRequestRevision') }}</button>
|
||||
<button class="btn-primary">{{ t('enterprise.contract.btnApprove') }}</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<aside class="review-evidence mc-surface-card">
|
||||
<header class="evidence-head">
|
||||
<h3 class="list-title">{{ t('enterprise.contract.evidenceTitle') }}</h3>
|
||||
<p class="evidence-sub">{{ activeClause.type }} · {{ activeClause.location }}</p>
|
||||
</header>
|
||||
|
||||
<div class="evidence-stack">
|
||||
<div class="evidence-block">
|
||||
<div class="block-label">{{ t('enterprise.contract.sourceContract') }}</div>
|
||||
<div class="block-quote">「{{ activeClause.quote }}」</div>
|
||||
<div class="block-loc">{{ selectedCase.title }} · {{ activeClause.location }}</div>
|
||||
</div>
|
||||
|
||||
<div class="evidence-block">
|
||||
<div class="block-label">{{ t('enterprise.contract.playbookRule') }}</div>
|
||||
<div class="block-quote">「{{ activeClause.playbookQuote }}」</div>
|
||||
<div class="block-loc">{{ activeClause.playbookRef }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeClause.precedents.length" class="evidence-block">
|
||||
<div class="block-label">{{ t('enterprise.contract.precedents') }}</div>
|
||||
<ul class="precedent-list">
|
||||
<li v-for="p in activeClause.precedents" :key="p.contract">
|
||||
<div class="precedent-headline">{{ p.contract }}</div>
|
||||
<div class="precedent-outcome">{{ p.outcome }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="evidence-foot">
|
||||
<div class="foot-meta">
|
||||
<span class="foot-label">{{ t('enterprise.contract.confidence') }}</span>
|
||||
<span class="foot-value">{{ activeClause.confidence }}</span>
|
||||
</div>
|
||||
<div class="foot-meta">
|
||||
<span class="foot-label">{{ t('enterprise.contract.model') }}</span>
|
||||
<span class="foot-value">{{ activeClause.model }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const props = defineProps<{ focus: string | null }>()
|
||||
|
||||
const listFilter = ref<'all' | 'high' | 'pending'>('all')
|
||||
|
||||
const listFilters = computed(() => [
|
||||
{ key: 'all', label: t('enterprise.contract.filterAll'), count: 23 },
|
||||
{ key: 'high', label: t('enterprise.contract.filterHigh'), count: 7 },
|
||||
{ key: 'pending', label: t('enterprise.contract.filterPending'), count: 12 },
|
||||
])
|
||||
|
||||
interface Clause {
|
||||
id: string
|
||||
type: string
|
||||
risk: 'high' | 'medium' | 'low'
|
||||
location: string
|
||||
quote: string
|
||||
deviation: string
|
||||
suggestion: string
|
||||
playbookQuote: string
|
||||
playbookRef: string
|
||||
precedents: { contract: string; outcome: string }[]
|
||||
confidence: string
|
||||
model: string
|
||||
}
|
||||
|
||||
interface ChainStep { who: string; note: string; at: string | null; done: boolean }
|
||||
|
||||
interface Case {
|
||||
id: string
|
||||
title: string
|
||||
counterparty: string
|
||||
version: string
|
||||
value: string
|
||||
line: string
|
||||
risk: 'high' | 'medium' | 'low'
|
||||
status: 'ai_reviewed' | 'pending_legal' | 'approved'
|
||||
clauses: Clause[]
|
||||
chain: ChainStep[]
|
||||
}
|
||||
|
||||
const cases: Case[] = [
|
||||
{
|
||||
id: 'msa-acme-2026q2',
|
||||
title: 'Acme Corp - MSA 续约',
|
||||
counterparty: 'Acme Corp (买方)',
|
||||
version: 'v3.2 · 2026-Q2',
|
||||
value: '合同价值 ¥12M / 3 年',
|
||||
line: '业务线:企业版 SaaS',
|
||||
risk: 'high',
|
||||
status: 'ai_reviewed',
|
||||
clauses: [
|
||||
{
|
||||
id: 'c1',
|
||||
type: '赔偿条款',
|
||||
risk: 'high',
|
||||
location: '第 8.2 条 · 第 14 页',
|
||||
quote: '乙方应赔偿甲方因履行本协议产生的全部直接和间接损失,包括但不限于利润损失、商誉损失和惩罚性赔偿,无上限',
|
||||
deviation: '我司 Legal Playbook 要求赔偿上限不超过 12 个月服务费,且明确排除惩罚性赔偿与间接损失',
|
||||
suggestion: '改为:乙方对甲方的赔偿责任总额不超过本协议项下最近 12 个月支付的服务费总额;任何一方均不对另一方的间接损失、利润损失或惩罚性赔偿承担责任。',
|
||||
playbookQuote: '所有商务合同的赔偿条款必须包含责任上限(不超过 12 个月服务费)和间接损失排除条款',
|
||||
playbookRef: 'Legal Playbook v3.1 · 第 4.2 节',
|
||||
precedents: [
|
||||
{ contract: 'Zerto MSA 2025', outcome: '采用阶梯式上限(前 12 月 = 服务费 100%,后续 = 50%)' },
|
||||
{ contract: 'BlueWave SLA 2024', outcome: '客户接受 12 个月服务费上限 + 惩罚性赔偿排除' },
|
||||
],
|
||||
confidence: '高(4 项证据)',
|
||||
model: 'Claude Sonnet 4.6',
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
type: '数据保护',
|
||||
risk: 'medium',
|
||||
location: '第 11.4 条 · 第 19 页',
|
||||
quote: '任何数据泄露,乙方应在 4 小时内通过书面方式通知甲方',
|
||||
deviation: '我司 Playbook 要求 72 小时通知期,4 小时不切实际且容易触发误报',
|
||||
suggestion: '建议改为 72 小时(与 GDPR 一致),并将"书面"扩展为"任何可追溯的方式(包括邮件、Slack 受控渠道)"',
|
||||
playbookQuote: '数据泄露通知期 = 72 小时,与 GDPR Article 33 对齐',
|
||||
playbookRef: 'Legal Playbook v3.1 · 第 5.1 节',
|
||||
precedents: [
|
||||
{ contract: 'EuroSoft DPA 2025', outcome: '客户接受 72 小时通知期' },
|
||||
],
|
||||
confidence: '中(2 项证据)',
|
||||
model: 'Claude Sonnet 4.6',
|
||||
},
|
||||
{
|
||||
id: 'c3',
|
||||
type: '自动续约',
|
||||
risk: 'low',
|
||||
location: '第 2.3 条 · 第 3 页',
|
||||
quote: '本协议自动续约一年,除非任何一方在到期前 30 天书面通知',
|
||||
deviation: '我司 Playbook 推荐 60 天通知期以匹配预算审批流程',
|
||||
suggestion: '修改通知期为 60 天',
|
||||
playbookQuote: '自动续约的退出通知期 ≥ 60 天',
|
||||
playbookRef: 'Legal Playbook v3.1 · 第 3.4 节',
|
||||
precedents: [],
|
||||
confidence: '高(明确 playbook 偏离)',
|
||||
model: 'Claude Sonnet 4.6',
|
||||
},
|
||||
],
|
||||
chain: [
|
||||
{ who: 'AI 法务审查员', note: '已完成条款级审查(3 条偏离)', at: '今天 09:42', done: true },
|
||||
{ who: '李法务', note: '复核中', at: '今天 10:15', done: false },
|
||||
{ who: '法务总监 张律师', note: '待审批', at: null, done: false },
|
||||
{ who: 'BD 总监 王经理', note: '待签字', at: null, done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nda-vendora',
|
||||
title: 'Vendor A NDA',
|
||||
counterparty: 'Vendor A Inc. (供应商)',
|
||||
version: 'v1.0 · 2026-04',
|
||||
value: '保密期:永久',
|
||||
line: '业务线:采购',
|
||||
risk: 'high',
|
||||
status: 'pending_legal',
|
||||
clauses: [
|
||||
{
|
||||
id: 'c1',
|
||||
type: '非常规竞业',
|
||||
risk: 'high',
|
||||
location: '第 5.1 条 · 第 4 页',
|
||||
quote: '签署方未来 5 年内不得与对方业务领域有重叠的任何公司发生雇佣或顾问关系',
|
||||
deviation: '我司 Playbook 不允许 NDA 包含竞业条款,更不允许 5 年这种长周期',
|
||||
suggestion: '建议彻底删除第 5.1 条,竞业义务应另行签订独立竞业协议',
|
||||
playbookQuote: 'NDA 不得包含竞业条款;竞业义务需独立协议',
|
||||
playbookRef: 'Legal Playbook v3.1 · 第 6.2 节',
|
||||
precedents: [],
|
||||
confidence: '高',
|
||||
model: 'Claude Sonnet 4.6',
|
||||
},
|
||||
],
|
||||
chain: [
|
||||
{ who: 'AI 法务审查员', note: '已完成审查(1 条高风险)', at: '今天 08:30', done: true },
|
||||
{ who: '王法务', note: '已发起改写沟通', at: '今天 09:15', done: false },
|
||||
{ who: '法务总监 张律师', note: '待审批', at: null, done: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'q2-vendor-cmp',
|
||||
title: 'Q2 供应商条款横向对比',
|
||||
counterparty: '5 家供应商汇总',
|
||||
version: '对比报告 v1',
|
||||
value: '总合同价值 ¥45M',
|
||||
line: '业务线:采购',
|
||||
risk: 'medium',
|
||||
status: 'ai_reviewed',
|
||||
clauses: [],
|
||||
chain: [
|
||||
{ who: 'AI 合同对比员', note: '已生成横向对比报告', at: '昨天 16:00', done: true },
|
||||
{ who: '采购总监', note: '复核中', at: null, done: false },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const filteredCases = computed(() => {
|
||||
if (listFilter.value === 'high') return cases.filter(c => c.risk === 'high')
|
||||
if (listFilter.value === 'pending') return cases.filter(c => c.status !== 'approved')
|
||||
return cases
|
||||
})
|
||||
|
||||
const selectedId = ref<string>(cases[0].id)
|
||||
watch(() => props.focus, (id) => {
|
||||
if (id && cases.find(c => c.id === id)) selectedId.value = id
|
||||
}, { immediate: true })
|
||||
|
||||
const selectedCase = computed(() => cases.find(c => c.id === selectedId.value) ?? cases[0])
|
||||
const activeClauseId = ref<string>(selectedCase.value.clauses[0]?.id ?? '')
|
||||
watch(selectedId, () => { activeClauseId.value = selectedCase.value.clauses[0]?.id ?? '' })
|
||||
|
||||
const activeClause = computed<Clause>(() => {
|
||||
const c = selectedCase.value.clauses.find(x => x.id === activeClauseId.value)
|
||||
return c ?? selectedCase.value.clauses[0] ?? emptyClause
|
||||
})
|
||||
|
||||
const firstActiveChain = computed(() => selectedCase.value.chain.findIndex(s => !s.done))
|
||||
|
||||
const emptyClause: Clause = {
|
||||
id: 'empty', type: '—', risk: 'low', location: '—', quote: '—', deviation: '—', suggestion: '—',
|
||||
playbookQuote: '—', playbookRef: '—', precedents: [], confidence: '—', model: '—',
|
||||
}
|
||||
|
||||
function riskLabel(r: 'high' | 'medium' | 'low'): string {
|
||||
return r === 'high' ? t('enterprise.risk.high')
|
||||
: r === 'medium' ? t('enterprise.risk.medium')
|
||||
: t('enterprise.risk.low')
|
||||
}
|
||||
function statusLabel(s: Case['status']): string {
|
||||
return s === 'ai_reviewed' ? t('enterprise.status.aiReviewed')
|
||||
: s === 'pending_legal' ? t('enterprise.status.pendingLegal')
|
||||
: t('enterprise.status.approved')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.review-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 340px;
|
||||
gap: 14px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* === panes === */
|
||||
.review-list, .review-center, .review-evidence {
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.review-list { padding: 16px 0; }
|
||||
|
||||
/* === list pane === */
|
||||
.list-head { padding: 0 18px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.list-title { font-size: 14px; font-weight: 700; color: var(--mc-text-primary); margin: 0; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.list-filters { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.chip {
|
||||
border: 1px solid var(--mc-border-light);
|
||||
background: var(--mc-bg-elevated);
|
||||
color: var(--mc-text-secondary);
|
||||
font-size: 11px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.chip.active { background: var(--mc-primary-bg); color: var(--mc-primary-hover); border-color: var(--mc-primary); }
|
||||
.chip-count { opacity: 0.65; }
|
||||
|
||||
.case-list { list-style: none; margin: 0; padding: 0; flex: 1; overflow-y: auto; }
|
||||
.case-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 10px;
|
||||
padding: 12px 18px;
|
||||
border-left: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.case-item:hover { background: var(--mc-bg-muted); }
|
||||
.case-item.active { background: var(--mc-primary-bg); border-left-color: var(--mc-primary); }
|
||||
.case-main { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.case-title { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.case-meta { font-size: 11px; color: var(--mc-text-tertiary); display: flex; gap: 6px; align-items: center; }
|
||||
|
||||
/* === risk pill (shared) === */
|
||||
.risk-pill {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
min-width: 38px;
|
||||
align-self: flex-start;
|
||||
height: fit-content;
|
||||
}
|
||||
.risk-high { background: #fee2e2; color: #b91c1c; }
|
||||
.risk-medium { background: #fef3c7; color: #b45309; }
|
||||
.risk-low { background: #dcfce7; color: #15803d; }
|
||||
.dot { width: 3px; height: 3px; background: currentColor; border-radius: 50%; opacity: 0.5; }
|
||||
|
||||
/* === center pane === */
|
||||
.review-center { overflow: hidden; }
|
||||
.center-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--mc-border-light);
|
||||
}
|
||||
.center-headline { display: flex; flex-direction: column; gap: 4px; }
|
||||
.center-title { font-size: 18px; font-weight: 700; color: var(--mc-text-primary); margin: 0; }
|
||||
.center-sub { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--mc-text-secondary); }
|
||||
.center-status-row { display: flex; gap: 10px; align-items: center; }
|
||||
.status-chip { font-size: 11px; font-weight: 600; padding: 4px 10px; border-radius: 6px; }
|
||||
.status-ai_reviewed { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
|
||||
.status-pending_legal { background: #fef3c7; color: #b45309; }
|
||||
.status-approved { background: #dcfce7; color: #15803d; }
|
||||
.status-counter { font-size: 12px; color: var(--mc-text-tertiary); }
|
||||
|
||||
.clause-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.clause-card {
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--mc-bg-elevated);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
}
|
||||
.clause-card.risk-high { border-left: 4px solid #ef4444; }
|
||||
.clause-card.risk-medium { border-left: 4px solid #f59e0b; }
|
||||
.clause-card.risk-low { border-left: 4px solid #84cc16; }
|
||||
.clause-card[data-active="true"] { box-shadow: var(--mc-shadow-soft); border-color: var(--mc-primary); }
|
||||
|
||||
.clause-head { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.clause-type { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); }
|
||||
.clause-loc { font-size: 11px; color: var(--mc-text-tertiary); font-family: var(--mc-font-mono); }
|
||||
|
||||
.clause-quote { font-size: 13px; color: var(--mc-text-primary); line-height: 1.55; margin: 0; padding: 8px 12px; background: var(--mc-bg-muted); border-radius: 8px; font-style: italic; }
|
||||
.clause-deviation { font-size: 12px; color: var(--mc-text-secondary); display: flex; gap: 8px; align-items: baseline; line-height: 1.5; }
|
||||
.dev-label, .suggest-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--mc-text-tertiary); white-space: nowrap; }
|
||||
.dev-label { color: #b45309; }
|
||||
.suggest-label { color: var(--mc-primary-hover); }
|
||||
|
||||
.clause-suggest {
|
||||
background: var(--mc-primary-bg);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.suggest-text { font-size: 13px; color: var(--mc-text-primary); line-height: 1.55; }
|
||||
|
||||
/* === approval chain === */
|
||||
.approval-chain {
|
||||
border-top: 1px solid var(--mc-border-light);
|
||||
padding-top: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.chain-title { font-size: 11px; font-weight: 700; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.chain-list { list-style: none; margin: 0; padding: 0; display: flex; gap: 0; }
|
||||
.chain-step { flex: 1; display: flex; gap: 10px; align-items: flex-start; padding-right: 12px; position: relative; }
|
||||
.chain-step::after {
|
||||
content: ''; position: absolute; top: 13px; right: 0; left: 38px; height: 2px; background: var(--mc-border-light);
|
||||
}
|
||||
.chain-step.done::after { background: var(--mc-primary); }
|
||||
.chain-step:last-child::after { display: none; }
|
||||
.chain-bullet {
|
||||
width: 26px; height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--mc-bg-muted);
|
||||
color: var(--mc-text-tertiary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--mc-border-light);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.chain-step.done .chain-bullet { background: var(--mc-primary); color: white; border-color: var(--mc-primary); }
|
||||
.chain-step.active .chain-bullet { background: var(--mc-primary-bg); color: var(--mc-primary-hover); border-color: var(--mc-primary); }
|
||||
.chain-body { padding-top: 2px; min-width: 0; }
|
||||
.chain-who { font-size: 12px; font-weight: 600; color: var(--mc-text-primary); }
|
||||
.chain-note { font-size: 11px; color: var(--mc-text-secondary); margin-top: 2px; line-height: 1.4; }
|
||||
.chain-at { font-size: 10px; color: var(--mc-text-tertiary); margin-top: 2px; font-family: var(--mc-font-mono); }
|
||||
|
||||
.chain-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.btn-primary, .btn-secondary {
|
||||
border-radius: 8px;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-primary { background: var(--mc-primary); color: white; }
|
||||
.btn-primary:hover { background: var(--mc-primary-hover); }
|
||||
.btn-secondary { background: var(--mc-bg-elevated); color: var(--mc-text-primary); border-color: var(--mc-border); }
|
||||
.btn-secondary:hover { background: var(--mc-bg-sunken); }
|
||||
|
||||
/* === evidence pane === */
|
||||
.evidence-head { display: flex; flex-direction: column; gap: 4px; }
|
||||
.evidence-sub { font-size: 11px; color: var(--mc-text-tertiary); margin: 0; }
|
||||
.evidence-stack { display: flex; flex-direction: column; gap: 14px; overflow-y: auto; flex: 1; }
|
||||
.evidence-block {
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
background: var(--mc-bg-muted);
|
||||
}
|
||||
.block-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--mc-text-tertiary); margin-bottom: 8px; }
|
||||
.block-quote { font-size: 12px; color: var(--mc-text-primary); line-height: 1.55; font-style: italic; }
|
||||
.block-loc { font-size: 11px; color: var(--mc-text-tertiary); margin-top: 6px; font-family: var(--mc-font-mono); }
|
||||
|
||||
.precedent-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.precedent-headline { font-size: 12px; font-weight: 600; color: var(--mc-text-primary); }
|
||||
.precedent-outcome { font-size: 11px; color: var(--mc-text-secondary); margin-top: 2px; line-height: 1.4; }
|
||||
|
||||
.evidence-foot {
|
||||
border-top: 1px solid var(--mc-border-light);
|
||||
padding-top: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.foot-meta { display: flex; flex-direction: column; gap: 2px; }
|
||||
.foot-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--mc-text-tertiary); }
|
||||
.foot-value { font-size: 12px; color: var(--mc-text-primary); font-weight: 500; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.review-shell { grid-template-columns: 240px 1fr 280px; }
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.review-shell { grid-template-columns: 1fr; }
|
||||
.review-list, .review-evidence { max-height: 240px; }
|
||||
}
|
||||
</style>
|
||||
405
mateclaw-ui/src/views/Enterprise/Overview.vue
Normal file
405
mateclaw-ui/src/views/Enterprise/Overview.vue
Normal file
@ -0,0 +1,405 @@
|
||||
<template>
|
||||
<div class="overview">
|
||||
<div class="metric-strip">
|
||||
<div class="metric" v-for="m in metrics" :key="m.key" :class="m.tone">
|
||||
<div class="metric-label">{{ m.label }}</div>
|
||||
<div class="metric-value">{{ m.value }}</div>
|
||||
<div class="metric-delta" :class="m.deltaTone">{{ m.delta }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overview-grid">
|
||||
<article class="panel work-queue mc-surface-card">
|
||||
<header class="panel-head">
|
||||
<div>
|
||||
<h3 class="panel-title">{{ t('enterprise.overview.queueTitle') }}</h3>
|
||||
<p class="panel-desc">{{ t('enterprise.overview.queueDesc') }}</p>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<button v-for="f in queueFilters" :key="f.key"
|
||||
class="chip" :class="{ active: activeFilter === f.key }"
|
||||
@click="activeFilter = f.key">
|
||||
{{ f.label }}
|
||||
<span class="chip-count">{{ f.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ul class="queue-list">
|
||||
<li v-for="item in filteredQueue" :key="item.id" class="queue-item" @click="openCase(item.id)">
|
||||
<span class="risk-pill" :class="`risk-${item.risk}`">{{ riskLabel(item.risk) }}</span>
|
||||
<div class="queue-main">
|
||||
<div class="queue-title">{{ item.title }}</div>
|
||||
<div class="queue-meta">
|
||||
<span>{{ item.type }}</span>
|
||||
<span class="dot"></span>
|
||||
<span>{{ item.owner }}</span>
|
||||
<span class="dot"></span>
|
||||
<span>{{ item.eta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="queue-status" :class="`status-${item.status}`">
|
||||
{{ statusLabel(item.status) }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="panel evidence-panel mc-surface-card">
|
||||
<header class="panel-head">
|
||||
<div>
|
||||
<h3 class="panel-title">{{ t('enterprise.overview.evidenceTitle') }}</h3>
|
||||
<p class="panel-desc">{{ t('enterprise.overview.evidenceDesc') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="coverage">
|
||||
<div class="coverage-bar">
|
||||
<div class="coverage-fill" :style="{ width: '98%' }"></div>
|
||||
</div>
|
||||
<div class="coverage-text">
|
||||
<strong>98%</strong>
|
||||
<span>{{ t('enterprise.overview.coverageNote') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="evidence-list">
|
||||
<li v-for="e in recentEvidence" :key="e.id" class="evidence-item">
|
||||
<div class="evidence-marker" :class="`evidence-${e.kind}`"></div>
|
||||
<div class="evidence-body">
|
||||
<div class="evidence-quote">「{{ e.quote }}」</div>
|
||||
<div class="evidence-meta">
|
||||
<span>{{ e.source }}</span>
|
||||
<span class="dot"></span>
|
||||
<span>{{ e.age }}</span>
|
||||
<span v-if="e.kind === 'playbook'" class="evidence-tag tag-playbook">{{ t('enterprise.overview.tagPlaybook') }}</span>
|
||||
<span v-else-if="e.kind === 'precedent'" class="evidence-tag tag-precedent">{{ t('enterprise.overview.tagPrecedent') }}</span>
|
||||
<span v-else class="evidence-tag tag-source">{{ t('enterprise.overview.tagSource') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="panel pipeline-panel mc-surface-card">
|
||||
<header class="panel-head">
|
||||
<div>
|
||||
<h3 class="panel-title">{{ t('enterprise.overview.pipelineTitle') }}</h3>
|
||||
<p class="panel-desc">{{ t('enterprise.overview.pipelineDesc') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="pipeline">
|
||||
<div class="pipeline-step" v-for="(s, i) in pipeline" :key="s.label">
|
||||
<div class="step-bullet" :class="{ done: s.done, active: !s.done && i === firstActive }">{{ i + 1 }}</div>
|
||||
<div class="step-body">
|
||||
<div class="step-label">{{ s.label }}</div>
|
||||
<div class="step-desc">{{ s.desc }}</div>
|
||||
</div>
|
||||
<div v-if="i < pipeline.length - 1" class="step-connector" :class="{ done: s.done }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{ (e: 'open-case', id: string): void }>()
|
||||
|
||||
const activeFilter = ref<'all' | 'high' | 'pending' | 'today'>('all')
|
||||
|
||||
const metrics = computed(() => [
|
||||
{ key: 'pending', label: t('enterprise.overview.metricPending'), value: '23', delta: '+4', deltaTone: 'up', tone: '' },
|
||||
{ key: 'risk', label: t('enterprise.overview.metricHighRisk'), value: '7', delta: '+2', deltaTone: 'up bad', tone: 'tone-warn' },
|
||||
{ key: 'account', label: t('enterprise.overview.metricAccounts'), value: '14', delta: '+1', deltaTone: 'up', tone: '' },
|
||||
{ key: 'cited', label: t('enterprise.overview.metricCited'), value: '98%', delta: '+3pp', deltaTone: 'up', tone: 'tone-good' },
|
||||
])
|
||||
|
||||
const queueFilters = computed(() => [
|
||||
{ key: 'all', label: t('enterprise.overview.filterAll'), count: 23 },
|
||||
{ key: 'high', label: t('enterprise.overview.filterHighRisk'), count: 7 },
|
||||
{ key: 'pending', label: t('enterprise.overview.filterPending'), count: 12 },
|
||||
{ key: 'today', label: t('enterprise.overview.filterToday'), count: 5 },
|
||||
])
|
||||
|
||||
interface QueueItem {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
owner: string
|
||||
eta: string
|
||||
risk: 'high' | 'medium' | 'low'
|
||||
status: 'ai_reviewed' | 'pending_legal' | 'approved' | 'signal'
|
||||
}
|
||||
|
||||
const queue: QueueItem[] = [
|
||||
{ id: 'msa-acme-2026q2', title: 'Acme Corp - MSA 续约(赔偿条款异常)', type: '合同审查', owner: '李法务', eta: '今天', risk: 'high', status: 'ai_reviewed' },
|
||||
{ id: 'nda-vendora', title: 'Vendor A NDA - 非常规竞业条款', type: '合同审查', owner: '王法务', eta: '今天', risk: 'high', status: 'pending_legal' },
|
||||
{ id: 'signal-acme-jd', title: 'Acme 招了新 CTO,10 月 14 日', type: '情报信号', owner: '张销售', eta: '6 小时前', risk: 'low', status: 'signal' },
|
||||
{ id: 'q2-vendor-cmp', title: 'Q2 供应商条款横向对比', type: '合同对比', owner: '李法务', eta: '昨天', risk: 'medium', status: 'ai_reviewed' },
|
||||
{ id: 'msa-bayer-renew', title: 'Bayer MSA - 自动续约条款偏离 playbook', type: '合同审查', owner: '王法务', eta: '昨天', risk: 'medium', status: 'ai_reviewed' },
|
||||
{ id: 'signal-zerto-pr', title: 'Zerto 发布与我们竞品的新版本', type: '情报信号', owner: '张销售', eta: '昨天', risk: 'low', status: 'signal' },
|
||||
{ id: 'nda-finchen', title: 'FinChen NDA - 仲裁地条款', type: '合同审查', owner: '李法务', eta: '前天', risk: 'low', status: 'approved' },
|
||||
]
|
||||
|
||||
const filteredQueue = computed(() => {
|
||||
switch (activeFilter.value) {
|
||||
case 'high': return queue.filter(q => q.risk === 'high')
|
||||
case 'pending': return queue.filter(q => q.status === 'pending_legal' || q.status === 'ai_reviewed')
|
||||
case 'today': return queue.filter(q => q.eta === '今天' || q.eta.includes('小时'))
|
||||
default: return queue
|
||||
}
|
||||
})
|
||||
|
||||
function openCase(id: string) {
|
||||
emit('open-case', id)
|
||||
}
|
||||
|
||||
function riskLabel(r: 'high' | 'medium' | 'low'): string {
|
||||
return r === 'high' ? t('enterprise.risk.high')
|
||||
: r === 'medium' ? t('enterprise.risk.medium')
|
||||
: t('enterprise.risk.low')
|
||||
}
|
||||
|
||||
function statusLabel(s: QueueItem['status']): string {
|
||||
switch (s) {
|
||||
case 'ai_reviewed': return t('enterprise.status.aiReviewed')
|
||||
case 'pending_legal': return t('enterprise.status.pendingLegal')
|
||||
case 'approved': return t('enterprise.status.approved')
|
||||
case 'signal': return t('enterprise.status.signal')
|
||||
}
|
||||
}
|
||||
|
||||
interface Evidence { id: string; quote: string; source: string; age: string; kind: 'playbook' | 'precedent' | 'source' }
|
||||
const recentEvidence: Evidence[] = [
|
||||
{ id: 'e1', quote: '本协议双方因履行本协议发生的争议,应当先行协商解决',
|
||||
source: 'Acme MSA · 第 8.2 条 · 第 14 页', age: '12 分钟前', kind: 'source' },
|
||||
{ id: 'e2', quote: '我司标准合同要求对方承担因数据泄露产生的全部直接和间接损失',
|
||||
source: 'Legal Playbook · 数据保护条款 v3.1', age: '今天 09:42', kind: 'playbook' },
|
||||
{ id: 'e3', quote: '类似案件 2025-06 与 Zerto 谈判,最终采用阶梯式赔偿上限',
|
||||
source: '历史合同 · Zerto MSA 2025', age: '今天 09:12', kind: 'precedent' },
|
||||
{ id: 'e4', quote: 'Acme 在 LinkedIn 上招聘 Privacy Counsel,1 个职位',
|
||||
source: 'LinkedIn 信号 · 10 月 14 日', age: '6 小时前', kind: 'source' },
|
||||
]
|
||||
|
||||
const pipeline = [
|
||||
{ label: t('enterprise.overview.step1'), desc: t('enterprise.overview.step1Desc'), done: true },
|
||||
{ label: t('enterprise.overview.step2'), desc: t('enterprise.overview.step2Desc'), done: true },
|
||||
{ label: t('enterprise.overview.step3'), desc: t('enterprise.overview.step3Desc'), done: true },
|
||||
{ label: t('enterprise.overview.step4'), desc: t('enterprise.overview.step4Desc'), done: false },
|
||||
{ label: t('enterprise.overview.step5'), desc: t('enterprise.overview.step5Desc'), done: false },
|
||||
]
|
||||
const firstActive = computed(() => pipeline.findIndex(s => !s.done))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overview { display: flex; flex-direction: column; gap: 18px; }
|
||||
|
||||
/* === metric strip === */
|
||||
.metric-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.metric {
|
||||
background: var(--mc-bg-elevated);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
box-shadow: var(--mc-shadow-soft);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.metric::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 3px;
|
||||
background: var(--mc-border);
|
||||
}
|
||||
.metric.tone-warn::before { background: #d97706; }
|
||||
.metric.tone-good::before { background: #15803d; }
|
||||
.metric-label { font-size: 12px; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.metric-value { font-size: 28px; font-weight: 700; color: var(--mc-text-primary); line-height: 1; }
|
||||
.metric-delta { font-size: 12px; color: var(--mc-text-secondary); }
|
||||
.metric-delta.up { color: #15803d; }
|
||||
.metric-delta.up.bad { color: #b91c1c; }
|
||||
|
||||
/* === panels === */
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.85fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.panel {
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
.panel-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
|
||||
.panel-title { font-size: 16px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 2px; }
|
||||
.panel-desc { font-size: 12px; color: var(--mc-text-secondary); margin: 0; max-width: 480px; line-height: 1.5; }
|
||||
|
||||
/* === filter chips === */
|
||||
.filter-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.chip {
|
||||
border: 1px solid var(--mc-border-light);
|
||||
background: var(--mc-bg-elevated);
|
||||
color: var(--mc-text-secondary);
|
||||
font-size: 12px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.chip:hover { color: var(--mc-text-primary); border-color: var(--mc-border); }
|
||||
.chip.active { background: var(--mc-primary-bg); color: var(--mc-primary-hover); border-color: var(--mc-primary); }
|
||||
.chip-count { font-size: 11px; opacity: 0.7; }
|
||||
|
||||
/* === queue list === */
|
||||
.queue-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
||||
.queue-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--mc-border-light);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.queue-item:hover { background: var(--mc-bg-muted); margin: 0 -8px; padding: 12px 8px; border-radius: 8px; border-bottom-color: transparent; }
|
||||
.queue-item:last-child { border-bottom: none; }
|
||||
|
||||
.risk-pill {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
min-width: 44px;
|
||||
text-align: center;
|
||||
}
|
||||
.risk-high { background: #fee2e2; color: #b91c1c; }
|
||||
.risk-medium { background: #fef3c7; color: #b45309; }
|
||||
.risk-low { background: #dcfce7; color: #15803d; }
|
||||
|
||||
.queue-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.queue-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--mc-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.queue-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.dot { width: 3px; height: 3px; background: currentColor; border-radius: 50%; opacity: 0.5; }
|
||||
|
||||
.queue-status {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 4px 9px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-ai_reviewed { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
|
||||
.status-pending_legal { background: #fef3c7; color: #b45309; }
|
||||
.status-approved { background: #dcfce7; color: #15803d; }
|
||||
.status-signal { background: var(--mc-accent-soft); color: var(--mc-accent); }
|
||||
|
||||
/* === evidence panel === */
|
||||
.coverage { background: var(--mc-bg-muted); padding: 12px 14px; border-radius: 10px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.coverage-bar { height: 8px; background: var(--mc-bg-sunken); border-radius: 999px; overflow: hidden; }
|
||||
.coverage-fill { height: 100%; background: linear-gradient(90deg, var(--mc-primary), var(--mc-primary-light)); border-radius: 999px; }
|
||||
.coverage-text { display: flex; align-items: baseline; gap: 8px; font-size: 12px; color: var(--mc-text-secondary); }
|
||||
.coverage-text strong { font-size: 18px; font-weight: 700; color: var(--mc-primary); }
|
||||
|
||||
.evidence-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.evidence-item { display: flex; gap: 10px; }
|
||||
.evidence-marker {
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
.evidence-source { background: var(--mc-border); }
|
||||
.evidence-playbook { background: var(--mc-primary); }
|
||||
.evidence-precedent { background: var(--mc-accent); }
|
||||
|
||||
.evidence-body { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.evidence-quote {
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-primary);
|
||||
line-height: 1.55;
|
||||
font-style: italic;
|
||||
}
|
||||
.evidence-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--mc-text-tertiary);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.evidence-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.tag-source { background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
|
||||
.tag-playbook { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
|
||||
.tag-precedent { background: var(--mc-accent-soft); color: var(--mc-accent); }
|
||||
|
||||
/* === pipeline === */
|
||||
.pipeline { display: flex; gap: 0; }
|
||||
.pipeline-step { display: flex; gap: 12px; flex: 1; align-items: flex-start; position: relative; }
|
||||
.step-bullet {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--mc-bg-muted);
|
||||
color: var(--mc-text-tertiary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--mc-border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.step-bullet.done { background: var(--mc-primary); color: white; border-color: var(--mc-primary); }
|
||||
.step-bullet.active { background: var(--mc-primary-bg); color: var(--mc-primary-hover); border-color: var(--mc-primary); }
|
||||
.step-body { flex: 1; padding-top: 5px; min-width: 0; padding-right: 12px; }
|
||||
.step-label { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); }
|
||||
.step-desc { font-size: 11px; color: var(--mc-text-tertiary); margin-top: 2px; line-height: 1.4; }
|
||||
.step-connector { position: absolute; top: 16px; left: 32px; right: -12px; height: 2px; background: var(--mc-border-light); }
|
||||
.step-connector.done { background: var(--mc-primary); }
|
||||
.pipeline-step:last-child .step-connector { display: none; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.overview-grid { grid-template-columns: 1fr; }
|
||||
.metric-strip { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
</style>
|
||||
132
mateclaw-ui/src/views/Enterprise/index.vue
Normal file
132
mateclaw-ui/src/views/Enterprise/index.vue
Normal file
@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="enterprise-shell">
|
||||
<header class="enterprise-head">
|
||||
<div class="enterprise-eyebrow">{{ t('enterprise.eyebrow') }}</div>
|
||||
<h1 class="enterprise-title">{{ t('enterprise.title') }}</h1>
|
||||
<p class="enterprise-subtitle">{{ t('enterprise.subtitle') }}</p>
|
||||
</header>
|
||||
|
||||
<nav class="enterprise-tabs">
|
||||
<button
|
||||
v-for="tab in tabs" :key="tab.key"
|
||||
class="enterprise-tab" :class="{ active: activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
<span class="tab-label">{{ tab.label }}</span>
|
||||
<span v-if="tab.count != null" class="tab-count">{{ tab.count }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section class="enterprise-body">
|
||||
<Overview v-if="activeTab === 'overview'" @open-case="onOpenCase" />
|
||||
<ContractReview v-else-if="activeTab === 'contract'" :focus="caseFocus" />
|
||||
<AccountIntel v-else-if="activeTab === 'account'" />
|
||||
<Approvals v-else-if="activeTab === 'approvals'" />
|
||||
<Audit v-else-if="activeTab === 'audit'" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Overview from './Overview.vue'
|
||||
import ContractReview from './ContractReview.vue'
|
||||
import AccountIntel from './AccountIntel.vue'
|
||||
import Approvals from './Approvals.vue'
|
||||
import Audit from './Audit.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const activeTab = ref<'overview' | 'contract' | 'account' | 'approvals' | 'audit'>('overview')
|
||||
const caseFocus = ref<string | null>(null)
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ key: 'overview', label: t('enterprise.tabs.overview'), count: null as number | null },
|
||||
{ key: 'contract', label: t('enterprise.tabs.contract'), count: 23 },
|
||||
{ key: 'account', label: t('enterprise.tabs.account'), count: 14 },
|
||||
{ key: 'approvals', label: t('enterprise.tabs.approvals'), count: 5 },
|
||||
{ key: 'audit', label: t('enterprise.tabs.audit'), count: null as number | null },
|
||||
])
|
||||
|
||||
function onOpenCase(id: string) {
|
||||
caseFocus.value = id
|
||||
activeTab.value = 'contract'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.enterprise-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 28px 32px 24px;
|
||||
gap: 18px;
|
||||
background: var(--mc-bg);
|
||||
}
|
||||
|
||||
.enterprise-head { display: flex; flex-direction: column; gap: 4px; }
|
||||
.enterprise-eyebrow {
|
||||
font-size: var(--mc-text-xs);
|
||||
color: var(--mc-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.enterprise-title {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
.enterprise-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--mc-text-secondary);
|
||||
max-width: 720px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.enterprise-tabs {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
padding: 4px;
|
||||
background: var(--mc-bg-muted);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: 14px;
|
||||
gap: 2px;
|
||||
}
|
||||
.enterprise-tab {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--mc-text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.enterprise-tab:hover { color: var(--mc-text-primary); }
|
||||
.enterprise-tab.active {
|
||||
background: var(--mc-bg-elevated);
|
||||
color: var(--mc-primary);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||
}
|
||||
.tab-count {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--mc-primary-bg);
|
||||
color: var(--mc-primary-hover);
|
||||
}
|
||||
.enterprise-tab.active .tab-count { background: var(--mc-primary); color: white; }
|
||||
|
||||
.enterprise-body { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
</style>
|
||||
@ -354,6 +354,11 @@ const navGroups = computed(() => [
|
||||
label: t('nav.wiki'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`,
|
||||
},
|
||||
{
|
||||
path: '/enterprise',
|
||||
label: t('nav.enterprise'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7l7-4 7 4v14"/><path d="M9 9h.01"/><path d="M9 12h.01"/><path d="M9 15h.01"/><path d="M9 18h.01"/><path d="M15 9h.01"/><path d="M15 12h.01"/><path d="M15 15h.01"/><path d="M15 18h.01"/></svg>`,
|
||||
},
|
||||
{
|
||||
path: '/memory',
|
||||
label: t('nav.memory'),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user