feat(projects): 增加意见反馈

This commit is contained in:
2026-06-27 08:55:33 +08:00
parent 570f284230
commit a4884035cd
25 changed files with 1536 additions and 7 deletions

View File

@@ -0,0 +1,34 @@
/** 后端统计原始返回type/status 可能为 number 或 stringcount 为整数) */
export type FeedbackStatResponse = {
total: number;
typeCounts?: Array<{ type: string | number; count: number }> | null;
statusCounts?: Array<{ status: string | number; count: number }> | null;
};
/** 后端统计原始返回 → 业务层type/status 一律 String 化为 Record 键count 缺省兜底 0data 为空全兜底 0 */
export function normalizeFeedbackStat(data: FeedbackStatResponse | null | undefined): Api.Feedback.FeedbackStat {
const typeCounts: Record<string, number> = {};
(data?.typeCounts ?? []).forEach(item => {
typeCounts[String(item.type)] = item.count ?? 0;
});
const statusCounts: Record<string, number> = {};
(data?.statusCounts ?? []).forEach(item => {
statusCounts[String(item.status)] = item.count ?? 0;
});
const total = data?.total ?? 0;
// 契约约定 total == sum(typeCounts) == sum(statusCounts)dev 下做一致性自检,便于尽早发现后端统计口径错位
if (import.meta.env.DEV) {
const typeSum = Object.values(typeCounts).reduce((sum, count) => sum + count, 0);
const statusSum = Object.values(statusCounts).reduce((sum, count) => sum + count, 0);
if (total !== typeSum || total !== statusSum) {
console.warn(
`[feedback-stat] total=${total} 与分类合计=${typeSum}、状态合计=${statusSum} 不一致,后端统计口径可能有误`
);
}
}
return { total, typeCounts, statusCounts };
}

185
src/service/api/feedback.ts Normal file
View File

