381 lines
12 KiB
Vue
381 lines
12 KiB
Vue
<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>
|