feat(finance): add financial disclosure publishing

This commit is contained in:
2026-08-13 17:17:33 +08:00
parent 39a2e53e61
commit 65d23eab4a
49 changed files with 2096 additions and 3 deletions
@@ -0,0 +1,380 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
import DOMPurify from 'dompurify';
import {
FINANCIAL_DISCLOSURE_STATUSES,
FINANCIAL_DISCLOSURE_STATUS_LABELS,
type FinancialDisclosureDetail,
type FinancialDisclosureListItem,
type FinancialDisclosureStatus,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import {
createFinancialDisclosure,
fetchFinancialDisclosure,
fetchFinancialDisclosures,
transitionFinancialDisclosure,
updateFinancialDisclosure,
} from '../api/financial-disclosures';
import { defaultFinancialSummary } from '../financial-document';
const items = ref<FinancialDisclosureListItem[]>([]),
loading = ref(true),
busy = ref(false),
message = ref(''),
editing = ref(false),
previewing = ref(false),
editor = ref<HTMLElement>();
const filter = ref<FinancialDisclosureStatus | ''>(''),
page = ref(1),
total = ref(0),
pageSize = 20;
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
const form = reactive({
publicId: '',
revision: 0,
title: '',
summary: '',
richText: '',
isPinned: false,
sortOrder: 0,
});
const errorText = (error: unknown) =>
error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: error instanceof Error
? error.message
: '操作失败,请稍后重试';
const sanitizeRichText = (html: string) =>
DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'p',
'br',
'h1',
'h2',
'h3',
'h4',
'strong',
'b',
'em',
'i',
'u',
's',
'blockquote',
'ul',
'ol',
'li',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'a',
'img',
'hr',
'span',
],
ALLOWED_ATTR: ['href', 'title', 'src', 'alt', 'colspan', 'rowspan', 'style'],
});
async function load(target = 1) {
loading.value = true;
message.value = '';
try {
const result = await fetchFinancialDisclosures({
page: target,
pageSize,
...(filter.value ? { status: filter.value } : {}),
});
items.value = result.items;
page.value = result.page;
total.value = result.total;
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function newDocument() {
Object.assign(form, {
publicId: '',
revision: 0,
title: '',
summary: '',
richText: '<h2>财务公开</h2><p>请在此编辑公开内容。</p>',
isPinned: false,
sortOrder: 0,
});
editing.value = true;
previewing.value = false;
await syncEditor();
}
async function edit(item: FinancialDisclosureListItem) {
busy.value = true;
try {
const result = await fetchFinancialDisclosure(item.publicId);
fill(result.disclosure);
editing.value = true;
previewing.value = false;
await syncEditor();
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
function fill(item: FinancialDisclosureDetail) {
Object.assign(form, item);
}
async function syncEditor() {
await nextTick();
if (editor.value) editor.value.innerHTML = form.richText;
}
function command(name: string, value?: string) {
editor.value?.focus();
document.execCommand(name, false, value);
form.richText = editor.value?.innerHTML ?? form.richText;
}
function inputEditor() {
form.richText = editor.value?.innerHTML ?? '';
}
async function importDocx(event: Event) {
const input = event.target as HTMLInputElement,
file = input.files?.[0];
input.value = '';
if (!file) return;
if (!file.name.toLowerCase().endsWith('.docx')) {
message.value = '请选择 .docx 格式的 Word 文档。';
return;
}
if (file.size > 10 * 1024 * 1024) {
message.value = 'DOCX 文件不能超过 10MB。';
return;
}
busy.value = true;
try {
const mammoth = await import('mammoth/mammoth.browser');
const result = await mammoth.convertToHtml({ arrayBuffer: await file.arrayBuffer() });
form.richText = sanitizeRichText(result.value);
if (!form.title) form.title = file.name.replace(/\.docx$/i, '').slice(0, 200);
if (!form.summary) form.summary = defaultFinancialSummary(result.value);
await syncEditor();
message.value = result.messages.length
? '文档已导入,部分复杂样式已简化,请预览确认。'
: '文档已导入,可继续编辑和排版。';
} catch {
message.value = 'Word 文档无法解析,请确认文件未损坏。';
} finally {
busy.value = false;
}
}
async function save() {
form.richText = sanitizeRichText(editor.value?.innerHTML ?? form.richText);
if (!form.title.trim() || !defaultFinancialSummary(form.richText)) {
message.value = '请填写标题和正文。';
return;
}
busy.value = true;
try {
const payload = {
title: form.title.trim(),
...(form.summary.trim() ? { summary: form.summary.trim() } : {}),
richText: form.richText,
isPinned: form.isPinned,
sortOrder: Number(form.sortOrder) || 0,
};
const result = form.publicId
? await updateFinancialDisclosure(form.publicId, {
expectedRevision: form.revision,
...payload,
})
: await createFinancialDisclosure({ clientRequestId: crypto.randomUUID(), ...payload });
fill(result.disclosure);
message.value = '财务公开草稿已保存。';
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function transition(item: FinancialDisclosureListItem) {
const targetStatus = item.status === 'DRAFT' ? 'PUBLISHED' : 'ARCHIVED';
if (
!window.confirm(
targetStatus === 'PUBLISHED'
? `确认发布“${item.title}”?发布后小程序将立即可见。`
: `确认归档“${item.title}”?`,
)
)
return;
busy.value = true;
try {
await transitionFinancialDisclosure(item.publicId, {
clientRequestId: crypto.randomUUID(),
targetStatus,
});
message.value = targetStatus === 'PUBLISHED' ? '文档已发布到小程序。' : '文档已归档。';
editing.value = false;
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
onMounted(() => load());
</script>
<template>
<main class="content-area">
<header class="page-header">
<div>
<h1>财务公开</h1>
<p>导入 Word 文档,调整排版后直接发布到小程序。</p>
</div>
<button class="primary-button compact-button" type="button" @click="newDocument">
新建文档
</button>
</header>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section v-if="editing" class="financial-workspace">
<div class="financial-editor-panel">
<div class="financial-editor-head">
<button class="secondary-button compact-button" type="button" @click="editing = false">
返回列表</button
><label class="import-button"
>导入 DOCX<input
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
@change="importDocx" /></label
><button
class="secondary-button compact-button"
type="button"
@click="previewing = !previewing"
>
{{ previewing ? '返回编辑' : '小程序预览' }}
</button>
</div>
<template v-if="!previewing"
><div class="financial-fields">
<label>标题<input v-model.trim="form.title" maxlength="200" /></label
><label
>摘要(选填)<textarea v-model.trim="form.summary" maxlength="500"></textarea></label
><label class="inline-check"
><input v-model="form.isPinned" type="checkbox" />置顶显示</label
><label
>排序<input v-model.number="form.sortOrder" type="number" min="-9999" max="9999"
/></label>
</div>
<div class="rich-toolbar" aria-label="排版工具栏">
<button type="button" @click="command('formatBlock', 'h2')">标题</button
><button type="button" @click="command('formatBlock', 'p')">正文</button
><button type="button" @click="command('bold')"><strong>加粗</strong></button
><button type="button" @click="command('underline')"><u>下划线</u></button
><button type="button" @click="command('insertUnorderedList')">项目符号</button
><button type="button" @click="command('insertOrderedList')">编号</button
><button type="button" @click="command('justifyLeft')">左对齐</button
><button type="button" @click="command('justifyCenter')">居中</button
><button type="button" @click="command('undo')">撤销</button
><button type="button" @click="command('redo')">重做</button>
</div>
<div
ref="editor"
class="rich-editor"
contenteditable="true"
role="textbox"
aria-label="财务公开正文"
aria-multiline="true"
@input="inputEditor"
></div
></template>
<article v-else class="mobile-preview">
<p class="preview-label">小程序阅读效果预览</p>
<h1>{{ form.title }}</h1>
<p v-if="form.summary" class="preview-summary">{{ form.summary }}</p>
<div class="public-rich-text" v-html="form.richText"></div>
</article>
<div class="form-actions">
<button
class="primary-button compact-button"
:disabled="busy"
type="button"
@click="save"
>
{{ busy ? '保存中…' : '保存草稿' }}
</button>
</div>
</div>
</section>
<template v-else
><form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field">
<label>状态</label
><select v-model="filter">
<option value="">全部</option>
<option v-for="status in FINANCIAL_DISCLOSURE_STATUSES" :key="status" :value="status">
{{ FINANCIAL_DISCLOSURE_STATUS_LABELS[status] }}
</option>
</select>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<section v-if="loading" class="panel">正在加载财务公开文档…</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无财务公开文档</h2>
<p>可以新建空白文档,也可以导入本地 DOCX。</p>
</section>
<section v-else class="table-panel">
<div class="table-scroll">
<table>
<thead>
<tr>
<th>标题</th>
<th>状态</th>
<th>发布/更新</th>
<th>排序</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td>
<strong>{{ item.title }}</strong
><span v-if="item.isPinned" class="status-chip">置顶</span><br />{{
item.summary || '暂无摘要'
}}
</td>
<td>{{ FINANCIAL_DISCLOSURE_STATUS_LABELS[item.status] }}</td>
<td>{{ new Date(item.publishedAt || item.updatedAt).toLocaleString('zh-CN') }}</td>
<td>{{ item.sortOrder }}</td>
<td>
<button
v-if="item.status === 'DRAFT'"
class="link-button"
:disabled="busy"
@click="edit(item)"
>
编辑</button
><button
v-if="item.status !== 'ARCHIVED'"
class="link-button"
:disabled="busy"
@click="transition(item)"
>
{{ item.status === 'DRAFT' ? '发布' : '归档' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
><span>第 {{ page }} / {{ totalPages }} 页</span
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
</div>
</section></template
>
</main>
</template>