@@ -0,0 +1,185 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
import { type FeedbackStatResponse, normalizeFeedbackStat } from './feedback-normalize';
const FEEDBACK_PREFIX = `${SYSTEM_SERVICE_PREFIX}/feedback`;
type FeedbackResponse = Omit<Api.Feedback.FeedbackItem, 'id' | 'creator' | 'attachments' | 'contentPreview'> & {
id: string | number;
creator: string | number;
/** 后端原始:附件的 JSON 数组字符串(前端约定存完整附件对象,兼容历史纯 URL 数组) */
attachmentUrls?: string | null;
};
type FeedbackPageResponse = {
total: number;
list: FeedbackResponse[];
};
/** 从代理 URL 末段拆出文件名(兜底历史纯 URL 附件的展示名) */
function deriveAttachmentName(url: string): string {
try {
const path = decodeURI(url.split('?')[0]);
const idx = path.lastIndexOf('/');
return idx >= 0 ? path.slice(idx + 1) : path;
} catch {
return url;
}
}
/**
* 单个附件归一化为业务层 AttachmentItemID 铁律fileId/configId 一律 String
* - 对象形态(新版存储):按字段对齐;
* - 字符串形态(历史仅存 URL补出 url + 派生 namefileId 缺省空串(不可下载/清理,仅展示)。
*/
function normalizeAttachment(raw: unknown): Api.Project.AttachmentItem | null {
if (typeof raw === 'string') {
return { fileId: '', url: raw, name: deriveAttachmentName(raw) };
}
if (raw && typeof raw === 'object') {
const item = raw as Record<string, unknown>;
const url = typeof item.url === 'string' ? item.url : '';
return {
fileId: item.fileId === null || item.fileId === undefined ? '' : String(item.fileId),
url,
name: typeof item.name === 'string' && item.name ? item.name : deriveAttachmentName(url),
size: typeof item.size === 'number' ? item.size : undefined,
contentType: typeof item.contentType === 'string' ? item.contentType : undefined,
configId: item.configId === null || item.configId === undefined ? undefined : String(item.configId),
path: typeof item.path === 'string' ? item.path : undefined
};
}
return null;
}
/** 把后端 attachmentUrls(JSON 字符串) 安全解析成附件对象数组;空 / 异常兜底为 [] */
function parseAttachments(raw?: string | null): Api.Project.AttachmentItem[] {
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.map(normalizeAttachment).filter((item): item is Api.Project.AttachmentItem => item !== null);
} catch {
return [];
}
}
/** 列表「内容」列去富文本标签取纯文本预览content 为 HTML预算一次免每次渲染重扫 */
function stripHtml(html: string | null | undefined): string {
if (!html) {
return '';
}
return html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/** 后端记录 → 业务层口径ID 铁律 String 兜底 + 附件 parse + 内容纯文本预览预算) */
function normalizeFeedback(data: FeedbackResponse): Api.Feedback.FeedbackItem {
const { attachmentUrls, ...rest } = data;
return {
...rest,
id: normalizeStringId(data.id),
creator: normalizeStringId(data.creator),
attachments: parseAttachments(attachmentUrls),
contentPreview: stripHtml(data.content)
};
}
/** 附件对象数组 → attachmentUrls JSON 字符串(无附件传空串) */
function serializeAttachments(attachments: Api.Project.AttachmentItem[]): string {
return attachments.length ? JSON.stringify(attachments) : '';
}
/** 分页(全量,不按提交人过滤;默认 createTime 倒序由后端保证) */
export async function fetchGetFeedbackPage(params: Api.Feedback.FeedbackSearchParams) {
const result = await request<FeedbackPageResponse>({
...safeJsonRequestConfig,
url: `${FEEDBACK_PREFIX}/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<FeedbackPageResponse>, data => ({
total: data.total,
list: data.list.map(normalizeFeedback)
}));
}
/** 统计聚合(左侧分面面板;全量口径,无业务筛选入参) */
export async function fetchGetFeedbackStat() {
const result = await request<FeedbackStatResponse>({
...safeJsonRequestConfig,
url: `${FEEDBACK_PREFIX}/stat`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<FeedbackStatResponse>, normalizeFeedbackStat);
}
/** 详情 */
export async function fetchGetFeedback(id: string) {
const result = await request<FeedbackResponse>({
...safeJsonRequestConfig,
url: `${FEEDBACK_PREFIX}/get`,
method: 'get',
params: { id }
});
return mapServiceResult(result as ServiceRequestResult<FeedbackResponse>, normalizeFeedback);
}
/** 提交反馈attachments 对象数组在此 stringify 成 attachmentUrls无附件传空串返回新建 idID 铁律 String 化) */
export async function fetchCreateFeedback(params: Api.Feedback.FeedbackSubmitParams) {
const { attachments, ...rest } = params;
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${FEEDBACK_PREFIX}/create`,
method: 'post',
data: {
...rest,
attachmentUrls: serializeAttachments(attachments)
}
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 更新反馈编辑态attachments 对象数组在此 stringify 成 attachmentUrls无附件传空串 */
export function fetchUpdateFeedback(params: Api.Feedback.FeedbackUpdateParams) {
const { attachments, ...rest } = params;
return request<boolean>({
...safeJsonRequestConfig,
url: `${FEEDBACK_PREFIX}/update`,
method: 'put',
data: {
...rest,
attachmentUrls: serializeAttachments(attachments)
}
});
}
/** 修改处理状态(仅超管;状态可任意改,不校验流转) */
export function fetchUpdateFeedbackStatus(id: string, status: number) {
return request<boolean>({
url: `${FEEDBACK_PREFIX}/${id}/status`,
method: 'put',
data: { status }
});
}
/** 删除反馈(软删除;后端按归属二次校验) */
export function fetchDeleteFeedback(id: string) {
return request<boolean>({
url: `${FEEDBACK_PREFIX}/delete`,
method: 'delete',
params: { id }
});
}

View File

@@ -1,5 +1,6 @@
export * from './auth';
export * from './dict';
export * from './feedback';
export * from './file';
export * from './infra';
export * from './notice';