Files
X-Financial/web/src/views/AuditView.vue

708 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="skill-center">
<Transition name="skill-view" mode="out-in">
<article v-if="selectedSkill" key="detail" class="skill-detail">
<div class="detail-scroll">
<section class="detail-hero panel">
<div class="hero-title">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
<h2>{{ selectedSkill.name }}</h2>
<p>{{ selectedSkill.summary || '当前资产尚未补充说明。' }}</p>
<div class="hero-review-meta">
<span>
<i class="mdi mdi-code-tags"></i>
{{ selectedSkill.code }}
</span>
<span>
<i class="mdi mdi-account-outline"></i>
负责人{{ selectedSkill.owner }}
</span>
<span>
<i class="mdi mdi-account-check-outline"></i>
审核人{{ selectedSkill.reviewer }}
</span>
<b :class="['status-pill', selectedSkill.statusTone]">{{ selectedSkill.status }}</b>
<b
v-if="selectedSkillIsRule"
:class="['status-pill', selectedSkill.reviewStatusTone]"
>
{{ selectedSkill.reviewStatusLabel }}
</b>
</div>
<div v-if="selectedSkillIsRule" class="review-note-block">
<strong>上线约束</strong>
<p>{{ activateBlockedReason || '当前规则版本审核通过后可正式上线。' }}</p>
<span v-if="showReviewNote">
审核时间{{ selectedSkill.reviewTimeLabel }}
<template v-if="selectedSkill.reviewNote"> · 审核意见{{ selectedSkill.reviewNote }}</template>
</span>
</div>
</div>
<div class="hero-stats">
<div class="hero-stat">
<span>资产编码</span>
<strong>{{ selectedSkill.code }}</strong>
</div>
<div class="hero-stat">
<span>业务域</span>
<strong>{{ selectedSkill.category }}</strong>
</div>
<div class="hero-stat">
<span>{{ selectedSkillIsRule ? '当前展示版本' : '当前版本' }}</span>
<strong>{{ selectedSkill.displayVersion || selectedSkill.version }}</strong>
</div>
<div class="hero-stat">
<span>最近更新</span>
<strong>{{ selectedSkill.updatedAt }}</strong>
</div>
</div>
</section>
<section v-if="detailError" class="detail-inline-state panel error">
<i class="mdi mdi-alert-circle-outline"></i>
<div>
<strong>资产详情加载失败</strong>
<p>{{ detailError }}</p>
</div>
<button class="state-action" type="button" @click="openAssetDetail(selectedSkill)">重新加载</button>
</section>
<section v-else-if="detailLoading && selectedSkill.loading" class="detail-inline-state panel">
<i class="mdi mdi-loading mdi-spin"></i>
<div>
<strong>正在加载资产详情</strong>
<p>列表数据已就绪正在补充版本审核和运行信息</p>
</div>
</section>
<div
v-else
class="detail-grid"
:class="{ 'skill-md-detail-grid': selectedSkill.type === 'rules' }"
>
<section class="detail-main">
<article v-if="selectedSkill.type === 'rules'" class="detail-card panel markdown-card">
<div class="card-head">
<div>
<h3>Markdown 规则内容</h3>
<p>当前展示版本{{ selectedSkill.displayVersion }}规则说明与运行时 JSON 分开编辑但保存时会一起进入版本快照</p>
</div>
<button
class="mini-btn primary"
type="button"
:disabled="!canEditMarkdown || detailBusy"
@click="saveRuleMarkdown"
>
<i class="mdi mdi-content-save-outline"></i>
<span>{{ actionState === 'save-markdown' ? '保存中...' : '保存 Markdown' }}</span>
</button>
</div>
<div v-if="detailLoading" class="subtle-banner">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在刷新规则详情...</span>
</div>
<label class="field">
<span>{{ selectedSkill.code }}</span>
<textarea
v-model="selectedSkill.markdownContent"
class="markdown-editor"
:class="{ disabled: !canEditMarkdown }"
spellcheck="false"
:readonly="!canEditMarkdown || detailBusy"
></textarea>
</label>
<div class="editor-foot">
<span>版本说明{{ selectedSkill.displayVersionChangeNote }}</span>
<span>最近保存{{ selectedSkill.updatedAt }}</span>
</div>
<div v-if="!canEditMarkdown" class="review-note-block muted">
<strong>只读模式</strong>
<p>当前账号没有规则编辑权限Markdown 仅可查看</p>
</div>
</article>
<article v-if="selectedSkill.type === 'rules'" class="detail-card panel json-editor-card">
<div class="card-head">
<div>
<h3>运行时 JSON</h3>
<p>编辑规则中心实际消费的 `config_json.runtime_rule`保存时会同步写入配置并追加到 Markdown 版本快照</p>
</div>
<button
class="mini-btn"
type="button"
:disabled="!canEditMarkdown || detailBusy"
@click="saveRuleRuntimeJson"
>
<i class="mdi mdi-code-json"></i>
<span>{{ actionState === 'save-runtime-json' ? '保存中...' : '保存 JSON' }}</span>
</button>
</div>
<div class="json-template-meta">
<span>
<strong>模板</strong>
{{ selectedSkill.ruleTemplateLabel }}
</span>
<span>
<strong>模板键</strong>
{{ selectedSkill.ruleTemplateKey || '未指定' }}
</span>
<span>
<strong>运行时类型</strong>
{{ selectedSkill.runtimeKind || 'policy_rule_draft' }}
</span>
</div>
<label class="field">
<span>config_json.runtime_rule</span>
<textarea
v-model="selectedSkill.runtimeRuleText"
class="json-editor"
:class="{ disabled: !canEditMarkdown }"
spellcheck="false"
:readonly="!canEditMarkdown || detailBusy"
></textarea>
</label>
<div class="editor-foot">
<span>JSON 必须是对象保存后会同步写入资产配置并以 `expense-rule` 代码块落到版本历史里</span>
<span>当前展示版本{{ selectedSkill.displayVersion }}</span>
</div>
<div v-if="!canEditMarkdown" class="review-note-block muted">
<strong>只读模式</strong>
<p>当前账号没有规则编辑权限运行时 JSON 仅可查看</p>
</div>
</article>
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>{{ selectedSkill.configTitle }}</h3>
<p>{{ selectedSkill.configDesc }}</p>
</div>
<span class="edit-badge">{{ selectedSkill.version }}</span>
</div>
<div class="form-grid">
<label v-for="field in selectedSkill.fields" :key="field.label" class="field">
<span>{{ field.label }}</span>
<input :value="field.value" readonly />
</label>
<label class="field span-2">
<span>适用场景</span>
<textarea rows="3" :value="selectedSkill.scope" readonly></textarea>
</label>
</div>
</article>
<article v-if="selectedSkill.type !== 'rules'" class="detail-card panel">
<div class="card-head">
<div>
<h3>{{ selectedSkill.detailTitle }}</h3>
<p>{{ selectedSkill.detailDesc }}</p>
</div>
<span class="edit-badge">{{ selectedSkill.promptSections.length }} </span>
</div>
<div class="prompt-stack">
<section
v-for="section in selectedSkill.promptSections"
:key="section.title"
class="prompt-block"
>
<header>
<strong>{{ section.title }}</strong>
<span>{{ section.intent }}</span>
</header>
<textarea rows="5" :value="section.content" readonly></textarea>
</section>
</div>
</article>
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>{{ selectedSkill.outputTitle }}</h3>
<p>{{ selectedSkill.outputDesc }}</p>
</div>
<span class="edit-badge">
{{ selectedSkill.type === 'rules' ? selectedSkill.reviewStatusLabel : selectedSkill.publishState }}
</span>
</div>
<div class="contract-grid">
<div class="contract-panel">
<h4>{{ selectedSkill.ruleListTitle }}</h4>
<ul>
<li v-for="rule in selectedSkill.outputRules" :key="rule">{{ rule }}</li>
</ul>
</div>
<div class="contract-panel">
<h4>{{ selectedSkill.checkListTitle }}</h4>
<div v-for="test in selectedSkill.tests" :key="test.name" class="test-row">
<div>
<strong>{{ test.name }}</strong>
<span>{{ test.input }}</span>
</div>
<b :class="['test-state', test.tone]">{{ test.result }}</b>
</div>
</div>
</div>
<div v-if="selectedSkillIsRule" class="review-action-strip">
<button
class="minor-action"
type="button"
:disabled="!canManageSelected || detailBusy"
@click="reviewSelectedRule('pending')"
>
<i class="mdi mdi-send-outline"></i>
<span>{{ actionState === 'review-pending' ? '提交中...' : '提交审核' }}</span>
</button>
<button
class="minor-action success-action"
type="button"
:disabled="!canManageSelected || detailBusy"
@click="reviewSelectedRule('approved')"
>
<i class="mdi mdi-check-decagram-outline"></i>
<span>{{ actionState === 'review-approved' ? '处理中...' : '审核通过' }}</span>
</button>
<button
class="minor-action danger-action"
type="button"
:disabled="!canManageSelected || detailBusy"
@click="reviewSelectedRule('rejected')"
>
<i class="mdi mdi-close-octagon-outline"></i>
<span>{{ actionState === 'review-rejected' ? '处理中...' : '驳回版本' }}</span>
</button>
</div>
<p v-if="selectedSkillIsRule && activateBlockedReason" class="action-help">
{{ activateBlockedReason }}
</p>
</article>
</section>
<aside v-if="selectedSkill.type === 'rules'" class="detail-side skill-review-side">
<article class="side-card panel review-card">
<div class="card-head">
<div>
<h3>版本信息</h3>
<p>最近 5 个规则版本仅切换当前展示内容</p>
</div>
</div>
<div v-if="selectedSkill.history.length" class="version-list">
<button
v-for="item in selectedSkill.history.slice(0, 5)"
:key="item.version + item.time"
class="version-row"
:class="{ active: item.version === selectedSkill.displayVersion }"
type="button"
@click="openVersionSwitch(item)"
>
<div class="version-row-head">
<strong>{{ item.version }}</strong>
<span class="version-current-slot">
<b v-if="item.version === selectedSkill.currentVersion" class="current-version">当前</b>
</span>
<span>{{ item.time }}</span>
</div>
<p>{{ item.note }}</p>
</button>
</div>
<div v-else class="empty-side-note">
<i class="mdi mdi-history"></i>
<span>暂无版本历史</span>
</div>
</article>
</aside>
<aside v-else class="detail-side">
<article class="side-card panel">
<div class="card-head">
<div>
<h3>{{ selectedSkill.triggerTitle }}</h3>
<p>{{ selectedSkill.triggerDesc }}</p>
</div>
</div>
<div class="tag-list">
<span v-for="item in selectedSkill.triggers" :key="item">{{ item }}</span>
</div>
</article>
<article class="side-card panel">
<div class="card-head">
<div>
<h3>{{ selectedSkill.toolTitle }}</h3>
<p>{{ selectedSkill.toolDesc }}</p>
</div>
</div>
<div v-if="selectedSkill.tools.length" class="tool-list">
<div v-for="tool in selectedSkill.tools" :key="tool.name + tool.scope" class="tool-row">
<div>
<strong>{{ tool.name }}</strong>
<span>{{ tool.scope }}</span>
</div>
<b :class="['tool-state', tool.tone]">{{ tool.mode }}</b>
</div>
</div>
<div v-else class="empty-side-note">
<i class="mdi mdi-connection"></i>
<span>暂无依赖信息</span>
</div>
</article>
<article class="side-card panel">
<div class="card-head">
<div>
<h3>{{ selectedSkill.historyTitle }}</h3>
<p>{{ selectedSkill.historyDesc }}</p>
</div>
</div>
<div v-if="selectedSkill.history.length" class="history-list">
<div v-for="item in selectedSkill.history" :key="item.version + item.time" class="history-row">
<strong>{{ item.version }}</strong>
<span>{{ item.note }}</span>
<small>{{ item.time }}</small>
</div>
</div>
<div v-else class="empty-side-note">
<i class="mdi mdi-clock-outline"></i>
<span>暂无版本记录</span>
</div>
</article>
<article class="side-card panel publish-card">
<div>
<h3>{{ selectedSkill.publishTitle }}</h3>
<p>{{ selectedSkill.publishDesc }}</p>
</div>
<div class="publish-summary">
<span>{{ selectedSkill.publishMeta }}</span>
<strong>{{ selectedSkill.publishState }}</strong>
</div>
</article>
</aside>
</div>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="closeDetail">
<i class="mdi mdi-arrow-left"></i>
<span>返回能力列表</span>
</button>
<div v-if="selectedSkillIsRule" class="detail-action-group">
<button
class="minor-action"
type="button"
:disabled="!canEditMarkdown || detailBusy"
@click="saveRuleMarkdown"
>
<i class="mdi mdi-content-save-outline"></i>
<span>{{ actionState === 'save-markdown' ? '保存中...' : '保存 Markdown' }}</span>
</button>
<button
class="major-action"
type="button"
:disabled="!canActivateSelected"
:title="activateBlockedReason"
@click="activateSelectedRule"
>
<i class="mdi mdi-rocket-launch-outline"></i>
<span>{{ actionState === 'activate' ? '上线中...' : selectedSkill.statusValue === 'active' ? '已上线' : '正式上线' }}</span>
</button>
</div>
<div v-else class="detail-action-group detail-meta-actions">
<span class="footer-note">{{ selectedSkill.publishMeta }}</span>
</div>
</footer>
</article>
<article v-else key="list" class="skill-list panel">
<nav class="status-tabs" aria-label="能力类型">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
:class="{ active: activeType === tab.id }"
@click="activeType = tab.id"
>
{{ tab.label }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<label class="search-filter">
<i class="mdi mdi-magnify"></i>
<input
v-model="keyword"
type="search"
:placeholder="searchPlaceholder"
/>
</label>
<div class="picker-filter" :class="{ open: activeFilterPopover === 'domain' }">
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'domain'"
aria-haspopup="dialog"
@click="toggleFilterPopover('domain')"
>
<span class="picker-label">{{ selectedDomainLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'domain'"
class="picker-popover"
role="dialog"
aria-label="选择业务域"
>
<header>
<strong>选择业务域</strong>
<button type="button" aria-label="关闭业务域选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
v-for="option in domainOptions"
:key="option.value || 'all-domain'"
type="button"
class="picker-option"
:class="{ active: selectedDomain === option.value }"
@click="selectFilter('domain', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<div class="picker-filter" :class="{ open: activeFilterPopover === 'owner' }">
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'owner'"
aria-haspopup="dialog"
@click="toggleFilterPopover('owner')"
>
<span class="picker-label">{{ selectedOwnerLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'owner'"
class="picker-popover"
role="dialog"
aria-label="选择负责人"
>
<header>
<strong>选择负责人</strong>
<button type="button" aria-label="关闭负责人选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
v-for="option in ownerOptions"
:key="option.value || 'all-owner'"
type="button"
class="picker-option"
:class="{ active: selectedOwner === option.value }"
@click="selectFilter('owner', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<div class="picker-filter" :class="{ open: activeFilterPopover === 'status' }">
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'status'"
aria-haspopup="dialog"
@click="toggleFilterPopover('status')"
>
<span class="picker-label">{{ selectedStatusLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'status'"
class="picker-popover"
role="dialog"
aria-label="选择状态"
>
<header>
<strong>选择状态</strong>
<button type="button" aria-label="关闭状态选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
v-for="option in statusOptions"
:key="option.value || 'all-status'"
type="button"
class="picker-option"
:class="{ active: selectedStatus === option.value }"
@click="selectFilter('status', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
</div>
<div class="toolbar-actions">
<button v-if="activeFilterTokens.length" class="ghost-filter-btn" type="button" @click="resetFilters">
<i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span>
</button>
<button class="create-btn" type="button" disabled>
<i class="mdi mdi-plus"></i>
<span>{{ createButtonLabel }}</span>
</button>
</div>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> {{ hintText }}</p>
<div v-if="activeFilterTokens.length" class="active-filter-strip">
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
{{ token }}
</span>
</div>
<div class="table-wrap" :class="{ 'is-empty': !loading && !errorMessage && !visibleSkills.length }">
<div v-if="loading" class="table-state">
<i class="mdi mdi-loading mdi-spin"></i>
<p>正在加载{{ activeTabLabel }}资产...</p>
</div>
<div v-else-if="errorMessage" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<p>{{ errorMessage }}</p>
<button type="button" class="state-action" @click="loadAssets">重新加载</button>
</div>
<TableEmptyState
v-else-if="!visibleSkills.length"
:eyebrow="auditEmptyState.eyebrow"
:title="auditEmptyState.title"
:description="auditEmptyState.desc"
:icon="auditEmptyState.icon"
:action-label="auditEmptyState.actionLabel"
:action-icon="auditEmptyState.actionIcon"
:tone="auditEmptyState.tone"
:art-label="auditEmptyState.artLabel"
:tips="auditEmptyState.tips"
@action="handleAuditEmptyAction"
/>
<table v-else>
<thead>
<tr>
<th>{{ tableColumns.name }}</th>
<th>{{ tableColumns.category }}</th>
<th>{{ tableColumns.owner }}</th>
<th>{{ tableColumns.scope }}</th>
<th>{{ tableColumns.runtime }}</th>
<th>{{ tableColumns.version }}</th>
<th>状态</th>
<th v-if="showMetricColumn">{{ tableColumns.metric }}</th>
<th>最近更新</th>
</tr>
</thead>
<tbody>
<tr
v-for="skill in visibleSkills"
:key="skill.id"
:class="{ spotlight: skill.spotlight }"
@click="openAssetDetail(skill)"
>
<td>
<div class="skill-name-cell">
<span class="skill-avatar" :class="skill.badgeTone">{{ skill.short }}</span>
<div>
<strong>{{ skill.name }}</strong>
<span>{{ skill.summary }}</span>
</div>
</div>
</td>
<td>{{ skill.category }}</td>
<td>{{ skill.owner }}</td>
<td><span class="scope-pill">{{ skill.scope }}</span></td>
<td>{{ skill.model }}</td>
<td>{{ skill.version }}</td>
<td><span class="status-pill" :class="skill.statusTone">{{ skill.status }}</span></td>
<td v-if="showMetricColumn">{{ skill.hitRate }}</td>
<td>{{ skill.updatedAt }}</td>
</tr>
</tbody>
</table>
</div>
<footer v-if="!loading && !errorMessage && visibleSkills.length" class="list-foot">
<span class="page-summary">当前展示 {{ visibleSkills.length }} 条资产</span>
</footer>
</article>
</Transition>
<ConfirmDialog
:open="Boolean(versionSwitchTarget)"
badge="切换版本"
badge-tone="info"
title="切换规则版本"
description="切换后编辑器只会替换当前展示内容,不会直接回滚后端当前版本。"
cancel-text="取消"
confirm-text="确认切换"
busy-text="切换中..."
confirm-tone="primary"
confirm-icon="mdi mdi-swap-horizontal"
@close="cancelVersionSwitch"
@confirm="confirmVersionSwitch"
>
<div class="version-modal-summary">
<div>
<span>当前展示版本</span>
<strong>{{ selectedSkill?.displayVersion }}</strong>
</div>
<i class="mdi mdi-arrow-right"></i>
<div>
<span>目标版本</span>
<strong>{{ versionSwitchTarget?.version }}</strong>
</div>
</div>
<div v-if="versionSwitchTarget" class="version-modal-note">
<strong>{{ versionSwitchTarget.note }}</strong>
<span>{{ versionSwitchTarget.time }}</span>
</div>
</ConfirmDialog>
</section>
</template>
<script src="./scripts/AuditView.js"></script>
<style scoped src="../assets/styles/views/audit-view.css"></style>