Browse Source
- Move 审计日志 after 系统管理; add 定时任务 submenu and /scheduled-tasks route - Add ScheduledTasksView with cron editing, manual run and run history drawer - Add StyleConfigView and SysConfigView under system management - Add line feedback dialog and branding composable - Remove explanatory subtitles and mock-mode notices from pages - Allow workspace proxy hosts in Vite dev server (allowedHosts, HMR port) Co-Authored-By: Claude Code <noreply@anthropic.com>master
19 changed files with 1975 additions and 103 deletions
@ -0,0 +1,520 @@ |
|||||
|
<script setup> |
||||
|
import { computed, ref, watch } from 'vue' |
||||
|
import { voucherApi } from '../api' |
||||
|
import { formatAmount } from '../utils/format' |
||||
|
import { ElMessage } from 'element-plus' |
||||
|
|
||||
|
const props = defineProps({ |
||||
|
modelValue: { type: Boolean, default: false }, |
||||
|
voucherId: { type: [String, Number], default: null }, |
||||
|
line: { type: Object, default: null }, |
||||
|
accounts: { type: Array, default: () => [] }, |
||||
|
initialTab: { type: String, default: 'feedback' } |
||||
|
}) |
||||
|
const emit = defineEmits(['update:modelValue', 'applied']) |
||||
|
|
||||
|
const AUX_FACT_FIELDS = { |
||||
|
DEPT: 'departmentId', |
||||
|
CUSTOMER: 'customerId', |
||||
|
PROJECT: 'projectId', |
||||
|
COST_CENTER: 'costCenterId', |
||||
|
EMP: 'applicantId' |
||||
|
} |
||||
|
|
||||
|
const tab = ref('feedback') |
||||
|
const loading = ref(false) |
||||
|
const testing = ref(false) |
||||
|
const applying = ref(false) |
||||
|
const context = ref(null) |
||||
|
const sourceLineNo = ref(null) |
||||
|
const accountCode = ref('') |
||||
|
const selectedFields = ref([]) |
||||
|
const aux = ref({}) |
||||
|
const reason = ref('') |
||||
|
const testResult = ref(null) |
||||
|
|
||||
|
const sourceLineLocked = computed(() => props.line?.sourceLineNo != null) |
||||
|
const sourceLines = computed(() => context.value?.sourceLines || []) |
||||
|
const sameDay = computed(() => context.value?.sameDay || { total: 0, truncated: false, lines: [] }) |
||||
|
const facts = computed(() => context.value?.facts || {}) |
||||
|
const allowlist = computed(() => context.value?.allowlist || []) |
||||
|
const contextFields = computed(() => |
||||
|
allowlist.value.filter((item) => item.field !== 'expenseCategory') |
||||
|
) |
||||
|
const categoryItem = computed(() => |
||||
|
allowlist.value.find((item) => item.field === 'expenseCategory') |
||||
|
) |
||||
|
const selectedAccount = computed(() => |
||||
|
props.accounts.find((a) => a.code === accountCode.value) |
||||
|
) |
||||
|
const requiredAuxKeys = computed(() => { |
||||
|
const json = selectedAccount.value?.auxRequiredJson |
||||
|
if (!json) { |
||||
|
return [] |
||||
|
} |
||||
|
try { |
||||
|
const parsed = JSON.parse(json) |
||||
|
return Array.isArray(parsed) ? parsed : [] |
||||
|
} catch (e) { |
||||
|
return [] |
||||
|
} |
||||
|
}) |
||||
|
const leafAccounts = computed(() => |
||||
|
props.accounts.filter((a) => a.isLeaf !== false && (!a.status || a.status === 'ACTIVE')) |
||||
|
) |
||||
|
const currentRule = computed(() => context.value?.currentRule) |
||||
|
const warnings = computed(() => context.value?.warnings || []) |
||||
|
const canApply = computed(() => Boolean(testResult.value?.passed)) |
||||
|
const failedChecks = computed(() => |
||||
|
(testResult.value?.checks || []).filter((item) => !item.passed) |
||||
|
) |
||||
|
|
||||
|
function visible(value) { |
||||
|
emit('update:modelValue', value) |
||||
|
} |
||||
|
|
||||
|
function displayValue(item) { |
||||
|
if (item.value === null || item.value === undefined || item.value === '') { |
||||
|
return '无值' |
||||
|
} |
||||
|
if (item.type === 'boolean') { |
||||
|
return item.value ? '是' : '否' |
||||
|
} |
||||
|
return String(item.value) |
||||
|
} |
||||
|
|
||||
|
function conditionLabel(item) { |
||||
|
return `${item.label}(当前值:${displayValue(item)})` |
||||
|
} |
||||
|
|
||||
|
function sourceLineLabel(line) { |
||||
|
const parts = [`#${line.lineNo}`, line.expenseCategory || '未分类'] |
||||
|
if (line.description) { |
||||
|
parts.push(line.description) |
||||
|
} |
||||
|
parts.push(formatAmount(line.amount, '')) |
||||
|
return parts.join(' · ') |
||||
|
} |
||||
|
|
||||
|
function clearResult() { |
||||
|
testResult.value = null |
||||
|
} |
||||
|
|
||||
|
async function loadContext(explicitSourceLineNo) { |
||||
|
if (!props.voucherId || !props.line?.lineNo) { |
||||
|
return |
||||
|
} |
||||
|
loading.value = true |
||||
|
try { |
||||
|
const params = |
||||
|
explicitSourceLineNo === null || explicitSourceLineNo === undefined |
||||
|
? {} |
||||
|
: { sourceLineNo: explicitSourceLineNo } |
||||
|
const data = await voucherApi.feedbackContext(props.voucherId, props.line.lineNo, params) |
||||
|
context.value = data |
||||
|
sourceLineNo.value = data.selectedSourceLineNo ?? null |
||||
|
accountCode.value = accountCode.value || props.line.accountCode || '' |
||||
|
selectedFields.value = (data.defaultFields || []).filter( |
||||
|
(field) => field !== 'expenseCategory' |
||||
|
) |
||||
|
syncAux() |
||||
|
clearResult() |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function syncAux() { |
||||
|
const next = {} |
||||
|
for (const key of requiredAuxKeys.value) { |
||||
|
next[key] = aux.value[key] ?? '' |
||||
|
} |
||||
|
aux.value = next |
||||
|
} |
||||
|
|
||||
|
function onSourceLineChange(value) { |
||||
|
accountCode.value = props.line?.accountCode || '' |
||||
|
loadContext(value) |
||||
|
} |
||||
|
|
||||
|
function onAccountChange() { |
||||
|
syncAux() |
||||
|
} |
||||
|
|
||||
|
function auxPlaceholder(key) { |
||||
|
const factField = AUX_FACT_FIELDS[key] |
||||
|
const value = factField ? facts.value[factField] : null |
||||
|
return value ? `留空自动取 ${value}` : '必填' |
||||
|
} |
||||
|
|
||||
|
function cleanAux() { |
||||
|
const result = {} |
||||
|
for (const [key, value] of Object.entries(aux.value)) { |
||||
|
if (String(value ?? '').trim()) { |
||||
|
result[key] = String(value).trim() |
||||
|
} |
||||
|
} |
||||
|
return result |
||||
|
} |
||||
|
|
||||
|
function payload() { |
||||
|
return { |
||||
|
sourceLineNo: sourceLineNo.value, |
||||
|
fields: ['expenseCategory', ...selectedFields.value], |
||||
|
accountCode: accountCode.value, |
||||
|
aux: cleanAux(), |
||||
|
reason: reason.value.trim() || null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function test() { |
||||
|
if (!sourceLineNo.value) { |
||||
|
ElMessage.warning('请先选择该分录对应的源费用行') |
||||
|
return |
||||
|
} |
||||
|
if (!accountCode.value) { |
||||
|
ElMessage.warning('请选择正确的科目') |
||||
|
return |
||||
|
} |
||||
|
testing.value = true |
||||
|
try { |
||||
|
testResult.value = await voucherApi.feedbackTest( |
||||
|
props.voucherId, |
||||
|
props.line.lineNo, |
||||
|
payload() |
||||
|
) |
||||
|
if (testResult.value.passed) { |
||||
|
ElMessage.success('测试通过,可以应用规则') |
||||
|
} else { |
||||
|
ElMessage.warning('测试未通过,请根据失败项调整条件后重试') |
||||
|
} |
||||
|
} finally { |
||||
|
testing.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function apply() { |
||||
|
if (!testResult.value?.fingerprint) { |
||||
|
ElMessage.warning('请先测试,测试通过后再应用规则') |
||||
|
return |
||||
|
} |
||||
|
applying.value = true |
||||
|
try { |
||||
|
const result = await voucherApi.feedbackApply(props.voucherId, props.line.lineNo, { |
||||
|
...payload(), |
||||
|
fingerprint: testResult.value.fingerprint |
||||
|
}) |
||||
|
if (!result.applied) { |
||||
|
testResult.value = result |
||||
|
ElMessage.warning(result.message || '规则应用前校验未通过,请查看检查项') |
||||
|
return |
||||
|
} |
||||
|
ElMessage.success( |
||||
|
result.created |
||||
|
? `规则 ${result.ruleCode} v${result.version} 已生效,请返回凭证点击「重新生成」` |
||||
|
: `规则 ${result.ruleCode} 已存在,无需重复创建;请返回凭证点击「重新生成」` |
||||
|
) |
||||
|
emit('applied', result) |
||||
|
visible(false) |
||||
|
} finally { |
||||
|
applying.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function init() { |
||||
|
tab.value = props.initialTab || 'feedback' |
||||
|
context.value = null |
||||
|
sourceLineNo.value = null |
||||
|
accountCode.value = '' |
||||
|
selectedFields.value = [] |
||||
|
aux.value = {} |
||||
|
reason.value = '' |
||||
|
testResult.value = null |
||||
|
loadContext(props.line?.sourceLineNo ?? null) |
||||
|
} |
||||
|
|
||||
|
watch( |
||||
|
() => props.modelValue, |
||||
|
(value) => { |
||||
|
if (value) { |
||||
|
init() |
||||
|
} |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
watch([sourceLineNo, accountCode, selectedFields, aux], clearResult, { deep: true }) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<el-dialog |
||||
|
:model-value="modelValue" |
||||
|
title="行反馈学习" |
||||
|
width="900px" |
||||
|
top="5vh" |
||||
|
destroy-on-close |
||||
|
@update:model-value="visible" |
||||
|
> |
||||
|
<el-tabs v-model="tab"> |
||||
|
<el-tab-pane label="反馈学习" name="feedback"> |
||||
|
<div v-loading="loading"> |
||||
|
<el-alert |
||||
|
v-if="props.line" |
||||
|
:title="`凭证第 ${props.line.lineNo} 行 · ${props.line.accountCode || '-'} ${ |
||||
|
props.line.accountName || '' |
||||
|
} · ${formatAmount(props.line.amount, '')} · ${props.line.summary || '无摘要'}`" |
||||
|
type="info" |
||||
|
:closable="false" |
||||
|
show-icon |
||||
|
style="margin-bottom: 14px" |
||||
|
/> |
||||
|
|
||||
|
<el-form label-width="120px" label-position="right" size="small"> |
||||
|
<el-form-item label="对应费用行"> |
||||
|
<div style="display: flex; flex-direction: column; gap: 6px; width: 100%"> |
||||
|
<el-select |
||||
|
v-model="sourceLineNo" |
||||
|
filterable |
||||
|
placeholder="请选择该分录对应的源费用行" |
||||
|
:disabled="sourceLineLocked" |
||||
|
style="width: 100%" |
||||
|
@change="onSourceLineChange" |
||||
|
> |
||||
|
<el-option |
||||
|
v-for="line in sourceLines" |
||||
|
:key="line.lineNo" |
||||
|
:label="sourceLineLabel(line)" |
||||
|
:value="line.lineNo" |
||||
|
/> |
||||
|
</el-select> |
||||
|
<span v-if="sourceLineLocked" class="muted"> |
||||
|
该分录已关联源费用行,不可更改 |
||||
|
</span> |
||||
|
</div> |
||||
|
</el-form-item> |
||||
|
|
||||
|
<el-form-item label="正确科目"> |
||||
|
<el-select |
||||
|
v-model="accountCode" |
||||
|
filterable |
||||
|
placeholder="选择末级科目" |
||||
|
style="width: 100%" |
||||
|
@change="onAccountChange" |
||||
|
> |
||||
|
<el-option |
||||
|
v-for="a in leafAccounts" |
||||
|
:key="a.code" |
||||
|
:label="`${a.code} ${a.name || ''}`" |
||||
|
:value="a.code" |
||||
|
/> |
||||
|
</el-select> |
||||
|
</el-form-item> |
||||
|
|
||||
|
<el-form-item |
||||
|
v-for="key in requiredAuxKeys" |
||||
|
:key="key" |
||||
|
:label="`辅助核算 ${key}`" |
||||
|
> |
||||
|
<el-input v-model="aux[key]" :placeholder="auxPlaceholder(key)" style="width: 100%" /> |
||||
|
</el-form-item> |
||||
|
|
||||
|
<el-form-item label="上下文条件"> |
||||
|
<div v-if="!categoryItem" class="muted"> |
||||
|
未找到源费用行,请先选择上方的对应费用行。 |
||||
|
</div> |
||||
|
<div v-else class="condition-list"> |
||||
|
<el-checkbox :model-value="true" disabled> |
||||
|
{{ conditionLabel(categoryItem) }} |
||||
|
</el-checkbox> |
||||
|
<el-checkbox-group v-model="selectedFields" class="condition-group"> |
||||
|
<el-checkbox |
||||
|
v-for="item in contextFields" |
||||
|
:key="item.field" |
||||
|
:value="item.field" |
||||
|
:disabled="item.value === null || item.value === undefined" |
||||
|
> |
||||
|
{{ conditionLabel(item) }} |
||||
|
</el-checkbox> |
||||
|
</el-checkbox-group> |
||||
|
</div> |
||||
|
</el-form-item> |
||||
|
|
||||
|
<el-form-item label="备注"> |
||||
|
<el-input |
||||
|
v-model="reason" |
||||
|
type="textarea" |
||||
|
:rows="2" |
||||
|
placeholder="可选,记录本次反馈原因" |
||||
|
/> |
||||
|
</el-form-item> |
||||
|
</el-form> |
||||
|
|
||||
|
<el-alert |
||||
|
v-for="warning in warnings" |
||||
|
:key="warning" |
||||
|
:title="warning" |
||||
|
type="warning" |
||||
|
:closable="false" |
||||
|
show-icon |
||||
|
style="margin-bottom: 8px" |
||||
|
/> |
||||
|
|
||||
|
<div class="section-title"> |
||||
|
同日相关费用(锚点日期:{{ context?.anchorDate || '-' }},共 |
||||
|
{{ sameDay.total }} 条) |
||||
|
</div> |
||||
|
<el-table :data="sameDay.lines" size="small" empty-text="当天没有其他相关费用"> |
||||
|
<el-table-column prop="docNo" label="单号" min-width="140" /> |
||||
|
<el-table-column prop="lineNo" label="#" width="50" /> |
||||
|
<el-table-column prop="expenseCategory" label="类别" width="100" /> |
||||
|
<el-table-column prop="description" label="摘要" min-width="150"> |
||||
|
<template #default="{ row }">{{ row.description || '-' }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="expenseCity" label="城市" width="90"> |
||||
|
<template #default="{ row }">{{ row.expenseCity || '-' }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="invoiceDate" label="开票日期" width="110"> |
||||
|
<template #default="{ row }">{{ row.invoiceDate || '-' }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="跨市" width="70"> |
||||
|
<template #default="{ row }">{{ row.crossCity ? '是' : '否' }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="金额" align="right" width="110"> |
||||
|
<template #default="{ row }">{{ formatAmount(row.amount, '') }}</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
<div v-if="sameDay.truncated" class="muted" style="margin-top: 6px"> |
||||
|
当日费用过多,仅展示前 20 条,上下文事实仍基于全部费用计算。 |
||||
|
</div> |
||||
|
|
||||
|
<template v-if="testResult"> |
||||
|
<el-alert |
||||
|
:title=" |
||||
|
testResult.passed |
||||
|
? `测试通过 · 规则 ${testResult.ruleCode} · 优先级 ${testResult.suggestedPriority}` |
||||
|
: '测试未通过,请调整条件后重试' |
||||
|
" |
||||
|
:type="testResult.passed ? 'success' : 'error'" |
||||
|
:closable="false" |
||||
|
show-icon |
||||
|
style="margin-top: 14px" |
||||
|
> |
||||
|
<div v-if="testResult.passed" class="muted"> |
||||
|
条件:{{ JSON.stringify(testResult.condition) }} |
||||
|
</div> |
||||
|
<div v-else> |
||||
|
<div v-for="check in failedChecks" :key="check.code"> |
||||
|
{{ check.code }}:{{ check.message }} |
||||
|
</div> |
||||
|
</div> |
||||
|
</el-alert> |
||||
|
<el-table :data="testResult.checks || []" size="small" style="margin-top: 8px"> |
||||
|
<el-table-column prop="code" label="检查项" width="200" /> |
||||
|
<el-table-column label="结果" width="80"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-tag :type="row.passed ? 'success' : 'danger'" size="small"> |
||||
|
{{ row.passed ? '通过' : '失败' }} |
||||
|
</el-tag> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="message" label="说明" min-width="300" /> |
||||
|
</el-table> |
||||
|
<div v-if="testResult.conflicts?.length" class="muted" style="margin-top: 8px"> |
||||
|
应用后将退役: |
||||
|
{{ testResult.conflicts.map((c) => c.ruleCode).join('、') }} |
||||
|
</div> |
||||
|
</template> |
||||
|
</div> |
||||
|
</el-tab-pane> |
||||
|
|
||||
|
<el-tab-pane label="当前规则与上下文" name="context"> |
||||
|
<div v-loading="loading"> |
||||
|
<div class="section-title">当前命中规则</div> |
||||
|
<el-descriptions v-if="currentRule" :column="2" border size="small"> |
||||
|
<el-descriptions-item label="规则编码">{{ currentRule.ruleCode }}</el-descriptions-item> |
||||
|
<el-descriptions-item label="规则名称">{{ currentRule.name }}</el-descriptions-item> |
||||
|
<el-descriptions-item label="优先级">{{ currentRule.priority }}</el-descriptions-item> |
||||
|
<el-descriptions-item label="命中条件"> |
||||
|
{{ (currentRule.matchedFields || []).join('、') || '-' }} |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="规则结果" :span="2"> |
||||
|
{{ JSON.stringify(currentRule.result) }} |
||||
|
</el-descriptions-item> |
||||
|
</el-descriptions> |
||||
|
<el-empty v-else description="当前没有规则命中该费用行" :image-size="60" /> |
||||
|
|
||||
|
<div class="section-title" style="margin-top: 16px">上下文事实</div> |
||||
|
<el-descriptions :column="2" border size="small"> |
||||
|
<el-descriptions-item |
||||
|
v-for="item in allowlist" |
||||
|
:key="item.field" |
||||
|
:label="item.label" |
||||
|
> |
||||
|
{{ displayValue(item) }} |
||||
|
</el-descriptions-item> |
||||
|
</el-descriptions> |
||||
|
<el-empty |
||||
|
v-if="!allowlist.length" |
||||
|
description="请先在「反馈学习」选择对应费用行" |
||||
|
:image-size="60" |
||||
|
/> |
||||
|
|
||||
|
<div class="section-title" style="margin-top: 16px"> |
||||
|
同日费用(共 {{ sameDay.total }} 条) |
||||
|
</div> |
||||
|
<el-table :data="sameDay.lines" size="small" empty-text="当天没有其他相关费用"> |
||||
|
<el-table-column prop="docNo" label="单号" min-width="140" /> |
||||
|
<el-table-column prop="lineNo" label="#" width="50" /> |
||||
|
<el-table-column prop="expenseCategory" label="类别" width="100" /> |
||||
|
<el-table-column prop="expenseCity" label="城市" width="90"> |
||||
|
<template #default="{ row }">{{ row.expenseCity || '-' }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="invoiceDate" label="开票日期" width="110"> |
||||
|
<template #default="{ row }">{{ row.invoiceDate || '-' }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="金额" align="right" width="110"> |
||||
|
<template #default="{ row }">{{ formatAmount(row.amount, '') }}</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</div> |
||||
|
</el-tab-pane> |
||||
|
</el-tabs> |
||||
|
|
||||
|
<template #footer> |
||||
|
<el-button @click="visible(false)">取消</el-button> |
||||
|
<el-button v-if="tab === 'feedback'" :loading="testing" @click="test">测试</el-button> |
||||
|
<el-button |
||||
|
v-if="tab === 'feedback'" |
||||
|
type="primary" |
||||
|
:loading="applying" |
||||
|
:disabled="!canApply" |
||||
|
@click="apply" |
||||
|
> |
||||
|
应用规则 |
||||
|
</el-button> |
||||
|
</template> |
||||
|
</el-dialog> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.condition-list { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 2px; |
||||
|
} |
||||
|
|
||||
|
.condition-group { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)); |
||||
|
} |
||||
|
|
||||
|
.section-title { |
||||
|
margin: 14px 0 8px; |
||||
|
font-weight: 600; |
||||
|
font-size: 13px; |
||||
|
} |
||||
|
|
||||
|
.muted { |
||||
|
color: #8a94a6; |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,41 @@ |
|||||
|
import { reactive } from 'vue' |
||||
|
import { systemConfigApi } from '../api' |
||||
|
|
||||
|
const state = reactive({ |
||||
|
platformName: 'AutoVoucher', |
||||
|
logo: null, |
||||
|
loaded: false |
||||
|
}) |
||||
|
|
||||
|
let pending = null |
||||
|
|
||||
|
export function useBranding() { |
||||
|
async function load(force = false) { |
||||
|
if (state.loaded && !force) { |
||||
|
return state |
||||
|
} |
||||
|
if (pending) { |
||||
|
return pending |
||||
|
} |
||||
|
pending = systemConfigApi |
||||
|
.get() |
||||
|
.then((data) => { |
||||
|
apply(data || {}) |
||||
|
state.loaded = true |
||||
|
return state |
||||
|
}) |
||||
|
.catch(() => state) |
||||
|
.finally(() => { |
||||
|
pending = null |
||||
|
}) |
||||
|
return pending |
||||
|
} |
||||
|
|
||||
|
function apply(data) { |
||||
|
state.platformName = data.platformName || 'AutoVoucher' |
||||
|
state.logo = data.logo || null |
||||
|
state.loaded = true |
||||
|
} |
||||
|
|
||||
|
return { branding: state, load, apply } |
||||
|
} |
||||
@ -0,0 +1,439 @@ |
|||||
|
<script setup> |
||||
|
import { onMounted, onUnmounted, reactive, ref } from 'vue' |
||||
|
import { ElMessage, ElMessageBox } from 'element-plus' |
||||
|
import { scheduledJobApi } from '../api' |
||||
|
import JsonBlock from '../components/JsonBlock.vue' |
||||
|
import StatusTag from '../components/StatusTag.vue' |
||||
|
import { formatDateTime } from '../utils/format' |
||||
|
|
||||
|
const JOB_TYPE_LABELS = { |
||||
|
OA_FETCH: 'OA 单据拉取', |
||||
|
NCC_PUSH: 'NCC 凭证推送' |
||||
|
} |
||||
|
|
||||
|
const TRIGGER_LABELS = { |
||||
|
SCHEDULED: '定时', |
||||
|
MANUAL: '手动' |
||||
|
} |
||||
|
|
||||
|
const loading = ref(false) |
||||
|
const jobs = ref([]) |
||||
|
const updatingId = ref(null) |
||||
|
const saving = ref(false) |
||||
|
|
||||
|
const editVisible = ref(false) |
||||
|
const editingJob = ref(null) |
||||
|
const form = reactive({ |
||||
|
cronExpression: '', |
||||
|
description: '' |
||||
|
}) |
||||
|
|
||||
|
const historyVisible = ref(false) |
||||
|
const historyJob = ref(null) |
||||
|
const runs = ref([]) |
||||
|
const runsLoading = ref(false) |
||||
|
|
||||
|
let listTimer = null |
||||
|
let runTimer = null |
||||
|
let loadingPromise = null |
||||
|
|
||||
|
function jobTypeLabel(type) { |
||||
|
return JOB_TYPE_LABELS[type] || type || '-' |
||||
|
} |
||||
|
|
||||
|
function triggerLabel(trigger) { |
||||
|
return TRIGGER_LABELS[trigger] || trigger || '-' |
||||
|
} |
||||
|
|
||||
|
function formatDuration(ms) { |
||||
|
if (ms === null || ms === undefined) { |
||||
|
return '-' |
||||
|
} |
||||
|
if (ms < 1000) { |
||||
|
return `${ms} ms` |
||||
|
} |
||||
|
return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s` |
||||
|
} |
||||
|
|
||||
|
function hasRunning() { |
||||
|
return jobs.value.some((job) => job.running || job.lastStatus === 'RUNNING') |
||||
|
} |
||||
|
|
||||
|
function syncRunTimer() { |
||||
|
if (hasRunning()) { |
||||
|
if (!runTimer) { |
||||
|
runTimer = setInterval(refreshWhileRunning, 2000) |
||||
|
} |
||||
|
} else if (runTimer) { |
||||
|
clearInterval(runTimer) |
||||
|
runTimer = null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function refreshWhileRunning() { |
||||
|
await load() |
||||
|
if (historyVisible.value) { |
||||
|
await loadRuns() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function load() { |
||||
|
if (loadingPromise) { |
||||
|
return loadingPromise |
||||
|
} |
||||
|
loading.value = true |
||||
|
loadingPromise = scheduledJobApi |
||||
|
.list() |
||||
|
.then((data) => { |
||||
|
jobs.value = data |
||||
|
syncRunTimer() |
||||
|
}) |
||||
|
.finally(() => { |
||||
|
loading.value = false |
||||
|
loadingPromise = null |
||||
|
}) |
||||
|
return loadingPromise |
||||
|
} |
||||
|
|
||||
|
async function confirmToggle(row) { |
||||
|
const next = !row.enabled |
||||
|
try { |
||||
|
await ElMessageBox.confirm( |
||||
|
next |
||||
|
? `确定启用任务「${row.jobName}」?启用后将按 Cron 自动执行。` |
||||
|
: `确定停用任务「${row.jobName}」?停用后不再自动执行,仍可手动触发。`, |
||||
|
next ? '启用确认' : '停用确认', |
||||
|
{ |
||||
|
type: 'warning', |
||||
|
confirmButtonText: next ? '启用' : '停用', |
||||
|
cancelButtonText: '取消' |
||||
|
} |
||||
|
) |
||||
|
} catch (e) { |
||||
|
return false |
||||
|
} |
||||
|
updatingId.value = row.id |
||||
|
try { |
||||
|
await scheduledJobApi.update(row.id, { enabled: next }) |
||||
|
ElMessage.success(next ? '任务已启用' : '任务已停用') |
||||
|
await load() |
||||
|
return true |
||||
|
} catch (e) { |
||||
|
return false |
||||
|
} finally { |
||||
|
updatingId.value = null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function openEdit(row) { |
||||
|
editingJob.value = row |
||||
|
form.cronExpression = row.cronExpression || '' |
||||
|
form.description = row.description || '' |
||||
|
editVisible.value = true |
||||
|
} |
||||
|
|
||||
|
async function saveEdit() { |
||||
|
const cron = form.cronExpression.trim() |
||||
|
if (!cron) { |
||||
|
ElMessage.error('Cron 表达式不能为空') |
||||
|
return |
||||
|
} |
||||
|
saving.value = true |
||||
|
try { |
||||
|
await scheduledJobApi.update(editingJob.value.id, { |
||||
|
cronExpression: cron, |
||||
|
// Empty string clears the description; null would mean "leave unchanged". |
||||
|
description: form.description.trim() |
||||
|
}) |
||||
|
ElMessage.success('任务已更新,新的执行计划立即生效') |
||||
|
editVisible.value = false |
||||
|
await load() |
||||
|
} catch (e) { |
||||
|
// The HTTP layer already surfaces JOB_CRON_INVALID and similar messages. |
||||
|
} finally { |
||||
|
saving.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function runNow(row) { |
||||
|
try { |
||||
|
await ElMessageBox.confirm( |
||||
|
`确定立即执行任务「${row.jobName}」?任务将在后台执行,可在运行历史中查看结果。`, |
||||
|
'执行确认', |
||||
|
{ type: 'warning', confirmButtonText: '立即执行', cancelButtonText: '取消' } |
||||
|
) |
||||
|
} catch (e) { |
||||
|
return |
||||
|
} |
||||
|
try { |
||||
|
const result = await scheduledJobApi.run(row.id) |
||||
|
ElMessage.success(`已触发,运行编号 #${result.runId}`) |
||||
|
await openHistory(row) |
||||
|
await load() |
||||
|
} catch (e) { |
||||
|
// Conflict (409) and executor unavailable (503) messages come from the interceptor. |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function openHistory(row) { |
||||
|
historyJob.value = row |
||||
|
historyVisible.value = true |
||||
|
await loadRuns() |
||||
|
} |
||||
|
|
||||
|
async function loadRuns() { |
||||
|
if (!historyJob.value) { |
||||
|
return |
||||
|
} |
||||
|
runsLoading.value = true |
||||
|
try { |
||||
|
runs.value = await scheduledJobApi.runs(historyJob.value.id, { limit: 100 }) |
||||
|
} finally { |
||||
|
runsLoading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function closeHistory() { |
||||
|
historyJob.value = null |
||||
|
runs.value = [] |
||||
|
} |
||||
|
|
||||
|
onMounted(() => { |
||||
|
load() |
||||
|
listTimer = setInterval(load, 10000) |
||||
|
}) |
||||
|
|
||||
|
onUnmounted(() => { |
||||
|
if (listTimer) { |
||||
|
clearInterval(listTimer) |
||||
|
listTimer = null |
||||
|
} |
||||
|
if (runTimer) { |
||||
|
clearInterval(runTimer) |
||||
|
runTimer = null |
||||
|
} |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="page"> |
||||
|
<div class="page-header"> |
||||
|
<div> |
||||
|
<h2 class="page-title">定时任务</h2> |
||||
|
</div> |
||||
|
<el-button :loading="loading" @click="load">刷新</el-button> |
||||
|
</div> |
||||
|
|
||||
|
<el-card shadow="never"> |
||||
|
<el-table |
||||
|
v-loading="loading" |
||||
|
:data="jobs" |
||||
|
size="small" |
||||
|
empty-text="暂无定时任务" |
||||
|
> |
||||
|
<el-table-column label="任务" min-width="200"> |
||||
|
<template #default="{ row }"> |
||||
|
<div class="job-name">{{ row.jobName }}</div> |
||||
|
<div class="mono muted">{{ row.jobCode }}</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="类型" width="140"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-tag size="small" effect="plain">{{ jobTypeLabel(row.jobType) }}</el-tag> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="Cron" width="160"> |
||||
|
<template #default="{ row }"> |
||||
|
<span class="mono">{{ row.cronExpression || '-' }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="启用" width="90"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-switch |
||||
|
v-model="row.enabled" |
||||
|
:loading="updatingId === row.id" |
||||
|
:before-change="() => confirmToggle(row)" |
||||
|
/> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="上次执行" min-width="210"> |
||||
|
<template #default="{ row }"> |
||||
|
<template v-if="row.lastStatus"> |
||||
|
<StatusTag :status="row.lastStatus" /> |
||||
|
<span class="cell-time">{{ formatDateTime(row.lastRunAt) }}</span> |
||||
|
<span class="muted">· {{ formatDuration(row.lastDurationMs) }}</span> |
||||
|
</template> |
||||
|
<span v-else class="muted">尚未执行</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="下次执行" width="170"> |
||||
|
<template #default="{ row }"> |
||||
|
<span v-if="row.enabled">{{ formatDateTime(row.nextRunAt) }}</span> |
||||
|
<span v-else class="muted">已停用</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="最近信息" min-width="220" show-overflow-tooltip> |
||||
|
<template #default="{ row }"> |
||||
|
<span :class="row.lastStatus === 'FAILED' ? 'message-failed' : 'muted'"> |
||||
|
{{ row.lastMessage || '—' }} |
||||
|
</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="操作" width="190" fixed="right"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button link type="primary" @click="openEdit(row)">编辑</el-button> |
||||
|
<el-button |
||||
|
link |
||||
|
type="primary" |
||||
|
:disabled="row.running" |
||||
|
@click="runNow(row)" |
||||
|
> |
||||
|
立即执行 |
||||
|
</el-button> |
||||
|
<el-button link @click="openHistory(row)">历史</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
|
||||
|
<div class="table-footer muted"> |
||||
|
共 {{ jobs.length }} 个任务 · 运行中的任务每 2 秒自动刷新 |
||||
|
</div> |
||||
|
</el-card> |
||||
|
|
||||
|
<el-dialog |
||||
|
v-model="editVisible" |
||||
|
title="编辑定时任务" |
||||
|
width="560px" |
||||
|
@closed="editingJob = null" |
||||
|
> |
||||
|
<el-form v-if="editingJob" label-position="top" @submit.prevent="saveEdit"> |
||||
|
<el-form-item label="任务"> |
||||
|
<div> |
||||
|
<div class="job-name">{{ editingJob.jobName }}</div> |
||||
|
<div class="mono muted"> |
||||
|
{{ editingJob.jobCode }} · {{ jobTypeLabel(editingJob.jobType) }} |
||||
|
</div> |
||||
|
</div> |
||||
|
</el-form-item> |
||||
|
|
||||
|
<el-form-item label="Cron 表达式"> |
||||
|
<el-input |
||||
|
v-model="form.cronExpression" |
||||
|
class="mono" |
||||
|
maxlength="128" |
||||
|
placeholder="例如 0 */5 * * * *" |
||||
|
/> |
||||
|
<div class="field-hint"> |
||||
|
支持 5 段或 6 段(秒 分 时 日 月 周,0 和 7 均表示周日),5 段会自动补秒。 |
||||
|
保存后立即生效。示例:<span class="mono">0 */5 * * * *</span>(每 5 分钟)、 |
||||
|
<span class="mono">*/10 * * * * *</span>(每 10 秒)。 |
||||
|
</div> |
||||
|
</el-form-item> |
||||
|
|
||||
|
<el-form-item label="说明"> |
||||
|
<el-input |
||||
|
v-model="form.description" |
||||
|
type="textarea" |
||||
|
:rows="3" |
||||
|
maxlength="255" |
||||
|
show-word-limit |
||||
|
placeholder="选填,留空则清除说明" |
||||
|
/> |
||||
|
</el-form-item> |
||||
|
</el-form> |
||||
|
|
||||
|
<template #footer> |
||||
|
<el-button @click="editVisible = false">取消</el-button> |
||||
|
<el-button type="primary" :loading="saving" @click="saveEdit">保存</el-button> |
||||
|
</template> |
||||
|
</el-dialog> |
||||
|
|
||||
|
<el-drawer |
||||
|
v-model="historyVisible" |
||||
|
size="780px" |
||||
|
:title="historyJob ? `运行历史 · ${historyJob.jobName}` : '运行历史'" |
||||
|
@closed="closeHistory" |
||||
|
> |
||||
|
<div class="drawer-meta muted"> |
||||
|
<span class="mono">{{ historyJob?.jobCode }}</span> |
||||
|
<span>Cron:<span class="mono">{{ historyJob?.cronExpression }}</span></span> |
||||
|
<el-button size="small" :loading="runsLoading" @click="loadRuns">刷新</el-button> |
||||
|
</div> |
||||
|
|
||||
|
<el-table |
||||
|
v-loading="runsLoading" |
||||
|
:data="runs" |
||||
|
size="small" |
||||
|
empty-text="暂无运行记录" |
||||
|
max-height="640" |
||||
|
> |
||||
|
<el-table-column label="开始时间" width="170"> |
||||
|
<template #default="{ row }">{{ formatDateTime(row.startedAt) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="触发" width="80"> |
||||
|
<template #default="{ row }">{{ triggerLabel(row.triggerType) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="状态" width="100"> |
||||
|
<template #default="{ row }"><StatusTag :status="row.status" /></template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="耗时" width="90"> |
||||
|
<template #default="{ row }"> |
||||
|
<span class="mono">{{ formatDuration(row.durationMs) }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="消息" min-width="220" show-overflow-tooltip> |
||||
|
<template #default="{ row }">{{ row.message || '—' }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column type="expand"> |
||||
|
<template #default="{ row }"> |
||||
|
<div class="expand-body"> |
||||
|
<div class="muted">执行详情</div> |
||||
|
<JsonBlock :value="row.detailJson" /> |
||||
|
</div> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-drawer> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.job-name { |
||||
|
font-weight: 600; |
||||
|
color: #1f2d3d; |
||||
|
} |
||||
|
|
||||
|
.table-footer { |
||||
|
margin-top: 12px; |
||||
|
font-size: 12px; |
||||
|
text-align: right; |
||||
|
} |
||||
|
|
||||
|
.cell-time { |
||||
|
margin-left: 8px; |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
|
||||
|
.message-failed { |
||||
|
color: var(--el-color-danger); |
||||
|
} |
||||
|
|
||||
|
.field-hint { |
||||
|
margin-top: 6px; |
||||
|
color: var(--av-text-muted); |
||||
|
font-size: 12px; |
||||
|
line-height: 1.6; |
||||
|
} |
||||
|
|
||||
|
.drawer-meta { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 16px; |
||||
|
margin-bottom: 12px; |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
|
||||
|
.expand-body { |
||||
|
padding: 8px 16px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,221 @@ |
|||||
|
<script setup> |
||||
|
import { computed, onMounted, ref } from 'vue' |
||||
|
import { ElMessage } from 'element-plus' |
||||
|
import { systemConfigApi } from '../api' |
||||
|
import { useBranding } from '../composables/useBranding' |
||||
|
|
||||
|
const { branding, apply } = useBranding() |
||||
|
|
||||
|
const loading = ref(false) |
||||
|
const saving = ref(false) |
||||
|
const platformName = ref('') |
||||
|
const logo = ref(null) |
||||
|
|
||||
|
const MAX_LOGO_BYTES = 1024 * 1024 |
||||
|
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] |
||||
|
|
||||
|
const trimmedName = computed(() => platformName.value.trim()) |
||||
|
const nameValid = computed(() => trimmedName.value.length > 0 && trimmedName.value.length <= 64) |
||||
|
const dirty = computed( |
||||
|
() => |
||||
|
trimmedName.value !== branding.platformName || |
||||
|
(logo.value || null) !== (branding.logo || null) |
||||
|
) |
||||
|
|
||||
|
async function load() { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
const data = await systemConfigApi.get() |
||||
|
platformName.value = data.platformName || 'AutoVoucher' |
||||
|
logo.value = data.logo || null |
||||
|
apply(data) |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function onLogoChange(uploadFile) { |
||||
|
const file = uploadFile.raw |
||||
|
if (!file) { |
||||
|
return |
||||
|
} |
||||
|
if (!ALLOWED_TYPES.includes(file.type)) { |
||||
|
ElMessage.error('Logo 仅支持 PNG、JPG、WebP 或 GIF 图片') |
||||
|
return |
||||
|
} |
||||
|
if (file.size > MAX_LOGO_BYTES) { |
||||
|
ElMessage.error('Logo 不能超过 1MB') |
||||
|
return |
||||
|
} |
||||
|
const reader = new FileReader() |
||||
|
reader.onload = () => { |
||||
|
logo.value = reader.result |
||||
|
} |
||||
|
reader.onerror = () => ElMessage.error('图片读取失败,请重新选择') |
||||
|
reader.readAsDataURL(file) |
||||
|
} |
||||
|
|
||||
|
function removeLogo() { |
||||
|
logo.value = null |
||||
|
} |
||||
|
|
||||
|
function reset() { |
||||
|
platformName.value = branding.platformName |
||||
|
logo.value = branding.logo || null |
||||
|
} |
||||
|
|
||||
|
async function save() { |
||||
|
if (!nameValid.value) { |
||||
|
ElMessage.error('平台名称不能为空,且不能超过 64 个字符') |
||||
|
return |
||||
|
} |
||||
|
saving.value = true |
||||
|
try { |
||||
|
const data = await systemConfigApi.update({ |
||||
|
platformName: trimmedName.value, |
||||
|
logo: logo.value || null |
||||
|
}) |
||||
|
platformName.value = data.platformName |
||||
|
logo.value = data.logo || null |
||||
|
apply(data) |
||||
|
ElMessage.success('样式配置已保存') |
||||
|
} finally { |
||||
|
saving.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMounted(load) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="page"> |
||||
|
<div class="page-header"> |
||||
|
<div> |
||||
|
<h2 class="page-title">样式管理</h2> |
||||
|
<div class="page-subtitle">平台名称与 Logo 会同步展示在登录页、侧边栏和顶部标题</div> |
||||
|
</div> |
||||
|
<el-button :loading="loading" @click="load">刷新</el-button> |
||||
|
</div> |
||||
|
|
||||
|
<el-card v-loading="loading" shadow="never" class="config-card"> |
||||
|
<div class="config-section-title">品牌样式</div> |
||||
|
|
||||
|
<el-form label-position="top" @submit.prevent="save"> |
||||
|
<el-form-item label="平台名称"> |
||||
|
<el-input |
||||
|
v-model="platformName" |
||||
|
maxlength="64" |
||||
|
show-word-limit |
||||
|
placeholder="例如:AutoVoucher" |
||||
|
style="max-width: 420px" |
||||
|
/> |
||||
|
<div class="field-hint">最多 64 个字符,保存后立即生效</div> |
||||
|
</el-form-item> |
||||
|
|
||||
|
<el-form-item label="Logo"> |
||||
|
<div class="logo-row"> |
||||
|
<div class="logo-preview"> |
||||
|
<img v-if="logo" :src="logo" alt="logo preview" /> |
||||
|
<span v-else class="logo-empty">未上传</span> |
||||
|
</div> |
||||
|
<div class="logo-actions"> |
||||
|
<div class="logo-buttons"> |
||||
|
<el-upload |
||||
|
:auto-upload="false" |
||||
|
:show-file-list="false" |
||||
|
accept="image/png,image/jpeg,image/webp,image/gif" |
||||
|
:on-change="onLogoChange" |
||||
|
> |
||||
|
<el-button> |
||||
|
<el-icon><Upload /></el-icon> |
||||
|
<span>选择图片</span> |
||||
|
</el-button> |
||||
|
</el-upload> |
||||
|
<el-button v-if="logo" text type="danger" @click="removeLogo">移除 Logo</el-button> |
||||
|
</div> |
||||
|
<div class="field-hint">支持 PNG / JPG / WebP / GIF,不超过 1MB;建议使用透明背景的正方形图片</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</el-form-item> |
||||
|
</el-form> |
||||
|
|
||||
|
<div class="config-footer"> |
||||
|
<el-button :disabled="!dirty || saving" @click="reset">撤销修改</el-button> |
||||
|
<el-button type="primary" :loading="saving" :disabled="!nameValid || !dirty" @click="save"> |
||||
|
保存 |
||||
|
</el-button> |
||||
|
</div> |
||||
|
</el-card> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.config-card { |
||||
|
max-width: 820px; |
||||
|
} |
||||
|
|
||||
|
.config-section-title { |
||||
|
margin-bottom: 18px; |
||||
|
font-size: 15px; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
.field-hint { |
||||
|
margin-top: 6px; |
||||
|
color: var(--av-text-muted); |
||||
|
font-size: 12px; |
||||
|
line-height: 1.5; |
||||
|
} |
||||
|
|
||||
|
.logo-row { |
||||
|
display: flex; |
||||
|
align-items: flex-start; |
||||
|
gap: 18px; |
||||
|
} |
||||
|
|
||||
|
.logo-preview { |
||||
|
display: grid; |
||||
|
place-items: center; |
||||
|
width: 96px; |
||||
|
height: 96px; |
||||
|
flex: none; |
||||
|
border: 1px dashed #d7dee8; |
||||
|
border-radius: 10px; |
||||
|
background: #fafbfd; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.logo-preview img { |
||||
|
max-width: 88px; |
||||
|
max-height: 88px; |
||||
|
object-fit: contain; |
||||
|
} |
||||
|
|
||||
|
.logo-empty { |
||||
|
color: var(--av-text-muted); |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
|
||||
|
.logo-actions { |
||||
|
padding-top: 6px; |
||||
|
} |
||||
|
|
||||
|
.logo-buttons { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
} |
||||
|
|
||||
|
.logo-buttons :deep(.el-upload) { |
||||
|
display: inline-flex; |
||||
|
} |
||||
|
|
||||
|
.config-footer { |
||||
|
display: flex; |
||||
|
justify-content: flex-end; |
||||
|
gap: 8px; |
||||
|
margin-top: 8px; |
||||
|
padding-top: 18px; |
||||
|
border-top: 1px solid #eef1f6; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,345 @@ |
|||||
|
<script setup> |
||||
|
import { computed, onMounted, reactive, ref } from 'vue' |
||||
|
import { ElMessage, ElMessageBox } from 'element-plus' |
||||
|
import { sysConfigApi } from '../api' |
||||
|
import { formatDateTime } from '../utils/format' |
||||
|
|
||||
|
const MAX_GROUP_LENGTH = 64 |
||||
|
const MAX_KEY_LENGTH = 128 |
||||
|
const MAX_VALUE_BYTES = 65535 |
||||
|
const MAX_DESCRIPTION_LENGTH = 255 |
||||
|
const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ |
||||
|
|
||||
|
const loading = ref(false) |
||||
|
const saving = ref(false) |
||||
|
const entries = ref([]) |
||||
|
const dialogVisible = ref(false) |
||||
|
const editingId = ref(null) |
||||
|
const editingSecret = ref(false) |
||||
|
|
||||
|
const filters = reactive({ |
||||
|
group: '', |
||||
|
keyword: '' |
||||
|
}) |
||||
|
|
||||
|
const form = reactive({ |
||||
|
configGroup: '', |
||||
|
configKey: '', |
||||
|
configValue: '', |
||||
|
description: '' |
||||
|
}) |
||||
|
|
||||
|
const groups = computed(() => { |
||||
|
const set = new Set(entries.value.map((entry) => entry.configGroup)) |
||||
|
return Array.from(set).sort((a, b) => a.localeCompare(b)) |
||||
|
}) |
||||
|
|
||||
|
const filteredEntries = computed(() => { |
||||
|
const keyword = filters.keyword.trim().toLowerCase() |
||||
|
return entries.value.filter((entry) => { |
||||
|
if (filters.group && entry.configGroup !== filters.group) { |
||||
|
return false |
||||
|
} |
||||
|
if (!keyword) { |
||||
|
return true |
||||
|
} |
||||
|
return [entry.configGroup, entry.configKey, entry.description] |
||||
|
.filter(Boolean) |
||||
|
.some((value) => value.toLowerCase().includes(keyword)) |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
async function load() { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
entries.value = await sysConfigApi.list() |
||||
|
if (filters.group && !groups.value.includes(filters.group)) { |
||||
|
filters.group = '' |
||||
|
} |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function resetFilters() { |
||||
|
filters.group = '' |
||||
|
filters.keyword = '' |
||||
|
} |
||||
|
|
||||
|
function resetForm() { |
||||
|
editingId.value = null |
||||
|
editingSecret.value = false |
||||
|
form.configGroup = '' |
||||
|
form.configKey = '' |
||||
|
form.configValue = '' |
||||
|
form.description = '' |
||||
|
} |
||||
|
|
||||
|
function openCreate() { |
||||
|
resetForm() |
||||
|
dialogVisible.value = true |
||||
|
} |
||||
|
|
||||
|
function openEdit(row) { |
||||
|
editingId.value = row.id |
||||
|
editingSecret.value = Boolean(row.secret) |
||||
|
form.configGroup = row.configGroup |
||||
|
form.configKey = row.configKey |
||||
|
form.configValue = row.secret ? '' : (row.configValue || '') |
||||
|
form.description = row.description || '' |
||||
|
dialogVisible.value = true |
||||
|
} |
||||
|
|
||||
|
function validate() { |
||||
|
const group = form.configGroup.trim() |
||||
|
const key = form.configKey.trim() |
||||
|
const description = form.description.trim() |
||||
|
|
||||
|
if (!group) { |
||||
|
ElMessage.error('配置分组不能为空') |
||||
|
return false |
||||
|
} |
||||
|
if (group.length > MAX_GROUP_LENGTH) { |
||||
|
ElMessage.error(`配置分组不能超过 ${MAX_GROUP_LENGTH} 个字符`) |
||||
|
return false |
||||
|
} |
||||
|
if (!editingId.value) { |
||||
|
if (!key) { |
||||
|
ElMessage.error('配置键不能为空') |
||||
|
return false |
||||
|
} |
||||
|
if (key.length > MAX_KEY_LENGTH) { |
||||
|
ElMessage.error(`配置键不能超过 ${MAX_KEY_LENGTH} 个字符`) |
||||
|
return false |
||||
|
} |
||||
|
if (!KEY_PATTERN.test(key)) { |
||||
|
ElMessage.error('配置键只能包含字母、数字、点、下划线和短横线,且需以字母或数字开头') |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
if (new TextEncoder().encode(form.configValue).length > MAX_VALUE_BYTES) { |
||||
|
ElMessage.error('配置值不能超过 65535 字节') |
||||
|
return false |
||||
|
} |
||||
|
if (description.length > MAX_DESCRIPTION_LENGTH) { |
||||
|
ElMessage.error(`说明不能超过 ${MAX_DESCRIPTION_LENGTH} 个字符`) |
||||
|
return false |
||||
|
} |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
async function save() { |
||||
|
if (!validate()) { |
||||
|
return |
||||
|
} |
||||
|
saving.value = true |
||||
|
try { |
||||
|
const secretEdit = Boolean(editingId.value) && editingSecret.value |
||||
|
const payload = { |
||||
|
configGroup: form.configGroup.trim(), |
||||
|
configKey: form.configKey.trim(), |
||||
|
// Masked secrets are never sent to the browser, so blank means keep the stored value. |
||||
|
configValue: secretEdit && form.configValue === '' ? null : form.configValue, |
||||
|
description: form.description.trim() || null |
||||
|
} |
||||
|
if (editingId.value) { |
||||
|
await sysConfigApi.update(editingId.value, payload) |
||||
|
ElMessage.success('配置项已更新') |
||||
|
} else { |
||||
|
await sysConfigApi.create(payload) |
||||
|
ElMessage.success('配置项已新增') |
||||
|
} |
||||
|
dialogVisible.value = false |
||||
|
await load() |
||||
|
} finally { |
||||
|
saving.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function remove(row) { |
||||
|
try { |
||||
|
await ElMessageBox.confirm( |
||||
|
`确定删除配置项「${row.configKey}」?该操作不可恢复。`, |
||||
|
'删除确认', |
||||
|
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' } |
||||
|
) |
||||
|
} catch (e) { |
||||
|
return |
||||
|
} |
||||
|
await sysConfigApi.remove(row.id) |
||||
|
ElMessage.success('配置项已删除') |
||||
|
await load() |
||||
|
} |
||||
|
|
||||
|
onMounted(load) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="page"> |
||||
|
<div class="page-header"> |
||||
|
<div> |
||||
|
<h2 class="page-title">系统配置</h2> |
||||
|
<div class="page-subtitle"> |
||||
|
按分组维护系统运行参数,保存后立即生效;配置变更会记录审计日志 |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="toolbar-actions"> |
||||
|
<el-button :loading="loading" @click="load">刷新</el-button> |
||||
|
<el-button type="primary" @click="openCreate">新增配置</el-button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<el-card shadow="never"> |
||||
|
<div class="filter-bar"> |
||||
|
<el-select |
||||
|
v-model="filters.group" |
||||
|
clearable |
||||
|
placeholder="全部分组" |
||||
|
style="width: 180px" |
||||
|
> |
||||
|
<el-option v-for="group in groups" :key="group" :label="group" :value="group" /> |
||||
|
</el-select> |
||||
|
<el-input |
||||
|
v-model="filters.keyword" |
||||
|
clearable |
||||
|
placeholder="搜索配置键 / 说明" |
||||
|
style="max-width: 280px" |
||||
|
/> |
||||
|
<el-button @click="resetFilters">重置</el-button> |
||||
|
</div> |
||||
|
|
||||
|
<el-table |
||||
|
v-loading="loading" |
||||
|
:data="filteredEntries" |
||||
|
size="small" |
||||
|
empty-text="暂无配置项" |
||||
|
> |
||||
|
<el-table-column prop="configGroup" label="分组" width="140"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-tag size="small" effect="plain">{{ row.configGroup }}</el-tag> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="configKey" label="配置键" min-width="200"> |
||||
|
<template #default="{ row }"> |
||||
|
<span class="mono">{{ row.configKey }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="configValue" label="配置值" min-width="220" show-overflow-tooltip> |
||||
|
<template #default="{ row }"> |
||||
|
<template v-if="row.secret"> |
||||
|
<span class="mono secret-value"> |
||||
|
{{ row.configured ? '••••••••' : '未配置' }} |
||||
|
</span> |
||||
|
<el-tag v-if="row.configured" size="small" type="success" effect="plain"> |
||||
|
已配置 |
||||
|
</el-tag> |
||||
|
</template> |
||||
|
<span v-else class="mono">{{ row.configValue || '—' }}</span> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column prop="description" label="说明" min-width="220" show-overflow-tooltip> |
||||
|
<template #default="{ row }">{{ row.description || '—' }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="更新时间" width="170"> |
||||
|
<template #default="{ row }">{{ formatDateTime(row.updatedAt) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="操作" width="130" fixed="right"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button link type="primary" @click="openEdit(row)">编辑</el-button> |
||||
|
<el-button link type="danger" @click="remove(row)">删除</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
|
||||
|
<div class="table-footer muted">共 {{ filteredEntries.length }} 项</div> |
||||
|
</el-card> |
||||
|
|
||||
|
<el-dialog |
||||
|
v-model="dialogVisible" |
||||
|
:title="editingId ? '编辑配置' : '新增配置'" |
||||
|
width="560px" |
||||
|
@closed="resetForm" |
||||
|
> |
||||
|
<el-form label-position="top" @submit.prevent="save"> |
||||
|
<el-form-item label="配置分组"> |
||||
|
<el-select |
||||
|
v-model="form.configGroup" |
||||
|
filterable |
||||
|
allow-create |
||||
|
default-first-option |
||||
|
placeholder="选择或输入分组" |
||||
|
style="width: 100%" |
||||
|
> |
||||
|
<el-option v-for="group in groups" :key="group" :label="group" :value="group" /> |
||||
|
</el-select> |
||||
|
<div class="field-hint">可直接输入新的分组名称,例如 llm、push、oa</div> |
||||
|
</el-form-item> |
||||
|
|
||||
|
<el-form-item label="配置键"> |
||||
|
<el-input |
||||
|
v-model="form.configKey" |
||||
|
:disabled="Boolean(editingId)" |
||||
|
maxlength="128" |
||||
|
placeholder="例如 llm.endpoint" |
||||
|
/> |
||||
|
<div class="field-hint">创建后不可修改;建议使用“分组.名称”的格式</div> |
||||
|
</el-form-item> |
||||
|
|
||||
|
<el-form-item label="配置值"> |
||||
|
<el-input |
||||
|
v-model="form.configValue" |
||||
|
:type="editingSecret ? 'password' : 'textarea'" |
||||
|
:show-password="editingSecret" |
||||
|
:rows="editingSecret ? 1 : 4" |
||||
|
:autocomplete="editingSecret ? 'new-password' : 'off'" |
||||
|
:placeholder="editingSecret |
||||
|
? '已配置,留空表示不修改' |
||||
|
: '请输入配置值,留空表示清除'" |
||||
|
/> |
||||
|
<div class="field-hint"> |
||||
|
<template v-if="editingSecret"> |
||||
|
出于安全考虑,已保存的令牌不会回显,也无法复制;如需清除请删除该配置项。 |
||||
|
</template> |
||||
|
<template v-else>最多 65535 字节</template> |
||||
|
</div> |
||||
|
</el-form-item> |
||||
|
|
||||
|
<el-form-item label="说明"> |
||||
|
<el-input |
||||
|
v-model="form.description" |
||||
|
maxlength="255" |
||||
|
show-word-limit |
||||
|
placeholder="选填,描述该配置项的用途" |
||||
|
/> |
||||
|
</el-form-item> |
||||
|
</el-form> |
||||
|
|
||||
|
<template #footer> |
||||
|
<el-button @click="dialogVisible = false">取消</el-button> |
||||
|
<el-button type="primary" :loading="saving" @click="save">保存</el-button> |
||||
|
</template> |
||||
|
</el-dialog> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.table-footer { |
||||
|
margin-top: 12px; |
||||
|
font-size: 12px; |
||||
|
text-align: right; |
||||
|
} |
||||
|
|
||||
|
.field-hint { |
||||
|
margin-top: 6px; |
||||
|
color: var(--av-text-muted); |
||||
|
font-size: 12px; |
||||
|
line-height: 1.5; |
||||
|
} |
||||
|
|
||||
|
.secret-value { |
||||
|
margin-right: 8px; |
||||
|
letter-spacing: 2px; |
||||
|
user-select: none; |
||||
|
-webkit-user-select: none; |
||||
|
} |
||||
|
</style> |
||||
Loading…
Reference in new issue