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