Files
lehuo/apps/admin-web/src/views/FinancialDisclosuresView.vue
T

381 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>