Merged PR 587: 画面実装(ダウンロード処理)
## 概要 [Task3122: 画面実装(ダウンロード処理)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3122) - ファイルバックアップポップアップからのダウンロード実行処理を実装しました。 - ファイルURL取得をAdminが適切に実施できるように権限を修正しました。 - SASトークンの開始時刻を修正しました。 - ダウンロード処理を実行できるようにするためにblobストレージのCORS設定を修正しました。 - faviconとタイトルを正式なものに差し替えました。 ## レビューポイント - faviconはアセットフォルダに一緒に入れてしまっていますが配置場所として問題ないでしょうか? - `Operation`内でループで一件ずつダウンロード・バックアップ処理を実行していますが認識あっていますでしょうか? - SASトークンの生成時の開始時刻10秒前にしていますが問題ないでしょうか? - SASトークン付きURLの発行直後にURLでダウンロード実行するとSASトークンエラーとなることがありましたので、その対応です。 - 発行直後に使うはずなのでSASトークンの開始を10秒前にしても影響はないはずと考えています。 - ほかの対応としてトークン取得後に数秒待つことも考えましたが、動作全体が遅くなってしまうのとSASトークンの作りの問題だとがんが得ているのでこのような対応をしています。 ## UIの変更 - [Task3122](https://ndstokyo.sharepoint.com/:f:/r/sites/Piranha/Shared%20Documents/General/OMDS/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88/Task3122?csf=1&web=1&e=2JPi0J) ## 動作確認状況 - ローカルで確認
This commit is contained in:
parent
aab5bf2af6
commit
8b04adf095
@ -2,9 +2,13 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="/src/assets/images/favicon.ico"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite App</title>
|
||||
<title>ODMS Cloud</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
BIN
dictation_client/src/assets/images/favicon.ico
Normal file
BIN
dictation_client/src/assets/images/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@ -1,15 +0,0 @@
|
||||
<svg width="410" height="404" viewBox="0 0 410 404" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.378 198.228 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8058C397.439 43.2894 403.768 52.1434 399.641 59.5246Z" fill="url(#paint0_linear)"/>
|
||||
<path d="M292.965 1.5744L156.801 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8664L144.395 174.33C144.198 177.662 147.258 180.248 150.51 179.498L188.42 170.749C191.967 169.931 195.172 173.055 194.443 176.622L183.18 231.775C182.422 235.487 185.907 238.661 189.532 237.56L212.947 230.446C216.577 229.344 220.065 232.527 219.297 236.242L201.398 322.875C200.278 328.294 207.486 331.249 210.492 326.603L212.5 323.5L323.454 102.072C325.312 98.3645 322.108 94.137 318.036 94.9228L279.014 102.454C275.347 103.161 272.227 99.746 273.262 96.1583L298.731 7.86689C299.767 4.27314 296.636 0.855181 292.965 1.5744Z" fill="url(#paint1_linear)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear" x1="6.00017" y1="32.9999" x2="235" y2="344" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#41D1FF"/>
|
||||
<stop offset="1" stop-color="#BD34FE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear" x1="194.651" y1="8.81818" x2="236.076" y2="292.989" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFEA83"/>
|
||||
<stop offset="0.0833333" stop-color="#FFDD35"/>
|
||||
<stop offset="1" stop-color="#FFA800"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@ -2,6 +2,7 @@ import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||
import { Assignee, Task } from "api/api";
|
||||
import { DictationState } from "./state";
|
||||
import {
|
||||
backupTasksAsync,
|
||||
getSortColumnAsync,
|
||||
listBackupPopupTasksAsync,
|
||||
listTasksAsync,
|
||||
@ -46,6 +47,7 @@ const initialState: DictationState = {
|
||||
},
|
||||
isLoading: true,
|
||||
isBackupListLoading: false,
|
||||
isDownloading: false,
|
||||
},
|
||||
};
|
||||
|
||||
@ -203,6 +205,15 @@ export const dictationSlice = createSlice({
|
||||
builder.addCase(listBackupPopupTasksAsync.rejected, (state) => {
|
||||
state.apps.isBackupListLoading = false;
|
||||
});
|
||||
builder.addCase(backupTasksAsync.pending, (state) => {
|
||||
state.apps.isDownloading = true;
|
||||
});
|
||||
builder.addCase(backupTasksAsync.fulfilled, (state) => {
|
||||
state.apps.isDownloading = false;
|
||||
});
|
||||
builder.addCase(backupTasksAsync.rejected, (state) => {
|
||||
state.apps.isDownloading = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import type { RootState } from "app/store";
|
||||
import { getTranslationID } from "translation";
|
||||
import { openSnackbar } from "features/ui/uiSlice";
|
||||
import { getAccessToken } from "features/auth";
|
||||
import { BlobClient } from "@azure/storage-blob";
|
||||
import {
|
||||
TasksResponse,
|
||||
TasksApi,
|
||||
@ -11,6 +12,7 @@ import {
|
||||
GetTypistsResponse,
|
||||
GetTypistGroupsResponse,
|
||||
Assignee,
|
||||
FilesApi,
|
||||
} from "../../api/api";
|
||||
import { Configuration } from "../../api/configuration";
|
||||
import { ErrorObject, createErrorObject } from "../../common/errors";
|
||||
@ -22,6 +24,7 @@ import {
|
||||
SORTABLE_COLUMN,
|
||||
SortableColumnType,
|
||||
} from "./constants";
|
||||
import { BackupTask } from "./types";
|
||||
|
||||
export const listTasksAsync = createAsyncThunk<
|
||||
TasksResponse,
|
||||
@ -455,7 +458,7 @@ export const listBackupPopupTasksAsync = createAsyncThunk<
|
||||
BACKUP_POPUP_LIST_SIZE,
|
||||
offset,
|
||||
BACKUP_POPUP_LIST_STATUS.join(","), // ステータスはFinished,Backupのみ
|
||||
DIRECTION.ASC,
|
||||
DIRECTION.DESC,
|
||||
SORTABLE_COLUMN.Status,
|
||||
{
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
@ -476,3 +479,96 @@ export const listBackupPopupTasksAsync = createAsyncThunk<
|
||||
return thunkApi.rejectWithValue({ error });
|
||||
}
|
||||
});
|
||||
|
||||
export const backupTasksAsync = createAsyncThunk<
|
||||
{
|
||||
// empty
|
||||
},
|
||||
{
|
||||
// パラメータ
|
||||
tasks: BackupTask[];
|
||||
},
|
||||
{
|
||||
// rejectした時の返却値の型
|
||||
rejectValue: {
|
||||
error: ErrorObject;
|
||||
};
|
||||
}
|
||||
>("dictations/backupTasksAsync", async (args, thunkApi) => {
|
||||
const { tasks } = args;
|
||||
|
||||
// apiのConfigurationを取得する
|
||||
const { getState } = thunkApi;
|
||||
const state = getState() as RootState;
|
||||
const { configuration } = state.auth;
|
||||
const accessToken = getAccessToken(state.auth);
|
||||
const config = new Configuration(configuration);
|
||||
const tasksApi = new TasksApi(config);
|
||||
const filesApi = new FilesApi(config);
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const task of tasks) {
|
||||
if (task.checked) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { data } = await filesApi.downloadLocation(task.audioFileId, {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
const { url } = data;
|
||||
const { pathname } = new URL(url);
|
||||
|
||||
const paths = pathname.split("/").filter((p) => p !== "");
|
||||
if (paths.length < 2) {
|
||||
throw new Error("invalid path");
|
||||
}
|
||||
const blobName = paths[1];
|
||||
|
||||
// コンテナとBlobを取得
|
||||
const blobClient = new BlobClient(url);
|
||||
// Blobをダウンロード
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const blobDownloadResponse = await blobClient.download();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const blobBody = await blobDownloadResponse.blobBody;
|
||||
|
||||
if (!blobBody) {
|
||||
throw new Error("invalid blobBody");
|
||||
}
|
||||
|
||||
// ダウンロードしたBlobをローカルに保存するリンクを作成してクリックする
|
||||
const blobURL = window.URL.createObjectURL(blobBody);
|
||||
const a = document.createElement("a");
|
||||
a.href = blobURL;
|
||||
a.download = blobName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.parentNode?.removeChild(a);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await tasksApi.backup(task.audioFileId, {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
thunkApi.dispatch(
|
||||
openSnackbar({
|
||||
level: "info",
|
||||
message: getTranslationID("common.message.success"),
|
||||
})
|
||||
);
|
||||
return {};
|
||||
} catch (e) {
|
||||
// e ⇒ errorObjectに変換"
|
||||
const error = createErrorObject(e);
|
||||
thunkApi.dispatch(
|
||||
openSnackbar({
|
||||
level: "error",
|
||||
message: getTranslationID("dictationPage.message.backupFailedError"),
|
||||
})
|
||||
);
|
||||
|
||||
return thunkApi.rejectWithValue({ error });
|
||||
}
|
||||
});
|
||||
|
||||
@ -68,3 +68,6 @@ export const selectBackupAllChecked = (state: RootState) => {
|
||||
|
||||
export const selectIsBackupListLoading = (state: RootState) =>
|
||||
state.dictation.apps.isBackupListLoading;
|
||||
|
||||
export const selectIsDownloading = (state: RootState) =>
|
||||
state.dictation.apps.isDownloading;
|
||||
|
||||
@ -32,6 +32,7 @@ export interface Apps {
|
||||
};
|
||||
isLoading: boolean;
|
||||
isBackupListLoading: boolean;
|
||||
isDownloading: boolean;
|
||||
}
|
||||
|
||||
export interface Backup {
|
||||
|
||||
@ -3,6 +3,7 @@ import styles from "styles/app.module.scss";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import {
|
||||
BACKUP_POPUP_LIST_SIZE,
|
||||
backupTasksAsync,
|
||||
changeBackupTaskAllCheched,
|
||||
changeBackupTaskChecked,
|
||||
listBackupPopupTasksAsync,
|
||||
@ -11,6 +12,7 @@ import {
|
||||
selectBackupTotal,
|
||||
selectCurrentBackupPage,
|
||||
selectIsBackupListLoading,
|
||||
selectIsDownloading,
|
||||
selectTotalBackupPage,
|
||||
} from "features/dictation";
|
||||
import { AppDispatch } from "app/store";
|
||||
@ -30,6 +32,7 @@ export const BackupPopup: React.FC<BackupPopupProps> = (props) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const isBackupListLoading = useSelector(selectIsBackupListLoading);
|
||||
const isDownloading = useSelector(selectIsDownloading);
|
||||
|
||||
const backupTasks = useSelector(selectBackupTasks);
|
||||
|
||||
@ -41,8 +44,11 @@ export const BackupPopup: React.FC<BackupPopupProps> = (props) => {
|
||||
|
||||
// ポップアップを閉じる処理
|
||||
const closePopup = useCallback(() => {
|
||||
if (isDownloading) {
|
||||
return;
|
||||
}
|
||||
onClose(false);
|
||||
}, [onClose]);
|
||||
}, [onClose, isDownloading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@ -50,6 +56,33 @@ export const BackupPopup: React.FC<BackupPopupProps> = (props) => {
|
||||
}
|
||||
}, [dispatch, isOpen]);
|
||||
|
||||
// ブラウザのウィンドウが閉じられようとしている場合に発火するイベントハンドラ
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
// ファイルダウンロード中に閉じられようとしている場合、ダイアログを表示させる
|
||||
if (isDownloading) {
|
||||
e.preventDefault();
|
||||
// ChromeではreturnValueが必要
|
||||
e.returnValue = "";
|
||||
}
|
||||
};
|
||||
|
||||
// コンポーネントがマウントされた時にイベントハンドラを登録する
|
||||
useEffect(() => {
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
// コンポーネントがアンマウントされるときにイベントハンドラを解除する
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
});
|
||||
|
||||
// バックアップ処理
|
||||
const onBackup = useCallback(async () => {
|
||||
await dispatch(backupTasksAsync({ tasks: backupTasks }));
|
||||
|
||||
// バックアップ処理の終了後、バックアップ対象タスク一覧を再取得する
|
||||
dispatch(listBackupPopupTasksAsync({ offset: 0 }));
|
||||
}, [dispatch, backupTasks]);
|
||||
|
||||
// ページネーションの制御
|
||||
const getFirstPage = useCallback(() => {
|
||||
dispatch(listBackupPopupTasksAsync({ offset: 0 }));
|
||||
@ -75,7 +108,11 @@ export const BackupPopup: React.FC<BackupPopupProps> = (props) => {
|
||||
<div className={styles.modalBox}>
|
||||
<p className={styles.modalTitle}>
|
||||
{t(getTranslationID("dictationPage.label.fileBackup"))}
|
||||
<button type="button" onClick={closePopup}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closePopup}
|
||||
style={{ pointerEvents: isDownloading ? "none" : "auto" }}
|
||||
>
|
||||
<img src={close} className={styles.modalTitleIcon} alt="close" />
|
||||
</button>
|
||||
</p>
|
||||
@ -97,6 +134,7 @@ export const BackupPopup: React.FC<BackupPopupProps> = (props) => {
|
||||
})
|
||||
)
|
||||
}
|
||||
disabled={isDownloading}
|
||||
/>
|
||||
</th>
|
||||
<th className={styles.noLine}>
|
||||
@ -133,6 +171,7 @@ export const BackupPopup: React.FC<BackupPopupProps> = (props) => {
|
||||
})
|
||||
);
|
||||
}}
|
||||
disabled={isDownloading}
|
||||
/>
|
||||
</td>
|
||||
<td>{task.jobNumber}</td>
|
||||
@ -208,13 +247,25 @@ export const BackupPopup: React.FC<BackupPopupProps> = (props) => {
|
||||
</dd>
|
||||
<dd className={`${styles.full} ${styles.alignCenter}`}>
|
||||
<input
|
||||
type="submit"
|
||||
type="button"
|
||||
name="submit"
|
||||
value={t(
|
||||
getTranslationID("dictationPage.label.downloadForBackup")
|
||||
)}
|
||||
className={`${styles.formSubmit} ${styles.marginBtm1} ${styles.isActive}`}
|
||||
className={`${styles.formSubmit} ${styles.marginBtm1} ${
|
||||
isDownloading || backupTasks.every((x) => !x.checked)
|
||||
? ""
|
||||
: styles.isActive
|
||||
}`}
|
||||
onClick={onBackup}
|
||||
/>
|
||||
{isDownloading && (
|
||||
<img
|
||||
src={progress_activit}
|
||||
className={styles.icLoading}
|
||||
alt="Loading"
|
||||
/>
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
</form>
|
||||
|
||||
@ -32,7 +32,6 @@ import {
|
||||
playbackAsync,
|
||||
cancelAsync,
|
||||
} from "features/dictation";
|
||||
import { selectUserName } from "features/login/index";
|
||||
import { getTranslationID } from "translation";
|
||||
import { Task } from "api/api";
|
||||
import { isAdminUser, isAuthorUser, isTypistUser } from "features/auth";
|
||||
@ -75,9 +74,6 @@ const DictationPage: React.FC = (): JSX.Element => {
|
||||
[dispatch, setIsChangeTranscriptionistPopupOpen]
|
||||
);
|
||||
|
||||
// ログイン中のユーザ名
|
||||
const myName = useSelector(selectUserName);
|
||||
|
||||
// 各カラムの表示/非表示
|
||||
const displayColumn = useSelector(selectDisplayInfo);
|
||||
|
||||
|
||||
@ -220,7 +220,9 @@ export class FilesController {
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard)
|
||||
@UseGuards(
|
||||
RoleGuard.requireds({ roles: [USER_ROLES.AUTHOR, USER_ROLES.TYPIST] }),
|
||||
RoleGuard.requireds({
|
||||
roles: [ADMIN_ROLES.ADMIN, USER_ROLES.AUTHOR, USER_ROLES.TYPIST],
|
||||
}),
|
||||
)
|
||||
async downloadLocation(
|
||||
@Req() req: Request,
|
||||
|
||||
@ -376,6 +376,7 @@ export class FilesService {
|
||||
let country: string;
|
||||
let isTypist: boolean;
|
||||
let authorId: string | undefined;
|
||||
let isAdmin: boolean;
|
||||
try {
|
||||
const user = await this.usersRepository.findUserByExternalId(externalId);
|
||||
if (!user.account) {
|
||||
@ -399,6 +400,9 @@ export class FilesService {
|
||||
country = user.account.country;
|
||||
isTypist = user.role === USER_ROLES.TYPIST;
|
||||
authorId = user.author_id ?? undefined;
|
||||
isAdmin =
|
||||
user.account.primary_admin_user_id === user.id ||
|
||||
user.account.secondary_admin_user_id === user.id;
|
||||
} catch (e) {
|
||||
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
|
||||
this.logger.log(
|
||||
@ -426,7 +430,9 @@ export class FilesService {
|
||||
}
|
||||
|
||||
try {
|
||||
const status = isTypist
|
||||
// ユーザーが管理者でないTypistの場合はステータスがIN_PROGRESSかPENDINGのタスクのみ取得できる
|
||||
const status =
|
||||
!isAdmin && isTypist
|
||||
? [TASK_STATUS.IN_PROGRESS, TASK_STATUS.PENDING]
|
||||
: Object.values(TASK_STATUS);
|
||||
|
||||
@ -445,6 +451,8 @@ export class FilesService {
|
||||
);
|
||||
}
|
||||
|
||||
// ユーザーがAdminの場合は特にチェックしない
|
||||
if (!isAdmin) {
|
||||
// ユーザーがAuthorの場合、自身が追加したタスクでない場合はエラー
|
||||
if (!isTypist && file.author_id !== authorId) {
|
||||
throw new AuthorUserNotMatchError(
|
||||
@ -458,6 +466,7 @@ export class FilesService {
|
||||
`task typist is not match. audio_file_id:${audioFileId}, task.typist_user_id:${task.typist_user_id}, userId:${userId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const filePath = `${file.file_name}`;
|
||||
|
||||
|
||||
@ -368,6 +368,10 @@ export class BlobstorageService {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// SASの開始時刻より前に実行するとエラーになるため、開始時刻を15分前に設定
|
||||
const startDate = new Date();
|
||||
startDate.setTime(startDate.getTime() - 15 * 60 * 1000);
|
||||
|
||||
//SASの有効期限を設定
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setHours(expiryDate.getHours() + this.sasTokenExpireHour);
|
||||
@ -382,7 +386,7 @@ export class BlobstorageService {
|
||||
containerName: containerClient.containerName,
|
||||
blobName: blobClient.name,
|
||||
permissions: permissions,
|
||||
startsOn: new Date(),
|
||||
startsOn: startDate,
|
||||
expiresOn: expiryDate,
|
||||
},
|
||||
sharedKeyCredential,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user