project-file.service.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. import { Injectable } from '@angular/core';
  2. import { NovaStorage, NovaFile } from 'fmode-ng/core';
  3. import { FmodeParse, FmodeObject } from 'fmode-ng/parse';
  4. const Parse = FmodeParse.with('nova');
  5. @Injectable({
  6. providedIn: 'root'
  7. })
  8. export class ProjectFileService {
  9. /**
  10. * 上传项目文件并保存到ProjectFile表
  11. * @param file 要上传的文件
  12. * @param projectId 项目ID
  13. * @param fileType 文件类型
  14. * @param spaceId 空间ID(可选)
  15. * @param stage 项目阶段(可选)
  16. * @param additionalMetadata 额外元数据(可选)
  17. * @param onProgress 上传进度回调
  18. * @returns 上传后的NovaFile对象
  19. */
  20. async uploadProjectFile(
  21. file: File,
  22. projectId: string,
  23. fileType: string,
  24. spaceId?: string,
  25. stage?: string,
  26. additionalMetadata?: any,
  27. onProgress?: (progress: number) => void
  28. ): Promise<NovaFile> {
  29. try {
  30. // 获取公司ID
  31. const cid = localStorage.getItem('company');
  32. if (!cid) {
  33. throw new Error('公司ID未找到');
  34. }
  35. // 初始化存储
  36. const storage = await NovaStorage.withCid(cid);
  37. // 构建prefixKey
  38. let prefixKey = `project/${projectId}`;
  39. if (spaceId) {
  40. prefixKey += `/space/${spaceId}`;
  41. }
  42. if (stage) {
  43. prefixKey += `/stage/${stage}`;
  44. }
  45. // 上传文件
  46. const uploadedFile: NovaFile = await storage.upload(file, {
  47. prefixKey,
  48. onProgress: (progress: { total: { percent: number } }) => {
  49. if (onProgress) {
  50. onProgress(progress.total.percent);
  51. }
  52. }
  53. });
  54. // 保存到Attachment表
  55. await this.saveToAttachmentTable(uploadedFile, projectId, fileType, spaceId, stage, additionalMetadata);
  56. return uploadedFile;
  57. } catch (error) {
  58. console.error('项目文件上传失败:', error);
  59. throw error;
  60. }
  61. }
  62. /**
  63. * 保存文件信息到Attachment表
  64. */
  65. private async saveToAttachmentTable(
  66. file: NovaFile,
  67. projectId: string,
  68. fileType: string,
  69. spaceId?: string,
  70. stage?: string,
  71. additionalMetadata?: any
  72. ): Promise<FmodeObject> {
  73. const attachment = new Parse.Object('Attachment');
  74. // 设置基本字段
  75. attachment.set('size', file.size);
  76. attachment.set('url', file.url);
  77. attachment.set('name', file.name);
  78. attachment.set('mime', file.type);
  79. attachment.set('md5', file.md5);
  80. attachment.set('metadata', {
  81. ...file.metadata,
  82. projectId,
  83. fileType,
  84. spaceId,
  85. stage,
  86. ...additionalMetadata
  87. });
  88. // 设置关联关系
  89. const cid = localStorage.getItem('company');
  90. if (cid) {
  91. let company = new Parse.Object('Company');
  92. company.id = cid
  93. if (company) {
  94. attachment.set('company', company.toPointer());
  95. }
  96. }
  97. // 设置当前用户
  98. const currentUser = Parse.User.current();
  99. if (currentUser) {
  100. attachment.set('user', currentUser);
  101. }
  102. const savedAttachment = await attachment.save();
  103. return savedAttachment;
  104. }
  105. /**
  106. * 保存到ProjectFile表
  107. */
  108. async saveToProjectFile(
  109. attachment: FmodeObject,
  110. projectId: string,
  111. fileType: string,
  112. spaceId?: string,
  113. stage?: string
  114. ): Promise<FmodeObject> {
  115. const projectFile = new Parse.Object('ProjectFile');
  116. // 获取项目
  117. const projectQuery = new Parse.Query("Project");
  118. const project = await projectQuery.get(projectId);
  119. // 设置字段
  120. projectFile.set('project', project);
  121. projectFile.set('attach', attachment);
  122. projectFile.set('fileType', fileType);
  123. projectFile.set('fileUrl', attachment.get('url'));
  124. projectFile.set('fileName', attachment.get('name'));
  125. projectFile.set('fileSize', attachment.get('size'));
  126. if (stage) {
  127. projectFile.set('stage', stage);
  128. }
  129. // ✨ 增强:完整保存元数据到 ProjectFile.data,包括审批状态等
  130. const attachmentMetadata = attachment.get('metadata') || {};
  131. const deliverableId = attachmentMetadata.deliverableId;
  132. const data = {
  133. spaceId,
  134. uploadedAt: new Date(),
  135. fileType,
  136. deliverableId,
  137. // ✨ 新增:提交信息跟踪字段
  138. submittedAt: attachmentMetadata.submittedAt || new Date().toISOString(),
  139. submittedBy: attachmentMetadata.submittedBy || Parse.User.current()?.id,
  140. submittedByName: attachmentMetadata.submittedByName || Parse.User.current()?.get('name') || Parse.User.current()?.get('username'),
  141. modifiedAt: new Date().toISOString(),
  142. modifiedBy: Parse.User.current()?.id,
  143. modifiedByName: Parse.User.current()?.get('name') || Parse.User.current()?.get('username'),
  144. // AI分析结果
  145. analysisResult: attachmentMetadata.analysisResult,
  146. aiConfidence: attachmentMetadata.analysisResult?.content?.confidence,
  147. aiSuggestedStage: attachmentMetadata.analysisResult?.suggestedStage,
  148. aiQualityScore: attachmentMetadata.analysisResult?.quality?.score,
  149. // 交付清单状态
  150. deliveryStatus: 'submitted', // submitted, approved, rejected
  151. deliveryListId: attachmentMetadata.deliveryListId, // 交付清单ID
  152. // ✨ 保存所有元数据(包含 approvalStatus, uploadedByName, uploadedById 等)
  153. ...attachmentMetadata
  154. };
  155. projectFile.set('data', data);
  156. // 设置上传者
  157. const currentUser = Parse.User.current();
  158. if (currentUser) {
  159. projectFile.set('uploadedBy', currentUser);
  160. }
  161. const savedProjectFile = await projectFile.save();
  162. console.log('✅ ProjectFile已保存,data字段:', savedProjectFile.get('data'));
  163. return savedProjectFile;
  164. }
  165. /**
  166. * 删除项目文件
  167. */
  168. async deleteProjectFile(projectFileId: string): Promise<void> {
  169. try {
  170. // 删除ProjectFile记录
  171. const query = new Parse.Query("ProjectFile");
  172. const projectFile = await query.get(projectFileId);
  173. // 删除Attachment记录
  174. const attachment = projectFile.get('attach');
  175. if (attachment) {
  176. // 如果attachment是Pointer对象,需要先获取完整的对象
  177. if (typeof attachment.destroy === 'function') {
  178. await attachment.destroy();
  179. } else if (attachment.id) {
  180. // 如果是Pointer,需要先获取完整的Attachment对象
  181. const attachmentQuery = new Parse.Query("Attachment");
  182. const fullAttachment = await attachmentQuery.get(attachment.id);
  183. await fullAttachment.destroy();
  184. }
  185. }
  186. // 删除ProjectFile记录
  187. await projectFile.destroy();
  188. } catch (error) {
  189. console.error('删除项目文件失败:', error);
  190. throw error;
  191. }
  192. }
  193. /**
  194. * 获取项目文件列表
  195. */
  196. async getProjectFiles(
  197. projectId: string,
  198. filters?: {
  199. fileType?: string;
  200. spaceId?: string;
  201. stage?: string;
  202. }
  203. ): Promise<FmodeObject[]> {
  204. try {
  205. const ProjectFile = new Parse.Object('ProjectFile');
  206. const query = new Parse.Query("ProjectFile");
  207. // 关联项目查询
  208. const Project = new Parse.Object('Project');
  209. const projectQuery = new Parse.Query("Project");
  210. projectQuery.equalTo('objectId', projectId);
  211. query.matchesQuery('project', projectQuery);
  212. query.include('attach', 'uploadedBy');
  213. query.descending('createdAt');
  214. // 应用过滤器
  215. if (filters?.fileType) {
  216. query.equalTo('fileType', filters.fileType);
  217. }
  218. if (filters?.stage) {
  219. query.equalTo('stage', filters.stage);
  220. }
  221. const results = await query.find();
  222. // 如果有空间ID过滤,从data中筛选
  223. if (filters?.spaceId) {
  224. return results.filter(result => {
  225. const data = result.get('data');
  226. return data?.spaceId === filters.spaceId;
  227. });
  228. }
  229. return results;
  230. } catch (error) {
  231. console.error('获取项目文件列表失败:', error);
  232. throw error;
  233. }
  234. }
  235. /**
  236. * 批量上传文件
  237. */
  238. async uploadMultipleFiles(
  239. files: File[],
  240. projectId: string,
  241. fileType: string,
  242. spaceId?: string,
  243. stage?: string,
  244. onProgress?: (fileIndex: number, progress: number) => void
  245. ): Promise<NovaFile[]> {
  246. const results: NovaFile[] = [];
  247. for (let i = 0; i < files.length; i++) {
  248. const file = files[i];
  249. try {
  250. const uploadedFile = await this.uploadProjectFile(
  251. file,
  252. projectId,
  253. fileType,
  254. spaceId,
  255. stage,
  256. undefined,
  257. (progress) => {
  258. if (onProgress) {
  259. onProgress(i, progress);
  260. }
  261. }
  262. );
  263. results.push(uploadedFile);
  264. } catch (error) {
  265. console.error(`文件 ${file.name} 上传失败:`, error);
  266. // 继续上传其他文件
  267. }
  268. }
  269. return results;
  270. }
  271. /**
  272. * 验证文件
  273. */
  274. validateFile(file: File, maxSize: number = 50 * 1024 * 1024, allowedTypes?: string[]): boolean {
  275. // 检查文件大小
  276. if (file.size > maxSize) {
  277. return false;
  278. }
  279. // 检查文件类型
  280. if (allowedTypes && !allowedTypes.includes(file.type)) {
  281. return false;
  282. }
  283. return true;
  284. }
  285. /**
  286. * 获取文件类型标签
  287. */
  288. getFileTypeLabel(fileType: string): string {
  289. const typeMap: Record<string, string> = {
  290. 'image/jpeg': '图片',
  291. 'image/png': '图片',
  292. 'image/gif': '图片',
  293. 'image/webp': '图片',
  294. 'video/mp4': '视频',
  295. 'video/mov': '视频',
  296. 'video/avi': '视频',
  297. 'application/pdf': 'PDF',
  298. 'application/msword': 'Word',
  299. 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word',
  300. 'application/vnd.ms-excel': 'Excel',
  301. 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel',
  302. 'application/vnd.ms-powerpoint': 'PPT',
  303. 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PPT'
  304. };
  305. return typeMap[fileType] || '其他';
  306. }
  307. /**
  308. * 格式化文件大小
  309. */
  310. formatFileSize(bytes: number): string {
  311. if (bytes === 0) return '0 B';
  312. const k = 1024;
  313. const sizes = ['B', 'KB', 'MB', 'GB'];
  314. const i = Math.floor(Math.log(bytes) / Math.log(k));
  315. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  316. }
  317. /**
  318. * 上传文件并创建 Attachment 与 ProjectFile 记录,返回 ProjectFile
  319. */
  320. async uploadProjectFileWithRecord(
  321. file: File,
  322. projectId: string,
  323. fileType: string,
  324. spaceId?: string,
  325. stage?: string,
  326. additionalMetadata?: any,
  327. onProgress?: (progress: number) => void
  328. ): Promise<FmodeObject> {
  329. // 🔥 添加重试机制
  330. const maxRetries = 3;
  331. let lastError: any = null;
  332. for (let attempt = 1; attempt <= maxRetries; attempt++) {
  333. try {
  334. console.log(`📤 上传尝试 ${attempt}/${maxRetries}: ${file.name}`);
  335. // 🔥 修复存储桶配置:使用默认存储桶
  336. let cid = localStorage.getItem('company');
  337. if (!cid) {
  338. console.warn('⚠️ 未找到公司ID,使用默认存储桶');
  339. cid = 'cDL6R1hgSi'; // 默认公司ID
  340. }
  341. console.log(`📦 使用存储桶CID: ${cid}`);
  342. const storage = await NovaStorage.withCid(cid);
  343. let prefixKey = `project/${projectId}`;
  344. if (spaceId) {
  345. prefixKey += `/space/${spaceId}`;
  346. }
  347. if (stage) {
  348. prefixKey += `/stage/${stage}`;
  349. }
  350. console.log(`📤 开始上传文件: ${file.name}`, {
  351. size: `${(file.size / 1024 / 1024).toFixed(2)}MB`,
  352. type: file.type,
  353. prefixKey,
  354. projectId
  355. });
  356. const uploadedFile = await storage.upload(file, {
  357. prefixKey,
  358. onProgress: (progress: { total: { percent: number } }) => {
  359. if (onProgress) {
  360. onProgress(progress.total.percent);
  361. }
  362. }
  363. });
  364. console.log(`✅ 文件上传成功: ${file.name}`, {
  365. url: uploadedFile.url,
  366. key: uploadedFile.key
  367. });
  368. const attachment = await this.saveToAttachmentTable(
  369. uploadedFile,
  370. projectId,
  371. fileType,
  372. spaceId,
  373. stage,
  374. additionalMetadata
  375. );
  376. const projectFile = await this.saveToProjectFile(
  377. attachment,
  378. projectId,
  379. fileType,
  380. spaceId,
  381. stage
  382. );
  383. return projectFile;
  384. } catch (error: any) {
  385. lastError = error;
  386. console.error(`❌ 上传尝试 ${attempt}/${maxRetries} 失败:`, error);
  387. console.error('❌ 错误详情:', {
  388. message: error?.message,
  389. code: error?.code || error?.status,
  390. name: error?.name,
  391. fileName: file.name,
  392. fileSize: `${(file.size / 1024 / 1024).toFixed(2)}MB`,
  393. projectId,
  394. attempt
  395. });
  396. // 🔥 如果是631错误且还有重试次数,等待后重试
  397. if ((error?.status === 631 || error?.code === 631) && attempt < maxRetries) {
  398. const waitTime = Math.min(1000 * Math.pow(2, attempt - 1), 5000); // 指数退避
  399. console.log(`⏳ 等待 ${waitTime}ms 后重试...`);
  400. await new Promise(resolve => setTimeout(resolve, waitTime));
  401. continue;
  402. }
  403. // 🔥 如果是最后一次尝试,抛出详细错误
  404. if (attempt === maxRetries) {
  405. if (error?.status === 631 || error?.code === 631) {
  406. const errorMsg = `存储服务错误(631):${file.name}\n已重试${maxRetries}次\n\n可能原因:\n1. 存储配额已满(最可能)\n2. 项目ID无效: ${projectId}\n3. 存储服务暂时不可用\n4. 网络连接问题\n\n建议:\n- 联系管理员检查OBS存储配额\n- 稍后再试\n- 尝试上传更小的文件`;
  407. console.error('❌ 631错误(已重试):', errorMsg);
  408. throw new Error(errorMsg);
  409. }
  410. throw error;
  411. }
  412. }
  413. }
  414. // 不应该到达这里
  415. throw lastError || new Error('上传失败');
  416. }
  417. /**
  418. * 保存空间需求数据到ProjectFile表
  419. */
  420. async saveSpaceRequirements(projectId: string, spaceId: string, requirementsData: any): Promise<void> {
  421. try {
  422. console.log(`💾 保存空间需求数据: ${spaceId}`, requirementsData);
  423. // 查找现有记录
  424. const query = new Parse.Query('ProjectFile');
  425. query.equalTo('project', { __type: 'Pointer', className: 'Project', objectId: projectId });
  426. query.equalTo('fileType', 'space_requirements');
  427. // 在data字段中查找匹配的spaceId
  428. const existingRecords = await query.find();
  429. let existingRecord = null;
  430. for (const record of existingRecords) {
  431. const data = record.get('data');
  432. if (data && data.spaceId === spaceId) {
  433. existingRecord = record;
  434. break;
  435. }
  436. }
  437. const dataToSave = {
  438. spaceId: spaceId,
  439. ...requirementsData,
  440. spaceRequirementsVersion: '1.0',
  441. lastUpdated: new Date().toISOString()
  442. };
  443. if (existingRecord) {
  444. // 更新现有记录
  445. existingRecord.set('data', dataToSave);
  446. await existingRecord.save();
  447. console.log(`✅ 空间需求数据已更新: ${spaceId}`);
  448. } else {
  449. // 创建新记录
  450. const projectFile = new Parse.Object('ProjectFile');
  451. // 获取项目对象
  452. const projectQuery = new Parse.Query('Project');
  453. const project = await projectQuery.get(projectId);
  454. projectFile.set('project', project);
  455. projectFile.set('fileType', 'space_requirements');
  456. projectFile.set('stage', 'requirements');
  457. projectFile.set('data', dataToSave);
  458. // 设置上传者
  459. const currentUser = Parse.User.current();
  460. if (currentUser) {
  461. projectFile.set('uploadedBy', currentUser);
  462. }
  463. await projectFile.save();
  464. console.log(`✅ 空间需求数据已创建: ${spaceId}`);
  465. }
  466. } catch (error) {
  467. console.error('保存空间需求数据失败:', error);
  468. throw error;
  469. }
  470. }
  471. }