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>
|
||||
@@ -31,13 +31,15 @@
|
||||
"cookie-parser": "^1.4.7",
|
||||
"mariadb": "^3.5.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
"rxjs": "^7.8.2",
|
||||
"sanitize-html": "^2.17.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^11.1.6",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"prisma": "^7.9.1",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
CREATE TABLE `financial_disclosures` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`client_request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`title` VARCHAR(200) NOT NULL,
|
||||
`summary` VARCHAR(500) NULL,
|
||||
`rich_text` MEDIUMTEXT NOT NULL,
|
||||
`status` ENUM('DRAFT','PUBLISHED','ARCHIVED') NOT NULL DEFAULT 'DRAFT',
|
||||
`is_pinned` BOOLEAN NOT NULL DEFAULT false,
|
||||
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
|
||||
`published_at` DATETIME(3) NULL,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL,
|
||||
`updated_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `financial_disclosures_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `financial_disclosures_creator_request_key` (`created_by`, `client_request_id`),
|
||||
INDEX `financial_disclosures_public_list_idx` (`status`, `is_pinned`, `sort_order`, `published_at`),
|
||||
INDEX `financial_disclosures_updated_at_idx` (`updated_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `financial_disclosure_status_history` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`financial_disclosure_id` BIGINT UNSIGNED NOT NULL,
|
||||
`request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`from_status` ENUM('DRAFT','PUBLISHED','ARCHIVED') NULL,
|
||||
`to_status` ENUM('DRAFT','PUBLISHED','ARCHIVED') NOT NULL,
|
||||
`operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `financial_disclosure_status_history_request_id_key` (`request_id`),
|
||||
INDEX `financial_disclosure_history_document_idx` (`financial_disclosure_id`, `created_at`),
|
||||
INDEX `financial_disclosure_history_operator_idx` (`operator_user_id`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `financial_disclosures` ADD CONSTRAINT `financial_disclosures_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `financial_disclosures` ADD CONSTRAINT `financial_disclosures_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `financial_disclosure_status_history` ADD CONSTRAINT `financial_disclosure_history_document_fkey` FOREIGN KEY (`financial_disclosure_id`) REFERENCES `financial_disclosures` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `financial_disclosure_status_history` ADD CONSTRAINT `financial_disclosure_history_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
|
||||
('financial_disclosures:read', '查看财务公开', '查看财务公开草稿与发布状态'),
|
||||
('financial_disclosures:manage', '管理财务公开', '创建和编辑财务公开文档'),
|
||||
('financial_disclosures:publish', '发布财务公开', '发布和归档财务公开文档');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
|
||||
WHERE r.code IN ('ADMIN','SUPER_ADMIN')
|
||||
AND p.code IN ('financial_disclosures:read','financial_disclosures:manage','financial_disclosures:publish');
|
||||
@@ -170,6 +170,12 @@ enum ContentPostStatus {
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum FinancialDisclosureStatus {
|
||||
DRAFT
|
||||
PUBLISHED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum NotificationType {
|
||||
APPLICATION_SUBMITTED
|
||||
APPLICATION_ACCEPTED
|
||||
@@ -316,6 +322,9 @@ model User {
|
||||
contentStatusChanges ContentPostStatusHistory[] @relation("ContentStatusOperator")
|
||||
notifications Notification[]
|
||||
notificationPreferences NotificationPreference[]
|
||||
createdFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureCreator")
|
||||
updatedFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureUpdater")
|
||||
financialDisclosureStatusChanges FinancialDisclosureStatusHistory[] @relation("FinancialDisclosureStatusOperator")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -1148,6 +1157,50 @@ model ContentPostStatusHistory {
|
||||
@@map("content_post_status_history")
|
||||
}
|
||||
|
||||
model FinancialDisclosure {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
clientRequestId String @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
title String @db.VarChar(200)
|
||||
summary String? @db.VarChar(500)
|
||||
richText String @map("rich_text") @db.MediumText
|
||||
status FinancialDisclosureStatus @default(DRAFT)
|
||||
isPinned Boolean @default(false) @map("is_pinned")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
revision Int @default(1) @db.UnsignedInt
|
||||
publishedAt DateTime? @map("published_at") @db.DateTime(3)
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
creator User @relation("FinancialDisclosureCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
updater User @relation("FinancialDisclosureUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
|
||||
statusHistory FinancialDisclosureStatusHistory[]
|
||||
|
||||
@@unique([createdById, clientRequestId], map: "financial_disclosures_creator_request_key")
|
||||
@@index([status, isPinned, sortOrder, publishedAt], map: "financial_disclosures_public_list_idx")
|
||||
@@index([updatedAt], map: "financial_disclosures_updated_at_idx")
|
||||
@@map("financial_disclosures")
|
||||
}
|
||||
|
||||
model FinancialDisclosureStatusHistory {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
financialDisclosureId BigInt @map("financial_disclosure_id") @db.UnsignedBigInt
|
||||
requestId String @unique @map("request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
fromStatus FinancialDisclosureStatus? @map("from_status")
|
||||
toStatus FinancialDisclosureStatus @map("to_status")
|
||||
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
financialDisclosure FinancialDisclosure @relation(fields: [financialDisclosureId], references: [id], onDelete: Restrict)
|
||||
operator User @relation("FinancialDisclosureStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([financialDisclosureId, createdAt], map: "financial_disclosure_history_document_idx")
|
||||
@@index([operatorUserId, createdAt], map: "financial_disclosure_history_operator_idx")
|
||||
@@map("financial_disclosure_status_history")
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
|
||||
@@ -25,6 +25,7 @@ import { StatisticsModule } from './statistics/statistics.module';
|
||||
import { RequestMetricsService } from './common/request-metrics.service';
|
||||
import { RequestMetricsInterceptor } from './common/request-metrics.interceptor';
|
||||
import { SystemMetricsController } from './common/system-metrics.controller';
|
||||
import { FinancialDisclosuresModule } from './financial-disclosures/financial-disclosures.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -42,6 +43,7 @@ import { SystemMetricsController } from './common/system-metrics.controller';
|
||||
ParticipationModule,
|
||||
ContentNotificationsModule,
|
||||
StatisticsModule,
|
||||
FinancialDisclosuresModule,
|
||||
],
|
||||
controllers: [HealthController, SystemMetricsController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Length,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { FINANCIAL_DISCLOSURE_STATUSES } from '@lehuo/contracts';
|
||||
import { PaginationDto } from '../../case-management/dto/case-management.dto';
|
||||
|
||||
export class FinancialDisclosureQueryDto extends PaginationDto {
|
||||
@IsOptional() @IsIn(FINANCIAL_DISCLOSURE_STATUSES) status?: string;
|
||||
}
|
||||
export class CreateFinancialDisclosureDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsString() @Length(2, 200) title!: string;
|
||||
@IsOptional() @IsString() @Length(0, 500) summary?: string;
|
||||
@IsString() @Length(1, 1_000_000) richText!: string;
|
||||
@IsOptional() @IsBoolean() isPinned?: boolean;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
|
||||
}
|
||||
export class UpdateFinancialDisclosureDto {
|
||||
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
|
||||
@IsString() @Length(2, 200) title!: string;
|
||||
@IsOptional() @IsString() @Length(0, 500) summary?: string;
|
||||
@IsString() @Length(1, 1_000_000) richText!: string;
|
||||
@IsOptional() @IsBoolean() isPinned?: boolean;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
|
||||
}
|
||||
export class TransitionFinancialDisclosureDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsIn(['PUBLISHED', 'ARCHIVED']) targetStatus!: 'PUBLISHED' | 'ARCHIVED';
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { PERMISSION_CODES } from '@lehuo/contracts';
|
||||
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
|
||||
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { SessionChannel } from '../generated/prisma/enums';
|
||||
import {
|
||||
CreateFinancialDisclosureDto,
|
||||
FinancialDisclosureQueryDto,
|
||||
TransitionFinancialDisclosureDto,
|
||||
UpdateFinancialDisclosureDto,
|
||||
} from './dto/financial-disclosure.dto';
|
||||
import { FinancialDisclosuresService } from './financial-disclosures.service';
|
||||
|
||||
const strict = (expectedType: new () => object) =>
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType,
|
||||
});
|
||||
|
||||
@Controller('api/v1/financial-disclosures')
|
||||
@Public()
|
||||
export class PublicFinancialDisclosuresController {
|
||||
constructor(
|
||||
@Inject(FinancialDisclosuresService) private readonly service: FinancialDisclosuresService,
|
||||
) {}
|
||||
@Get() list(@Query(strict(FinancialDisclosureQueryDto)) q: FinancialDisclosureQueryDto) {
|
||||
return this.service.listPublic(q);
|
||||
}
|
||||
@Get(':publicId') get(@Param('publicId') id: string) {
|
||||
return this.service.getPublic(id);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('api/v1/admin/financial-disclosures')
|
||||
@RequireSessionChannel(SessionChannel.ADMIN)
|
||||
export class AdminFinancialDisclosuresController {
|
||||
constructor(
|
||||
@Inject(FinancialDisclosuresService) private readonly service: FinancialDisclosuresService,
|
||||
) {}
|
||||
@Get() @RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_READ) list(
|
||||
@Query(strict(FinancialDisclosureQueryDto)) q: FinancialDisclosureQueryDto,
|
||||
) {
|
||||
return this.service.listAdmin(q);
|
||||
}
|
||||
@Get(':publicId') @RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_READ) get(
|
||||
@Param('publicId') id: string,
|
||||
) {
|
||||
return this.service.getAdmin(id);
|
||||
}
|
||||
@Post() @RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_MANAGE) create(
|
||||
@Body(strict(CreateFinancialDisclosureDto)) input: CreateFinancialDisclosureDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.create(input, actor, req);
|
||||
}
|
||||
@Patch(':publicId') @RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_MANAGE) update(
|
||||
@Param('publicId') id: string,
|
||||
@Body(strict(UpdateFinancialDisclosureDto)) input: UpdateFinancialDisclosureDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.update(id, input, actor, req);
|
||||
}
|
||||
@Post(':publicId/transition')
|
||||
@RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_PUBLISH)
|
||||
transition(
|
||||
@Param('publicId') id: string,
|
||||
@Body(strict(TransitionFinancialDisclosureDto)) input: TransitionFinancialDisclosureDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.transition(id, input, actor, req);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import {
|
||||
AdminFinancialDisclosuresController,
|
||||
PublicFinancialDisclosuresController,
|
||||
} from './financial-disclosures.controller';
|
||||
import { FinancialDisclosuresService } from './financial-disclosures.service';
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [PublicFinancialDisclosuresController, AdminFinancialDisclosuresController],
|
||||
providers: [FinancialDisclosuresService],
|
||||
})
|
||||
export class FinancialDisclosuresModule {}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
type FinancialDisclosureDetail,
|
||||
type FinancialDisclosureDetailResponse,
|
||||
type PaginatedFinancialDisclosuresResponse,
|
||||
} from '@lehuo/contracts';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { ApplicationError } from '../common/application-error';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import type { FinancialDisclosure, Prisma } from '../generated/prisma/client';
|
||||
import { SensitiveDataService } from '../security/sensitive-data.service';
|
||||
import type {
|
||||
CreateFinancialDisclosureDto,
|
||||
FinancialDisclosureQueryDto,
|
||||
TransitionFinancialDisclosureDto,
|
||||
UpdateFinancialDisclosureDto,
|
||||
} from './dto/financial-disclosure.dto';
|
||||
|
||||
const clean = (html: string) =>
|
||||
sanitizeHtml(html, {
|
||||
allowedTags: [
|
||||
'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',
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ['href', 'title'],
|
||||
img: ['src', 'alt', 'title'],
|
||||
th: ['colspan', 'rowspan'],
|
||||
td: ['colspan', 'rowspan'],
|
||||
span: ['style'],
|
||||
p: ['style'],
|
||||
h1: ['style'],
|
||||
h2: ['style'],
|
||||
h3: ['style'],
|
||||
},
|
||||
allowedStyles: {
|
||||
'*': {
|
||||
'text-align': [/^(left|right|center|justify)$/],
|
||||
color: [/^#[0-9a-f]{3,6}$/i],
|
||||
'background-color': [/^#[0-9a-f]{3,6}$/i],
|
||||
},
|
||||
},
|
||||
allowedSchemes: ['https'],
|
||||
allowedSchemesByTag: { img: ['https'] },
|
||||
enforceHtmlBoundary: true,
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class FinancialDisclosuresService {
|
||||
constructor(
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
|
||||
@Inject(AuditService) private readonly audit: AuditService,
|
||||
) {}
|
||||
listAdmin(q: FinancialDisclosureQueryDto) {
|
||||
return this.list(q.status ? { status: q.status as never } : {}, q.page, q.pageSize);
|
||||
}
|
||||
listPublic(q: FinancialDisclosureQueryDto) {
|
||||
return this.list({ status: 'PUBLISHED' }, q.page, q.pageSize);
|
||||
}
|
||||
async getAdmin(id: string): Promise<FinancialDisclosureDetailResponse> {
|
||||
return { disclosure: this.detail(await this.find(id)) };
|
||||
}
|
||||
async getPublic(id: string): Promise<FinancialDisclosureDetailResponse> {
|
||||
const item = await this.find(id);
|
||||
if (item.status !== 'PUBLISHED') throw this.notFound();
|
||||
return { disclosure: this.detail(item) };
|
||||
}
|
||||
|
||||
async create(
|
||||
input: CreateFinancialDisclosureDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
): Promise<FinancialDisclosureDetailResponse & { idempotentReplay: boolean }> {
|
||||
const richText = this.sanitize(input.richText),
|
||||
fp = this.fingerprint('financial-disclosure-create', { ...input, richText });
|
||||
const replay = await this.prisma.financialDisclosure.findUnique({
|
||||
where: {
|
||||
createdById_clientRequestId: {
|
||||
createdById: actor.userId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { disclosure: this.detail(replay), idempotentReplay: true };
|
||||
}
|
||||
const item = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.financialDisclosure.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
clientRequestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
title: input.title.trim(),
|
||||
summary: input.summary?.trim() || null,
|
||||
richText,
|
||||
isPinned: input.isPinned ?? false,
|
||||
sortOrder: input.sortOrder ?? 0,
|
||||
createdById: actor.userId,
|
||||
updatedById: actor.userId,
|
||||
},
|
||||
});
|
||||
await tx.financialDisclosureStatusHistory.create({
|
||||
data: {
|
||||
financialDisclosureId: created.id,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
toStatus: 'DRAFT',
|
||||
operatorUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'FINANCIAL_DISCLOSURE_CREATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'FINANCIAL_DISCLOSURE',
|
||||
resourcePublicId: created.publicId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return created;
|
||||
});
|
||||
return { disclosure: this.detail(item), idempotentReplay: false };
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: UpdateFinancialDisclosureDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
): Promise<FinancialDisclosureDetailResponse> {
|
||||
const current = await this.find(id);
|
||||
if (current.status !== 'DRAFT') throw this.invalid();
|
||||
if (current.revision !== input.expectedRevision) throw this.conflict();
|
||||
const richText = this.sanitize(input.richText);
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.financialDisclosure.updateMany({
|
||||
where: { id: current.id, revision: input.expectedRevision, status: 'DRAFT' },
|
||||
data: {
|
||||
title: input.title.trim(),
|
||||
summary: input.summary?.trim() || null,
|
||||
richText,
|
||||
isPinned: input.isPinned ?? false,
|
||||
sortOrder: input.sortOrder ?? 0,
|
||||
updatedById: actor.userId,
|
||||
revision: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (result.count !== 1) throw this.conflict();
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'FINANCIAL_DISCLOSURE_UPDATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'FINANCIAL_DISCLOSURE',
|
||||
resourcePublicId: id,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return tx.financialDisclosure.findUniqueOrThrow({ where: { id: current.id } });
|
||||
});
|
||||
return { disclosure: this.detail(updated) };
|
||||
}
|
||||
|
||||
async transition(
|
||||
id: string,
|
||||
input: TransitionFinancialDisclosureDto,
|
||||
actor: AuthContext,
|
||||
req: RequestContext,
|
||||
) {
|
||||
const current = await this.find(id),
|
||||
fp = this.fingerprint('financial-disclosure-transition', {
|
||||
id,
|
||||
targetStatus: input.targetStatus,
|
||||
});
|
||||
const replay = await this.prisma.financialDisclosureStatusHistory.findUnique({
|
||||
where: { requestId: input.clientRequestId },
|
||||
include: { financialDisclosure: true },
|
||||
});
|
||||
if (replay) {
|
||||
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
||||
return { disclosure: this.detail(replay.financialDisclosure), idempotentReplay: true };
|
||||
}
|
||||
const allowed =
|
||||
(current.status === 'DRAFT' && input.targetStatus === 'PUBLISHED') ||
|
||||
(current.status === 'PUBLISHED' && input.targetStatus === 'ARCHIVED');
|
||||
if (!allowed) throw this.invalid();
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.financialDisclosure.updateMany({
|
||||
where: { id: current.id, status: current.status },
|
||||
data: {
|
||||
status: input.targetStatus,
|
||||
updatedById: actor.userId,
|
||||
revision: { increment: 1 },
|
||||
...(input.targetStatus === 'PUBLISHED' ? { publishedAt: new Date() } : {}),
|
||||
},
|
||||
});
|
||||
if (result.count !== 1) throw this.conflict();
|
||||
await tx.financialDisclosureStatusHistory.create({
|
||||
data: {
|
||||
financialDisclosureId: current.id,
|
||||
requestId: input.clientRequestId,
|
||||
requestFingerprint: fp,
|
||||
fromStatus: current.status,
|
||||
toStatus: input.targetStatus,
|
||||
operatorUserId: actor.userId,
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
req,
|
||||
{
|
||||
action: 'FINANCIAL_DISCLOSURE_STATUS_CHANGED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'FINANCIAL_DISCLOSURE',
|
||||
resourcePublicId: id,
|
||||
metadata: { fromStatus: current.status, toStatus: input.targetStatus },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return tx.financialDisclosure.findUniqueOrThrow({ where: { id: current.id } });
|
||||
});
|
||||
return { disclosure: this.detail(updated), idempotentReplay: false };
|
||||
}
|
||||
|
||||
private async list(
|
||||
where: Prisma.FinancialDisclosureWhereInput,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<PaginatedFinancialDisclosuresResponse> {
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.financialDisclosure.count({ where }),
|
||||
this.prisma.financialDisclosure.findMany({
|
||||
where,
|
||||
orderBy: [
|
||||
{ isPinned: 'desc' },
|
||||
{ sortOrder: 'desc' },
|
||||
{ publishedAt: 'desc' },
|
||||
{ updatedAt: 'desc' },
|
||||
],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
return { items: items.map((i) => this.item(i)), page, pageSize, total };
|
||||
}
|
||||
private async find(publicId: string) {
|
||||
const item = await this.prisma.financialDisclosure.findUnique({ where: { publicId } });
|
||||
if (!item) throw this.notFound();
|
||||
return item;
|
||||
}
|
||||
private item(i: FinancialDisclosure) {
|
||||
return {
|
||||
publicId: i.publicId,
|
||||
title: i.title,
|
||||
summary: i.summary,
|
||||
status: i.status,
|
||||
isPinned: i.isPinned,
|
||||
sortOrder: i.sortOrder,
|
||||
revision: i.revision,
|
||||
publishedAt: i.publishedAt?.toISOString() ?? null,
|
||||
updatedAt: i.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
private detail(i: FinancialDisclosure): FinancialDisclosureDetail {
|
||||
return { ...this.item(i), richText: i.richText };
|
||||
}
|
||||
private sanitize(value: string) {
|
||||
const result = clean(value).trim();
|
||||
if (!result || !sanitizeHtml(result, { allowedTags: [], allowedAttributes: {} }).trim())
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.VALIDATION_ERROR,
|
||||
'财务公开正文不能为空',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
private fingerprint(domain: string, value: object) {
|
||||
return this.sensitive.lookupHash(domain, JSON.stringify(value));
|
||||
}
|
||||
private notFound() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.RESOURCE_NOT_FOUND,
|
||||
'未找到该财务公开文档',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
private invalid() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.INVALID_STATE_TRANSITION,
|
||||
'当前文档状态不能执行该操作',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
private conflict() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.RESOURCE_CONFLICT,
|
||||
'文档已更新,请刷新后重试',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
private duplicate() {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.DUPLICATE_REQUEST,
|
||||
'请求标识已用于其他操作',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,8 @@ describe('Phase 1 authentication and authorization', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||
await prisma.financialDisclosure.deleteMany();
|
||||
await prisma.auditLog.deleteMany();
|
||||
await prisma.notificationDelivery.deleteMany();
|
||||
await prisma.notification.deleteMany();
|
||||
|
||||
@@ -106,6 +106,8 @@ describe('Phase 3 beneficiary and case management', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||
await prisma.financialDisclosure.deleteMany();
|
||||
await prisma.notificationDelivery.deleteMany();
|
||||
await prisma.notification.deleteMany();
|
||||
await prisma.notificationPreference.deleteMany();
|
||||
|
||||
@@ -51,6 +51,8 @@ describe('Phase 6 content and notifications', () => {
|
||||
passwords = app.get(PasswordService);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||
await prisma.financialDisclosure.deleteMany();
|
||||
await prisma.auditLog.deleteMany();
|
||||
await prisma.notificationDelivery.deleteMany();
|
||||
await prisma.notification.deleteMany();
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ValidationPipe, type INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'node:http';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
ApiErrorResponse,
|
||||
ApiSuccessResponse,
|
||||
FinancialDisclosureDetailResponse,
|
||||
PaginatedFinancialDisclosuresResponse,
|
||||
} from '@lehuo/contracts';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { PasswordService } from '../src/auth/password.service';
|
||||
import { ADMIN_SESSION_COOKIE } from '../src/auth/session.constants';
|
||||
import { PrismaService } from '../src/database/prisma.service';
|
||||
|
||||
const ORIGIN = 'http://localhost:5173';
|
||||
const body = <T>(response: { body: unknown }) => response.body as T;
|
||||
function sessionCookie(response: { headers: Record<string, unknown> }) {
|
||||
const values = response.headers['set-cookie'];
|
||||
const cookies: string[] = Array.isArray(values)
|
||||
? values.filter((value): value is string => typeof value === 'string')
|
||||
: [];
|
||||
const found = cookies.find((value) => value.startsWith(`${ADMIN_SESSION_COOKIE}=`));
|
||||
if (!found) throw new Error('no cookie');
|
||||
return found.split(';')[0]!;
|
||||
}
|
||||
|
||||
describe('financial disclosure publishing', () => {
|
||||
let app: INestApplication, prisma: PrismaService, passwords: PasswordService;
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = module.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }),
|
||||
);
|
||||
await app.init();
|
||||
prisma = app.get(PrismaService);
|
||||
passwords = app.get(PasswordService);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await prisma.auditLog.deleteMany();
|
||||
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||
await prisma.financialDisclosure.deleteMany();
|
||||
await prisma.authSession.deleteMany();
|
||||
await prisma.userRole.deleteMany();
|
||||
await prisma.adminCredential.deleteMany();
|
||||
await prisma.staffProfile.deleteMany();
|
||||
});
|
||||
afterAll(() => app.close());
|
||||
async function admin() {
|
||||
const username = `finance.${crypto.randomUUID()}`;
|
||||
const role = await prisma.role.findUniqueOrThrow({ where: { code: 'ADMIN' } });
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
publicId: crypto.randomUUID(),
|
||||
userType: 'STAFF',
|
||||
staffProfile: { create: { displayName: '财务公开管理员' } },
|
||||
adminCredential: {
|
||||
create: { username, passwordHash: await passwords.hash('correct horse battery staple') },
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } });
|
||||
const response = await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/auth/login')
|
||||
.set('origin', ORIGIN)
|
||||
.send({ username, password: 'correct horse battery staple' })
|
||||
.expect(201);
|
||||
return sessionCookie(response);
|
||||
}
|
||||
|
||||
it('keeps drafts private, sanitizes rich text, publishes and audits the document', async () => {
|
||||
const cookie = await admin();
|
||||
const created = await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/financial-disclosures')
|
||||
.set('origin', ORIGIN)
|
||||
.set('cookie', cookie)
|
||||
.send({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
title: '2026年度财务公开',
|
||||
summary: '年度财务公开说明',
|
||||
richText: '<h2>年度说明</h2><p onclick="alert(1)">公开正文</p><script>alert(1)</script>',
|
||||
isPinned: true,
|
||||
sortOrder: 10,
|
||||
})
|
||||
.expect(201);
|
||||
const disclosure =
|
||||
body<ApiSuccessResponse<FinancialDisclosureDetailResponse>>(created).data.disclosure;
|
||||
expect(disclosure.richText).not.toMatch(/script|onclick/);
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get(`/api/v1/financial-disclosures/${disclosure.publicId}`)
|
||||
.expect(404);
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/financial-disclosures/${disclosure.publicId}/transition`)
|
||||
.set('origin', ORIGIN)
|
||||
.set('cookie', cookie)
|
||||
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'PUBLISHED' })
|
||||
.expect(201);
|
||||
const listed = await request(app.getHttpServer() as Server)
|
||||
.get('/api/v1/financial-disclosures?page=1&pageSize=20')
|
||||
.expect(200);
|
||||
expect(
|
||||
body<ApiSuccessResponse<PaginatedFinancialDisclosuresResponse>>(listed).data.items,
|
||||
).toHaveLength(1);
|
||||
expect(await prisma.financialDisclosureStatusHistory.count()).toBe(2);
|
||||
expect(await prisma.auditLog.count({ where: { resourceType: 'FINANCIAL_DISCLOSURE' } })).toBe(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects edits after publishing and hides archived documents', async () => {
|
||||
const cookie = await admin(),
|
||||
requestId = crypto.randomUUID();
|
||||
const created = await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/financial-disclosures')
|
||||
.set('origin', ORIGIN)
|
||||
.set('cookie', cookie)
|
||||
.send({
|
||||
clientRequestId: requestId,
|
||||
title: '专项财务公开',
|
||||
richText: '<p>专项公开正文内容</p>',
|
||||
})
|
||||
.expect(201);
|
||||
const item =
|
||||
body<ApiSuccessResponse<FinancialDisclosureDetailResponse>>(created).data.disclosure;
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/financial-disclosures/${item.publicId}/transition`)
|
||||
.set('origin', ORIGIN)
|
||||
.set('cookie', cookie)
|
||||
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'PUBLISHED' })
|
||||
.expect(201);
|
||||
const rejected = await request(app.getHttpServer() as Server)
|
||||
.patch(`/api/v1/admin/financial-disclosures/${item.publicId}`)
|
||||
.set('origin', ORIGIN)
|
||||
.set('cookie', cookie)
|
||||
.send({
|
||||
expectedRevision: item.revision + 1,
|
||||
title: item.title,
|
||||
richText: '<p>覆盖已发布内容</p>',
|
||||
})
|
||||
.expect(409);
|
||||
expect(body<ApiErrorResponse>(rejected).error.code).toBe('INVALID_STATE_TRANSITION');
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/financial-disclosures/${item.publicId}/transition`)
|
||||
.set('origin', ORIGIN)
|
||||
.set('cookie', cookie)
|
||||
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'ARCHIVED' })
|
||||
.expect(201);
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get(`/api/v1/financial-disclosures/${item.publicId}`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,8 @@ describe('Phase 4 follow-up closure', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||
await prisma.financialDisclosure.deleteMany();
|
||||
await prisma.notificationDelivery.deleteMany();
|
||||
await prisma.notification.deleteMany();
|
||||
await prisma.notificationPreference.deleteMany();
|
||||
|
||||
@@ -70,6 +70,8 @@ describe('Phase 5 projects, activities and volunteers', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||
await prisma.financialDisclosure.deleteMany();
|
||||
throttlerStorage.storage.clear();
|
||||
await prisma.auditLog.deleteMany();
|
||||
await prisma.notificationDelivery.deleteMany();
|
||||
|
||||
@@ -69,6 +69,8 @@ describe('Phase 2 service applications', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||
await prisma.financialDisclosure.deleteMany();
|
||||
await prisma.notificationDelivery.deleteMany();
|
||||
await prisma.notification.deleteMany();
|
||||
await prisma.notificationPreference.deleteMany();
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"pages/volunteer-center/volunteer-center",
|
||||
"pages/content/content",
|
||||
"pages/content-detail/content-detail",
|
||||
"pages/notifications/notifications"
|
||||
"pages/notifications/notifications",
|
||||
"pages/financial-disclosures/financial-disclosures",
|
||||
"pages/financial-disclosure-detail/financial-disclosure-detail"
|
||||
],
|
||||
"window": {
|
||||
"backgroundColor": "#F7F5F2",
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "navigationBarTitleText": "财务公开详情" }
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import type { FinancialDisclosureDetail } from '@lehuo/contracts';
|
||||
import { fetchFinancialDisclosure } from '../../services/financial-disclosures';
|
||||
Page({
|
||||
data: { loading: true, message: '', disclosure: null as FinancialDisclosureDetail | null },
|
||||
onLoad(options) {
|
||||
const id = String(options['publicId'] || '');
|
||||
if (!id) {
|
||||
this.setData({ loading: false, message: '缺少财务公开文档标识,请返回重试' });
|
||||
return;
|
||||
}
|
||||
void this.load(id);
|
||||
},
|
||||
async load(id: string) {
|
||||
this.setData({ loading: true, message: '' });
|
||||
try {
|
||||
const result = await fetchFinancialDisclosure(id);
|
||||
this.setData({ disclosure: result.disclosure });
|
||||
} catch (error) {
|
||||
this.setData({ message: error instanceof Error ? error.message : '财务公开内容加载失败' });
|
||||
} finally {
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
+1
@@ -0,0 +1 @@
|
||||
<view class="page disclosure-detail-page"><view wx:if="{{loading}}" class="state-card">正在加载…</view><view wx:elif="{{message}}" class="state-card"><text class="state-title">文档暂时无法查看</text><text class="state-text">{{message}}</text></view><view wx:elif="{{disclosure}}" class="disclosure-paper"><text class="disclosure-title">{{disclosure.title}}</text><text wx:if="{{disclosure.summary}}" class="disclosure-summary">{{disclosure.summary}}</text><view class="disclosure-meta"><text>发布时间</text><text>{{disclosure.publishedAt}}</text></view><scroll-view class="document-scroll" scroll-x="true"><rich-text class="disclosure-rich-text" nodes="{{disclosure.richText}}" selectable="true"></rich-text></scroll-view><text class="disclosure-note">本页面内容由乐活公益管理后台正式发布。</text></view></view>
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
@import '../../app.wxss';
|
||||
.disclosure-paper {
|
||||
padding: 38rpx 32rpx;
|
||||
border-radius: 26rpx;
|
||||
background: #fff;
|
||||
box-shadow: 0 10rpx 30rpx rgba(38, 42, 48, 0.07);
|
||||
}
|
||||
.disclosure-title,
|
||||
.disclosure-summary,
|
||||
.disclosure-note {
|
||||
display: block;
|
||||
}
|
||||
.disclosure-title {
|
||||
color: #25292f;
|
||||
font-size: 42rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.disclosure-summary {
|
||||
margin-top: 22rpx;
|
||||
padding: 22rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #fff5ef;
|
||||
color: #684d40;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.disclosure-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 28rpx 0;
|
||||
padding-bottom: 22rpx;
|
||||
border-bottom: 1rpx solid #eceef1;
|
||||
color: #8c929a;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.document-scroll {
|
||||
width: 100%;
|
||||
}
|
||||
.disclosure-rich-text {
|
||||
display: block;
|
||||
min-width: 100%;
|
||||
color: #333840;
|
||||
font-size: 30rpx;
|
||||
line-height: 1.85;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.disclosure-note {
|
||||
margin-top: 36rpx;
|
||||
padding-top: 24rpx;
|
||||
border-top: 1rpx solid #eceef1;
|
||||
color: #9a9fa7;
|
||||
font-size: 23rpx;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "navigationBarTitleText": "财务公开" }
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { FinancialDisclosureListItem } from '@lehuo/contracts';
|
||||
import { fetchFinancialDisclosures } from '../../services/financial-disclosures';
|
||||
Page({
|
||||
data: { loading: true, message: '', items: [] as FinancialDisclosureListItem[] },
|
||||
onLoad() {
|
||||
void this.load();
|
||||
},
|
||||
async load() {
|
||||
this.setData({ loading: true, message: '' });
|
||||
try {
|
||||
const result = await fetchFinancialDisclosures();
|
||||
this.setData({ items: result.items });
|
||||
} catch (error) {
|
||||
this.setData({ message: error instanceof Error ? error.message : '财务公开内容加载失败' });
|
||||
} finally {
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
open(event: WechatMiniprogram.BaseEvent) {
|
||||
const id = String(event.currentTarget.dataset['id'] || '');
|
||||
if (id)
|
||||
void wx.navigateTo({
|
||||
url: `/pages/financial-disclosure-detail/financial-disclosure-detail?publicId=${encodeURIComponent(id)}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<view class="page financial-list-page"><view class="financial-hero"><text class="financial-hero__eyebrow">公开 · 透明 · 可查阅</text><text class="financial-hero__title">财务公开</text><text class="financial-hero__copy">查看乐活公益经管理端正式发布的财务公开文档。</text></view><view wx:if="{{loading}}" class="state-card">正在加载…</view><view wx:elif="{{message}}" class="state-card"><text class="state-title">暂时无法加载</text><text class="state-text">{{message}}</text><button class="secondary-button" bindtap="load">重新加载</button></view><view wx:elif="{{!items.length}}" class="state-card"><text class="state-title">暂无公开文档</text><text class="state-text">财务公开文档发布后会展示在这里。</text></view><button wx:for="{{items}}" wx:key="publicId" class="financial-card" data-id="{{item.publicId}}" bindtap="open"><view class="financial-card__top"><text wx:if="{{item.isPinned}}" class="pin-tag">置顶</text><text class="financial-card__date">{{item.publishedAt}}</text></view><text class="financial-card__title">{{item.title}}</text><text class="financial-card__summary">{{item.summary || '点击查看完整公开内容'}}</text><text class="financial-card__link">查看详情 ›</text></button></view>
|
||||
@@ -0,0 +1,74 @@
|
||||
@import '../../app.wxss';
|
||||
.financial-hero {
|
||||
padding: 40rpx 34rpx;
|
||||
border-radius: 28rpx;
|
||||
background: linear-gradient(135deg, #ff6b35, #ff9b5f);
|
||||
color: #fff;
|
||||
box-shadow: 0 18rpx 40rpx rgba(240, 90, 40, 0.18);
|
||||
}
|
||||
.financial-hero text,
|
||||
.financial-card text {
|
||||
display: block;
|
||||
}
|
||||
.financial-hero__eyebrow {
|
||||
font-size: 24rpx;
|
||||
opacity: 0.86;
|
||||
}
|
||||
.financial-hero__title {
|
||||
margin-top: 8rpx;
|
||||
font-size: 46rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.financial-hero__copy {
|
||||
margin-top: 14rpx;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.financial-card {
|
||||
width: 100%;
|
||||
margin-top: 24rpx;
|
||||
padding: 30rpx;
|
||||
border: 0;
|
||||
border-radius: 24rpx;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
box-shadow: 0 8rpx 28rpx rgba(38, 42, 48, 0.07);
|
||||
}
|
||||
.financial-card::after {
|
||||
border: 0;
|
||||
}
|
||||
.financial-card__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 38rpx;
|
||||
}
|
||||
.pin-tag {
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #fff0e8;
|
||||
color: #ee5b27;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.financial-card__date {
|
||||
margin-left: auto;
|
||||
color: #969ba3;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.financial-card__title {
|
||||
margin-top: 16rpx;
|
||||
color: #262a30;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.financial-card__summary {
|
||||
margin-top: 14rpx;
|
||||
color: #676d76;
|
||||
font-size: 27rpx;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.financial-card__link {
|
||||
margin-top: 20rpx;
|
||||
color: #f05a28;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -43,6 +43,9 @@ Page({
|
||||
organization() {
|
||||
void wx.navigateTo({ url: '/pages/content/content?type=ORGANIZATION_INTRO' });
|
||||
},
|
||||
financialDisclosures() {
|
||||
void wx.navigateTo({ url: '/pages/financial-disclosures/financial-disclosures' });
|
||||
},
|
||||
openProject(event: WechatMiniprogram.BaseEvent) {
|
||||
void wx.navigateTo({
|
||||
url: `/pages/project-detail/project-detail?publicId=${event.currentTarget.dataset['id'] as string}`,
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
<view wx:if="{{!activities.length}}" class="empty-strip">暂无可查看活动</view>
|
||||
<button wx:for="{{activities}}" wx:key="publicId" class="feature-card" data-id="{{item.publicId}}" bindtap="openActivity"><view class="feature-card__art green">♧</view><view class="feature-card__body"><text class="feature-card__tag">{{item.statusLabel}}</text><text class="feature-card__title">{{item.title}}</text><text class="feature-card__copy">{{item.summary}}</text><text class="feature-card__meta">已报名 {{item.activeRegistrationCount}} / {{item.capacity === null ? '不限' : item.capacity}}</text></view></button>
|
||||
<view class="section-heading"><text class="section-title">了解乐活</text><text class="section-note">所有内容均经审核发布</text></view>
|
||||
<view class="link-grid"><button bindtap="content"><text class="link-grid__icon orange">闻</text><text>公益动态</text><text>›</text></button><button bindtap="organization"><text class="link-grid__icon blue">乐</text><text>机构介绍</text><text>›</text></button></view>
|
||||
<view class="link-grid"><button bindtap="content"><text class="link-grid__icon orange">闻</text><text>公益动态</text><text>›</text></button><button bindtap="organization"><text class="link-grid__icon blue">乐</text><text>机构介绍</text><text>›</text></button><button bindtap="financialDisclosures"><text class="link-grid__icon green">财</text><text>财务公开</text><text>›</text></button></view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {
|
||||
FinancialDisclosureDetailResponse,
|
||||
PaginatedFinancialDisclosuresResponse,
|
||||
} from '@lehuo/contracts';
|
||||
import { apiRequest } from './api-client';
|
||||
export const fetchFinancialDisclosures = () =>
|
||||
apiRequest<PaginatedFinancialDisclosuresResponse>(
|
||||
'/api/v1/financial-disclosures?page=1&pageSize=50',
|
||||
);
|
||||
export const fetchFinancialDisclosure = (id: string) =>
|
||||
apiRequest<FinancialDisclosureDetailResponse>(
|
||||
`/api/v1/financial-disclosures/${encodeURIComponent(id)}`,
|
||||
);
|
||||
@@ -13,6 +13,8 @@ const allowedTags = new Set([
|
||||
'input',
|
||||
'label',
|
||||
'picker',
|
||||
'rich-text',
|
||||
'scroll-view',
|
||||
'switch',
|
||||
'text',
|
||||
'textarea',
|
||||
|
||||
Reference in New Issue
Block a user