feat(finance): add financial disclosure publishing
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user