feat(finance): add financial disclosure publishing
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export function htmlToPlainText(html: string): string {
|
||||
return html
|
||||
.replace(/<br\s*\/?\s*>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ | /gi, ' ')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'|'/gi, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function defaultFinancialSummary(html: string): string {
|
||||
return htmlToPlainText(html).slice(0, 120);
|
||||
}
|
||||
+11
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user