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

774 lines
26 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="digital-employees-view skill-center">
<Transition name="skill-view" mode="out-in">
<article
v-if="selectedEmployee"
key="detail"
class="skill-detail digital-employee-detail json-risk-skill-detail"
>
<div class="detail-scroll">
<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>
</section>
<TableLoadingState
v-else-if="detailLoading && selectedEmployee.loading"
class="detail-loading-state panel"
variant="panel"
title="正在加载数字员工详情"
message="列表数据已就绪,正在补充 Skills 源文件和执行配置"
icon="mdi mdi-account-cog-outline"
:show-skeleton="false"
/>
<AuditDigitalEmployeeDetail
v-else
:selected-skill="selectedEmployee"
:can-edit="canEditDigitalEmployeeSource"
:detail-busy="detailBusy"
:action-state="actionState"
@save-source="saveDigitalEmployeeSource"
/>
</div>
<footer class="detail-actions digital-employee-detail-actions">
<button class="back-action" type="button" @click="closeEmployeeDetail">
<i class="mdi mdi-arrow-left"></i>
<span>返回数字员工列表</span>
</button>
<div class="detail-action-group">
<button
class="minor-action enable-action"
:class="{ 'is-on': selectedEmployee.statusValue === 'active' }"
type="button"
:disabled="!canOperateDigitalEmployee || detailBusy"
@click="toggleDigitalEmployeeRunning(selectedEmployee)"
>
<i :class="selectedEmployee.statusValue === 'active' ? 'mdi mdi-toggle-switch' : 'mdi mdi-toggle-switch-off-outline'"></i>
<span>{{ selectedEmployee.statusValue === 'active' ? '停止运行' : '启用运行' }}</span>
</button>
<button
class="minor-action"
type="button"
:disabled="!canOperateDigitalEmployee || detailBusy"
@click="openDigitalEmployeeSchedule(selectedEmployee)"
>
<i class="mdi mdi-clock-edit-outline"></i>
<span>定时设置</span>
</button>
<button
class="minor-action success-action"
type="button"
:disabled="!canOperateDigitalEmployee || detailBusy"
@click="runDigitalEmployeeNow(selectedEmployee)"
>
<i class="mdi mdi-play-circle-outline"></i>
<span>{{ actionBusy(selectedEmployee.id, 'run-digital-now') ? '运行中...' : '立即运行' }}</span>
</button>
</div>
</footer>
</article>
<article
v-else
key="list"
class="skill-list digital-employees-list"
:class="{ 'panel': !workRecordDetailOpen }"
>
<nav v-if="!workRecordDetailOpen" class="status-tabs" aria-label="数字员工页签">
<button
type="button"
:class="{ active: activeSection === 'skills' }"
@click="activeSection = 'skills'"
>
数字员工
</button>
<button
type="button"
:class="{ active: activeSection === 'workRecords' }"
@click="activeSection = 'workRecords'"
>
工作记录
</button>
</nav>
<template v-if="activeSection === 'skills'">
<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="搜索技能名称、编号、执行计划或维护人" />
</label>
<AuditPickerFilter
id="status"
title="选择资产状态"
close-label="关闭资产状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedStatusLabel"
:options="statusOptions"
:selected-value="selectedStatus"
@toggle="toggleFilterPopover"
@close="closeFilterPopover"
@select="selectFilter('status', $event)"
/>
<AuditPickerFilter
id="enabled"
title="选择启动状态"
close-label="关闭启动状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedEnabledLabel"
:options="enabledStateOptions"
:selected-value="selectedEnabledState"
@toggle="toggleFilterPopover"
@close="closeFilterPopover"
@select="selectFilter('enabled', $event)"
/>
<AuditPickerFilter
id="executionMode"
title="选择执行方式"
close-label="关闭执行方式选择"
:active-filter-popover="activeFilterPopover"
:label="selectedExecutionModeLabel"
:options="executionModeOptions"
:selected-value="selectedExecutionMode"
@toggle="toggleFilterPopover"
@close="closeFilterPopover"
@select="selectFilter('executionMode', $event)"
/>
</div>
<div class="toolbar-actions">
<button
v-if="keyword || 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 digital-refresh-action"
type="button"
:disabled="loading"
@click="loadEmployees"
>
<i class="mdi mdi-refresh"></i>
<span>{{ loading ? '刷新中...' : '刷新' }}</span>
</button>
</div>
</div>
<p class="hint">
<i class="mdi mdi-information-outline"></i>
集中查看后台自动执行的技能执行计划和运行状态
</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 digital-table-wrap"
:class="{ 'is-empty': !loading && !errorMessage && !visibleEmployees.length }"
>
<div v-if="loading" class="table-state">
<TableLoadingState
variant="panel"
title="数字员工同步中"
message="正在读取后台自动执行技能列表"
icon="mdi mdi-account-cog-outline"
/>
</div>
<div v-else-if="errorMessage" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>数字员工加载失败</strong>
<p>{{ errorMessage }}</p>
</div>
<TableEmptyState
v-else-if="!visibleEmployees.length"
eyebrow="数字员工"
title="暂无匹配的数字员工"
description="当前没有符合搜索条件的后台执行技能。"
icon="mdi mdi-account-cog-outline"
tone="theme"
art-label="STAFF"
:tips="['数字员工已从规则中心拆出为独立入口', '运行与定时操作统一进入详情后处理']"
/>
<table v-else class="digital-employees-table">
<colgroup>
<col class="col-skill">
<col class="col-schedule">
<col class="col-mode">
<col class="col-skill-type">
<col class="col-status">
<col class="col-enabled">
<col class="col-updated">
</colgroup>
<thead>
<tr>
<th>技能名称</th>
<th>执行计划</th>
<th>触发方式</th>
<th>技能类型</th>
<th>资产状态</th>
<th>启动状态</th>
<th>最近更新</th>
</tr>
</thead>
<tbody>
<tr
v-for="employee in visibleEmployees"
:key="employee.id"
@click="openEmployeeDetail(employee)"
>
<td>
<div class="skill-name-cell">
<span class="skill-avatar" :class="employee.badgeTone">{{ employee.short }}</span>
<div>
<strong>{{ employee.name }}</strong>
<span class="skill-list-subtitle">{{ employee.code }}</span>
</div>
</div>
</td>
<td><span class="scope-pill">{{ employee.scope }}</span></td>
<td>{{ employee.executionMode }}</td>
<td><span class="scope-pill skill-type-pill">{{ employee.skillCategory }}</span></td>
<td>
<span :class="['status-pill', employee.statusTone]">{{ employee.status }}</span>
</td>
<td><span :class="['status-pill', employee.enabledTone]">{{ employee.enabledLabel }}</span></td>
<td>{{ employee.updatedAt || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<footer v-if="!loading && !errorMessage && visibleEmployees.length" class="list-foot">
<span class="page-summary">当前展示 {{ visibleEmployees.length }} 条数字员工</span>
</footer>
</template>
<DigitalEmployeeWorkRecords
v-else
class="digital-work-records-section"
@summary-change="emit('summary-change', $event)"
@detail-open-change="workRecordDetailOpen = $event"
/>
</article>
</Transition>
<DigitalEmployeeScheduleDialog
v-model="scheduleForm"
:open="scheduleEditorOpen"
:target-name="scheduleTarget?.name || ''"
:preview-label="schedulePreviewLabel"
:error-message="scheduleEditorError"
:busy="scheduleEditorBusy"
:can-save="canOperateDigitalEmployee"
@close="closeDigitalEmployeeSchedule"
@save="saveDigitalEmployeeSchedule"
/>
</section>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import AuditDigitalEmployeeDetail from '../components/audit/AuditDigitalEmployeeDetail.vue'
import AuditPickerFilter from '../components/audit/AuditPickerFilter.vue'
import DigitalEmployeeScheduleDialog from '../components/audit/DigitalEmployeeScheduleDialog.vue'
import DigitalEmployeeWorkRecords from '../components/audit/DigitalEmployeeWorkRecords.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useSystemState } from '../composables/useSystemState.js'
import { useToast } from '../composables/useToast.js'
import {
activateAgentAsset,
createAgentAssetVersion,
fetchAgentAssetDetail,
fetchAgentAssets,
updateAgentAsset
} from '../services/agentAssets.js'
import { runOrchestrator } from '../services/orchestrator.js'
import { isPlatformAdminUser } from '../utils/accessControl.js'
import {
DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS,
buildDigitalEmployeeDetailMeta,
buildDigitalEmployeeListMeta,
formatDigitalEmployeeCron,
isDigitalEmployeeAsset
} from './scripts/auditViewDigitalEmployeeModel.js'
import {
buildDigitalEmployeeScheduleConfig,
buildDigitalEmployeeScheduleCron,
createDigitalEmployeeScheduleForm,
resolveDigitalEmployeeScheduleValue
} from './scripts/digitalEmployeeScheduleModel.js'
import { incrementVersion } from './scripts/auditViewRuntimeModel.js'
import {
ENABLED_STATE_OPTIONS,
formatDateTime,
normalizeText,
resolveStatusMeta,
STATUS_OPTIONS
} from './scripts/auditViewModel.js'
const { currentUser } = useSystemState()
const { toast } = useToast()
const emit = defineEmits(['summary-change', 'detail-open-change'])
const employees = ref([])
const selectedEmployee = ref(null)
const selectedEmployeeId = ref('')
const activeSection = ref('skills')
const workRecordDetailOpen = ref(false)
const isDetailOpen = computed(() => Boolean(selectedEmployee.value) || (activeSection.value === 'workRecords' && workRecordDetailOpen.value))
watch(isDetailOpen, (newVal) => {
emit('detail-open-change', newVal)
}, { immediate: true })
const keyword = ref('')
const selectedStatus = ref('')
const selectedEnabledState = ref('')
const selectedExecutionMode = ref('')
const activeFilterPopover = ref('')
const loading = ref(false)
const detailLoading = ref(false)
const errorMessage = ref('')
const detailError = ref('')
const actionState = ref('')
const busyEmployeeId = ref('')
const scheduleEditorOpen = ref(false)
const scheduleTarget = ref(null)
const scheduleForm = ref(createDigitalEmployeeScheduleForm())
const scheduleEditorError = ref('')
const isAdmin = computed(() => isPlatformAdminUser(currentUser.value))
const detailBusy = computed(() => Boolean(detailLoading.value || actionState.value))
const canOperateDigitalEmployee = computed(() => isAdmin.value && Boolean(selectedEmployee.value))
const canEditDigitalEmployeeSource = computed(() => canOperateDigitalEmployee.value)
const scheduleEditorBusy = computed(() => actionState.value === 'save-digital-schedule')
const statusOptions = STATUS_OPTIONS
const enabledStateOptions = ENABLED_STATE_OPTIONS
const executionModeOptions = [
{ value: '', label: '全部执行方式' },
{ value: 'timed', label: '定时执行' },
{ value: 'manual', label: '手动触发' }
]
const selectedStatusLabel = computed(() =>
statusOptions.find((item) => item.value === selectedStatus.value)?.label || '全部状态'
)
const selectedEnabledLabel = computed(() =>
enabledStateOptions.find((item) => item.value === selectedEnabledState.value)?.label || '全部启动状态'
)
const selectedExecutionModeLabel = computed(() =>
executionModeOptions.find((item) => item.value === selectedExecutionMode.value)?.label || '全部执行方式'
)
const activeFilterTokens = computed(() => {
const tokens = []
if (selectedStatus.value) {
tokens.push(`资产状态:${selectedStatusLabel.value}`)
}
if (selectedEnabledState.value) {
tokens.push(`启动状态:${selectedEnabledLabel.value}`)
}
if (selectedExecutionMode.value) {
tokens.push(`执行方式:${selectedExecutionModeLabel.value}`)
}
return tokens
})
const schedulePreviewLabel = computed(() => {
try {
return formatDigitalEmployeeCron(buildDigitalEmployeeScheduleCron(scheduleForm.value))
} catch {
return '表达式待确认'
}
})
const visibleEmployees = computed(() => {
const searchText = normalizeText(keyword.value).toLowerCase()
return employees.value.filter((item) => {
const matchesKeyword = searchText
? [
item.name,
item.code,
item.summary,
item.owner,
item.scope,
item.executionMode,
item.skillCategory,
item.status,
item.enabledLabel
]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(searchText))
: true
const matchesStatus = selectedStatus.value ? item.statusValue === selectedStatus.value : true
const matchesEnabled = selectedEnabledState.value
? (selectedEnabledState.value === 'enabled') === Boolean(item.isEnabledValue)
: true
const matchesExecutionMode = selectedExecutionMode.value
? item.executionModeValue === selectedExecutionMode.value
: true
return matchesKeyword && matchesStatus && matchesEnabled && matchesExecutionMode
})
})
function toggleFilterPopover(id) {
activeFilterPopover.value = activeFilterPopover.value === id ? '' : id
}
function closeFilterPopover() {
activeFilterPopover.value = ''
}
function selectFilter(type, value) {
if (type === 'status') {
selectedStatus.value = value
}
if (type === 'enabled') {
selectedEnabledState.value = value
}
if (type === 'executionMode') {
selectedExecutionMode.value = value
}
closeFilterPopover()
}
function resetFilters() {
keyword.value = ''
selectedStatus.value = ''
selectedEnabledState.value = ''
selectedExecutionMode.value = ''
closeFilterPopover()
}
function resolveActor() {
const user = currentUser.value || {}
return normalizeText(user.name) || normalizeText(user.username) || 'system'
}
function buildEmployeeListItem(asset) {
const meta = buildDigitalEmployeeListMeta(asset)
const statusMeta = resolveStatusMeta(asset.status)
const displayName = meta.name || '数字员工技能'
return {
id: asset.id,
rawCode: asset.code,
short: displayName.slice(0, 2),
badgeTone: 'blue',
name: displayName,
code: meta.code,
summary: meta.summary,
owner: meta.owner,
scope: meta.scope,
executionMode: meta.executionMode,
executionModeValue: meta.executionMode === '定时执行' ? 'timed' : 'manual',
skillCategory: meta.skillCategory,
version: asset.working_version || asset.current_version || '-',
currentVersion: asset.current_version || '-',
status: statusMeta.label,
statusValue: asset.status,
statusTone: statusMeta.tone,
enabledLabel: meta.enabledLabel,
enabledTone: meta.enabledTone,
isEnabledValue: meta.enabled,
configJson: asset.config_json || {},
updatedAt: formatDateTime(asset.updated_at),
updatedAtRaw: asset.updated_at || '',
digitalEmployee: meta
}
}
function buildEmployeePlaceholder(employee) {
return {
...employee,
type: 'digitalEmployees',
typeLabel: '数字员工',
currentVersion: employee.currentVersion || employee.version || '-',
workingVersion: employee.version || '-',
markdownContent: '',
loading: true
}
}
function buildEmployeeDetail(asset) {
const meta = buildDigitalEmployeeDetailMeta({
...asset,
updated_at: formatDateTime(asset.updated_at)
})
const statusMeta = resolveStatusMeta(asset.status)
return {
id: asset.id,
type: 'digitalEmployees',
typeLabel: '数字员工',
rawCode: asset.code,
short: meta.name.slice(0, 2),
name: meta.name,
code: meta.code,
summary: meta.description,
owner: meta.owner,
reviewer: meta.reviewer,
category: meta.category,
scope: meta.scope,
version: asset.working_version || asset.current_version || '-',
currentVersion: asset.current_version || '-',
workingVersion: asset.working_version || asset.current_version || '-',
status: statusMeta.label,
statusValue: asset.status,
statusTone: statusMeta.tone,
configJson: asset.config_json || {},
updatedAt: formatDateTime(asset.updated_at),
markdownContent: meta.sourceMarkdown,
digitalEmployee: meta,
loading: false
}
}
function sortEmployees(items) {
return [...items].sort((left, right) =>
String(right.updatedAtRaw || '').localeCompare(String(left.updatedAtRaw || ''))
)
}
async function loadEmployees() {
loading.value = true
errorMessage.value = ''
try {
const payload = await fetchAgentAssets({ assetType: 'task' })
const items = Array.isArray(payload)
? payload.filter(isDigitalEmployeeAsset).map(buildEmployeeListItem)
: []
employees.value = sortEmployees(items)
} catch (error) {
errorMessage.value = error?.message || '数字员工数据加载失败,请稍后重试。'
toast(errorMessage.value)
} finally {
loading.value = false
}
}
async function loadEmployeeDetail(assetId, placeholder = null, options = {}) {
if (!assetId) {
return
}
selectedEmployeeId.value = assetId
selectedEmployee.value = placeholder
? buildEmployeePlaceholder(placeholder)
: selectedEmployee.value || { id: assetId, name: '数字员工', loading: true, digitalEmployee: {} }
detailLoading.value = true
detailError.value = ''
try {
const detail = await fetchAgentAssetDetail(assetId)
selectedEmployee.value = buildEmployeeDetail(detail)
} catch (error) {
detailError.value = error?.message || '数字员工详情加载失败,请稍后重试。'
if (!options.silent) {
toast(detailError.value)
}
} finally {
detailLoading.value = false
}
}
function openEmployeeDetail(employee) {
loadEmployeeDetail(employee.id, employee).catch(() => {})
}
function closeEmployeeDetail() {
closeDigitalEmployeeSchedule()
selectedEmployee.value = null
selectedEmployeeId.value = ''
detailError.value = ''
detailLoading.value = false
}
async function refreshAfterMutation(assetId) {
await loadEmployees()
if (selectedEmployee.value && selectedEmployeeId.value === assetId) {
const placeholder = employees.value.find((item) => item.id === assetId) || selectedEmployee.value
await loadEmployeeDetail(assetId, placeholder, { silent: true })
}
}
function actionBusy(assetId, action) {
return busyEmployeeId.value === assetId && actionState.value === action
}
async function toggleDigitalEmployeeRunning(employee) {
if (!employee || !canOperateDigitalEmployee.value || actionState.value) {
return
}
const assetId = employee.id
const shouldEnable = employee.statusValue !== 'active'
actionState.value = 'toggle-digital-running'
busyEmployeeId.value = assetId
try {
await (shouldEnable
? activateAgentAsset(assetId, { actor: resolveActor() })
: updateAgentAsset(assetId, { status: 'disabled' }, { actor: resolveActor() }))
await refreshAfterMutation(assetId)
toast(shouldEnable ? '已启用运行。' : '已停止运行。')
} catch (error) {
toast(error?.message || '运行状态更新失败,请稍后重试。')
} finally {
actionState.value = ''
busyEmployeeId.value = ''
}
}
async function saveDigitalEmployeeSource() {
if (!selectedEmployee.value || !canEditDigitalEmployeeSource.value || detailBusy.value) {
return
}
const markdown = normalizeText(selectedEmployee.value.markdownContent)
if (!markdown) {
toast('Skills Markdown 源文件不能为空。')
return
}
const nextVersion = incrementVersion(selectedEmployee.value.currentVersion)
actionState.value = 'save-digital-source'
busyEmployeeId.value = selectedEmployee.value.id
try {
await createAgentAssetVersion(
selectedEmployee.value.id,
{
version: nextVersion,
content: markdown,
content_type: 'markdown',
change_note: '通过数字员工页面更新 Skills Markdown 源文件。',
created_by: resolveActor()
},
{ actor: resolveActor() }
)
await refreshAfterMutation(selectedEmployee.value.id)
toast(`Skills 源文件已保存为 ${nextVersion}`)
} catch (error) {
toast(error?.message || 'Skills 源文件保存失败,请稍后重试。')
} finally {
actionState.value = ''
busyEmployeeId.value = ''
}
}
function openDigitalEmployeeSchedule(employee) {
if (!employee || !canOperateDigitalEmployee.value) {
return
}
scheduleTarget.value = employee
scheduleForm.value = createDigitalEmployeeScheduleForm(resolveDigitalEmployeeScheduleValue(employee))
scheduleEditorError.value = ''
scheduleEditorOpen.value = true
}
function closeDigitalEmployeeSchedule() {
if (scheduleEditorBusy.value) {
return
}
scheduleEditorOpen.value = false
scheduleTarget.value = null
scheduleEditorError.value = ''
}
async function saveDigitalEmployeeSchedule() {
const employee = scheduleTarget.value || selectedEmployee.value
if (!employee || !canOperateDigitalEmployee.value || actionState.value) {
return
}
let cron = ''
try {
cron = buildDigitalEmployeeScheduleCron(scheduleForm.value)
} catch (error) {
scheduleEditorError.value = error?.message || '定时计划格式不正确。'
return
}
const nextConfig = buildDigitalEmployeeScheduleConfig(employee.configJson, cron)
nextConfig.skill_category = employee.digitalEmployee?.skillCategory || employee.skillCategory || '整理'
nextConfig.skill_category_options = DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS
actionState.value = 'save-digital-schedule'
busyEmployeeId.value = employee.id
try {
await updateAgentAsset(employee.id, { config_json: nextConfig }, { actor: resolveActor() })
await refreshAfterMutation(employee.id)
scheduleEditorOpen.value = false
scheduleTarget.value = null
scheduleEditorError.value = ''
toast(cron ? `定时计划已更新为 ${formatDigitalEmployeeCron(cron)}` : '已改为手动触发。')
} catch (error) {
scheduleEditorError.value = error?.message || '定时计划保存失败,请稍后重试。'
toast(scheduleEditorError.value)
} finally {
actionState.value = ''
busyEmployeeId.value = ''
}
}
async function runDigitalEmployeeNow(employee) {
if (!employee || !canOperateDigitalEmployee.value || actionState.value) {
return
}
actionState.value = 'run-digital-now'
busyEmployeeId.value = employee.id
try {
const result = await runOrchestrator({
source: 'schedule',
task_id: employee.id,
message: employee.name,
context_json: {
manual_trigger: true,
entry: 'digital_employees'
}
})
toast(`已发起立即运行Run ID${result?.run_id || '-'}`)
} catch (error) {
toast(error?.message || '立即运行失败,请稍后重试。')
} finally {
actionState.value = ''
busyEmployeeId.value = ''
}
}
onMounted(() => {
loadEmployees().catch(() => {})
})
</script>
<style scoped src="../assets/styles/views/audit-view.css"></style>
<style scoped src="../assets/styles/views/audit-view-part2.css"></style>
<style scoped src="../assets/styles/views/digital-employees-view.css"></style>