feat(ui): enterprise scenario workbench combining contract review, account intel, approvals, and audit

This commit is contained in:
matevip 2026-05-12 11:26:05 +08:00
parent 8d01efbbb1
commit 093925e191
11 changed files with 2285 additions and 0 deletions

1
.gitignore vendored
View File

@ -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

View File

@ -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',

View File

@ -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',

View File

@ -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',

View 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 Counsel4 个职位),暗示其加速合规建设', 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>

View 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>

View 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>

View 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>

View 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 招了新 CTO10 月 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 Counsel1 个职位',
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>

View 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>

View File

@ -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'),