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
+2
View File
@@ -11,6 +11,8 @@
},
"dependencies": {
"@lehuo/contracts": "workspace:*",
"dompurify": "^3.4.13",
"mammoth": "^1.12.1",
"pinia": "^3.0.3",
"vue": "^3.5.20",
"vue-router": "^4.5.1"
+11
View File
@@ -17,6 +17,7 @@ import NotificationDeliveriesView from './views/NotificationDeliveriesView.vue';
import StatisticsView from './views/StatisticsView.vue';
import DashboardView from './views/DashboardView.vue';
import AdminIcon from './components/AdminIcon.vue';
import FinancialDisclosuresView from './views/FinancialDisclosuresView.vue';
const auth = useAuthStore();
const form = reactive({ username: '', password: '' });
@@ -36,6 +37,7 @@ const activeView = ref<
| 'content'
| 'notifications'
| 'statistics'
| 'financial-disclosures'
>('dashboard');
onMounted(() => auth.restore());
@@ -171,6 +173,14 @@ async function logout(): Promise<void> {
>
<AdminIcon name="content" />内容发布
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'financial-disclosures' }]"
type="button"
:aria-current="activeView === 'financial-disclosures' ? 'page' : undefined"
@click="activeView = 'financial-disclosures'"
>
<AdminIcon name="content" />财务公开
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'notifications' }]"
type="button"
@@ -253,6 +263,7 @@ async function logout(): Promise<void> {
<ProjectsView v-else-if="activeView === 'projects'" />
<ActivitiesView v-else-if="activeView === 'activities'" />
<ContentView v-else-if="activeView === 'content'" />
<FinancialDisclosuresView v-else-if="activeView === 'financial-disclosures'" />
<NotificationDeliveriesView v-else-if="activeView === 'notifications'" />
<StatisticsView v-else />
</div>
@@ -0,0 +1,43 @@
import type {
CreateFinancialDisclosureRequest,
FinancialDisclosureDetailResponse,
PaginatedFinancialDisclosuresResponse,
TransitionFinancialDisclosureRequest,
UpdateFinancialDisclosureRequest,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
const query = (value: Record<string, string | number | undefined>) => {
const params = new URLSearchParams();
Object.entries(value).forEach(
([key, item]) => item !== undefined && params.set(key, String(item)),
);
return params.toString();
};
export const fetchFinancialDisclosures = (value: {
page: number;
pageSize: number;
status?: string;
}) =>
apiRequest<PaginatedFinancialDisclosuresResponse>(
`/api/v1/admin/financial-disclosures?${query(value)}`,
);
export const fetchFinancialDisclosure = (id: string) =>
apiRequest<FinancialDisclosureDetailResponse>(`/api/v1/admin/financial-disclosures/${id}`);
export const createFinancialDisclosure = (input: CreateFinancialDisclosureRequest) =>
apiRequest<FinancialDisclosureDetailResponse>('/api/v1/admin/financial-disclosures', {
method: 'POST',
body: JSON.stringify(input),
});
export const updateFinancialDisclosure = (id: string, input: UpdateFinancialDisclosureRequest) =>
apiRequest<FinancialDisclosureDetailResponse>(`/api/v1/admin/financial-disclosures/${id}`, {
method: 'PATCH',
body: JSON.stringify(input),
});
export const transitionFinancialDisclosure = (
id: string,
input: TransitionFinancialDisclosureRequest,
) =>
apiRequest<FinancialDisclosureDetailResponse>(
`/api/v1/admin/financial-disclosures/${id}/transition`,
{ method: 'POST', body: JSON.stringify(input) },
);
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { defaultFinancialSummary, htmlToPlainText } from './financial-document';
describe('financial document helpers', () => {
it('creates a short readable summary from imported HTML', () => {
expect(htmlToPlainText('<h1>年度报告</h1><p>公益项目执行情况</p>')).toBe(
'年度报告公益项目执行情况',
);
expect(defaultFinancialSummary(`<p>${'财'.repeat(130)}</p>`)).toHaveLength(120);
});
});
+17
View File
@@ -0,0 +1,17 @@
export function htmlToPlainText(html: string): string {
return html
.replace(/<br\s*\/?\s*>/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;|&#160;/gi, ' ')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/gi, "'")
.replace(/\s+/g, ' ')
.trim();
}
export function defaultFinancialSummary(html: string): string {
return htmlToPlainText(html).slice(0, 120);
}
+11
View File
@@ -0,0 +1,11 @@
declare module 'mammoth/mammoth.browser' {
export interface ConversionMessage {
type: 'warning' | 'error';
message: string;
}
export interface ConversionResult {
value: string;
messages: ConversionMessage[];
}
export function convertToHtml(input: { arrayBuffer: ArrayBuffer }): Promise<ConversionResult>;
}
+151
View File
@@ -27,6 +27,157 @@
sans-serif;
}
.financial-workspace {
display: grid;
gap: 20px;
}
.financial-editor-panel {
padding: 24px;
border: 1px solid var(--color-border);
border-radius: 16px;
background: #fff;
box-shadow: var(--shadow-card);
}
.financial-editor-head,
.rich-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.financial-editor-head {
justify-content: flex-end;
margin-bottom: 20px;
}
.import-button {
display: inline-flex;
min-height: 38px;
align-items: center;
padding: 0 16px;
border: 1px solid var(--color-primary);
border-radius: 8px;
color: var(--color-primary);
cursor: pointer;
font-weight: 600;
}
.import-button input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
}
.financial-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) 140px;
gap: 16px;
margin-bottom: 18px;
}
.financial-fields label:first-child,
.financial-fields label:nth-child(2) {
grid-column: 1 / -1;
}
.inline-check {
display: flex;
align-items: center;
gap: 8px;
}
.inline-check input {
width: 18px;
height: 18px;
}
.rich-toolbar {
padding: 10px;
border: 1px solid var(--color-border);
border-bottom: 0;
border-radius: 10px 10px 0 0;
background: #f8fafc;
}
.rich-toolbar button {
min-height: 34px;
padding: 4px 10px;
border: 1px solid #d8dee8;
border-radius: 6px;
background: #fff;
color: #344054;
cursor: pointer;
}
.rich-editor {
min-height: 480px;
padding: 36px;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: 0 0 10px 10px;
outline: none;
line-height: 1.8;
}
.rich-editor:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgb(240 90 40 / 12%);
}
.rich-editor table,
.public-rich-text table {
width: 100%;
border-collapse: collapse;
}
.rich-editor th,
.rich-editor td,
.public-rich-text th,
.public-rich-text td {
padding: 8px 10px;
border: 1px solid #d9dde4;
}
.mobile-preview {
width: min(420px, 100%);
min-height: 640px;
margin: 0 auto;
padding: 30px 24px;
border: 10px solid #25282d;
border-radius: 34px;
background: #fff;
box-shadow: 0 18px 48px rgb(23 31 45 / 18%);
}
.preview-label {
margin: -12px 0 24px;
color: #98a2b3;
font-size: 12px;
text-align: center;
}
.preview-summary {
padding: 14px;
border-radius: 10px;
background: #fff5ee;
color: #6d4b3b;
}
.public-rich-text {
overflow-wrap: anywhere;
color: #30343b;
line-height: 1.8;
}
.public-rich-text img {
max-width: 100%;
height: auto;
}
.status-chip {
margin-left: 8px;
padding: 2px 7px;
border-radius: 999px;
background: #fff0e8;
color: #e65322;
font-size: 12px;
}
@media (max-width: 720px) {
.financial-fields {
grid-template-columns: 1fr;
}
.financial-fields label {
grid-column: 1 !important;
}
.rich-editor {
padding: 20px;
}
}
* {
box-sizing: border-box;
}
@@ -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>