create.vue 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. <template>
  2. <div class="admin--page-content">
  3. <div class="admin--form">
  4. <form @submit.prevent="handleSubmit">
  5. <table class="admin--form--table">
  6. <colgroup>
  7. <col style="width: 120px;">
  8. <col>
  9. </colgroup>
  10. <tbody>
  11. <tr>
  12. <th><div>아이템명 <span class="admin--required">*</span></div></th>
  13. <td>
  14. <div class="input--wrap">
  15. <input
  16. v-model="formData.name"
  17. type="text"
  18. class="admin--form-input"
  19. placeholder="예: 등급 포인트 +100"
  20. required
  21. />
  22. </div>
  23. </td>
  24. </tr>
  25. <tr>
  26. <th><div>구분 <span class="admin--required">*</span></div></th>
  27. <td>
  28. <div class="input--wrap">
  29. <label class="admin--radio-label">
  30. <input type="radio" v-model="formData.type" value="T" /> 진출권
  31. </label>
  32. <label class="admin--radio-label ml--16">
  33. <input type="radio" v-model="formData.type" value="P" /> 포인트
  34. </label>
  35. <label class="admin--radio-label ml--16">
  36. <input type="radio" v-model="formData.type" value="B" /> 뱃지
  37. </label>
  38. </div>
  39. </td>
  40. </tr>
  41. <tr v-if="formData.type !== 'T'">
  42. <th>
  43. <div>
  44. 포인트 <span v-if="formData.type === 'P'" class="admin--required">*</span>
  45. </div>
  46. </th>
  47. <td>
  48. <div class="input--wrap">
  49. <input
  50. v-model.number="formData.point"
  51. type="number"
  52. min="0"
  53. class="admin--form-input w--200"
  54. placeholder="예: 100"
  55. />
  56. </div>
  57. <p class="mt--10" v-if="formData.type === 'P'">아이템 구분이 "포인트"인 경우 필수 입력</p>
  58. <p class="mt--10" v-else>아이템 구분이 "뱃지"인 경우 선택 입력</p>
  59. </td>
  60. </tr>
  61. <tr>
  62. <th><div>이미지</div></th>
  63. <td>
  64. <div class="input--wrap">
  65. <input
  66. ref="imageInput"
  67. type="file"
  68. accept="image/*"
  69. class="admin--form-file-hidden"
  70. @change="onImageChange"
  71. />
  72. <button type="button" class="admin--btn-small admin--btn-blue" @click="triggerImageInput">
  73. 이미지 선택
  74. </button>
  75. <span v-if="image" class="ml--16">{{ image.file.name }}</span>
  76. </div>
  77. <p class="mt--10">JPG/PNG/GIF/WebP, 10MB 이하 (선택 사항)</p>
  78. <div v-if="image" class="onboard--photo-grid mt--10">
  79. <div class="onboard--photo-item">
  80. <img :src="image.preview" alt="미리보기" />
  81. <button type="button" class="onboard--photo-remove" @click="removeImage"></button>
  82. </div>
  83. </div>
  84. </td>
  85. </tr>
  86. <tr>
  87. <th><div>상태</div></th>
  88. <td>
  89. <div class="input--wrap">
  90. <label class="admin--radio-label">
  91. <input type="radio" v-model="formData.status_YN" value="Y" /> 사용중
  92. </label>
  93. <label class="admin--radio-label ml--16">
  94. <input type="radio" v-model="formData.status_YN" value="N" /> 미사용
  95. </label>
  96. </div>
  97. </td>
  98. </tr>
  99. </tbody>
  100. </table>
  101. <div class="admin--info--box bg--white">
  102. <h3>💡 아이템 구분별 지급 조건</h3>
  103. <div class="item--info--box">
  104. <div class="item--info">
  105. <span class="item--badge">진출권</span>
  106. <p>챌린지 진행 시 사용자가 조건의 물고기를 획득하면 다음 라운드에 진출할 수 있는 아이템</p>
  107. <span>ㆍ포인트 필드 미사용</span>
  108. </div>
  109. <div class="item--info">
  110. <span class="item--badge">포인트</span>
  111. <p>챌린지에서 조건의 물고기를 획득하면 지급. 사용자 등급 포인트를 지급해주는 아이템</p>
  112. <span>ㆍ포인트 필드 필수</span>
  113. </div>
  114. <div class="item--info">
  115. <span class="item--badge">뱃지</span>
  116. <p>퀘스트 진행 시 조건 달성한 사용자에게 지급되는 아이템</p>
  117. <span>ㆍ포인트 필드 사용 가능</span>
  118. </div>
  119. </div>
  120. </div>
  121. <!-- 버튼 영역 -->
  122. <div class="admin--form-actions">
  123. <button type="button" class="admin--btn" @click="goToList">
  124. ← 목록으로
  125. </button>
  126. <button type="submit" class="admin--btn admin--btn-red ml--auto" :disabled="isSaving">
  127. {{ isSaving ? "저장 중..." : "저장" }}
  128. </button>
  129. </div>
  130. <!-- 성공/에러 메시지 -->
  131. <div v-if="successMessage" class="admin--alert admin--alert-success">
  132. {{ successMessage }}
  133. </div>
  134. <div v-if="errorMessage" class="admin--alert admin--alert-error">
  135. {{ errorMessage }}
  136. </div>
  137. </form>
  138. </div>
  139. </div>
  140. </template>
  141. <script setup>
  142. import { ref, watch } from "vue";
  143. import { useRouter } from "vue-router";
  144. definePageMeta({
  145. layout: "admin",
  146. middleware: ["auth"],
  147. });
  148. const router = useRouter();
  149. const { upload } = useApi();
  150. const isSaving = ref(false);
  151. const successMessage = ref("");
  152. const errorMessage = ref("");
  153. const formData = ref({
  154. name: "",
  155. type: "T", // T(진출권) / P(포인트) / B(뱃지)
  156. point: null,
  157. status_YN: "Y",
  158. });
  159. // 이미지 1장 — { file, preview }
  160. const imageInput = ref(null);
  161. const image = ref(null);
  162. const MAX_IMAGE_SIZE = 10 * 1024 * 1024;
  163. const triggerImageInput = () => imageInput.value?.click();
  164. const onImageChange = (e) => {
  165. const file = (e.target.files || [])[0];
  166. e.target.value = "";
  167. if (!file) return;
  168. if (!file.type.startsWith("image/")) {
  169. errorMessage.value = "이미지 파일만 업로드할 수 있습니다.";
  170. return;
  171. }
  172. if (file.size > MAX_IMAGE_SIZE) {
  173. errorMessage.value = "이미지가 10MB를 초과합니다.";
  174. return;
  175. }
  176. if (image.value) URL.revokeObjectURL(image.value.preview);
  177. image.value = { file, preview: URL.createObjectURL(file) };
  178. };
  179. const removeImage = () => {
  180. if (image.value) {
  181. URL.revokeObjectURL(image.value.preview);
  182. image.value = null;
  183. }
  184. };
  185. // 진출권으로 바뀌면 포인트 값 초기화 (UI는 v-if로 숨김)
  186. watch(
  187. () => formData.value.type,
  188. (val) => {
  189. if (val === "T") formData.value.point = null;
  190. }
  191. );
  192. // 폼 제출
  193. const handleSubmit = async () => {
  194. successMessage.value = "";
  195. errorMessage.value = "";
  196. const name = formData.value.name.trim();
  197. if (!name) return (errorMessage.value = "아이템명을 입력하세요.");
  198. if (name.length > 50) return (errorMessage.value = "아이템명은 50자 이내로 입력하세요.");
  199. if (!["T", "P", "B"].includes(formData.value.type)) {
  200. return (errorMessage.value = "구분을 선택하세요.");
  201. }
  202. // 포인트 검증
  203. if (formData.value.type === "P") {
  204. if (formData.value.point === null || formData.value.point === "" || Number(formData.value.point) < 0) {
  205. return (errorMessage.value = "포인트를 입력하세요.");
  206. }
  207. } else if (formData.value.type === "B") {
  208. if (formData.value.point !== null && formData.value.point !== "" && Number(formData.value.point) < 0) {
  209. return (errorMessage.value = "포인트는 0 이상의 숫자여야 합니다.");
  210. }
  211. }
  212. isSaving.value = true;
  213. try {
  214. const fd = new FormData();
  215. fd.append("name", name);
  216. fd.append("type", formData.value.type);
  217. if (formData.value.type !== "T" && formData.value.point !== null && formData.value.point !== "") {
  218. fd.append("point", String(formData.value.point));
  219. }
  220. fd.append("status_YN", formData.value.status_YN);
  221. if (image.value) fd.append("image", image.value.file);
  222. const { data, error } = await upload("/item", fd);
  223. if (error || !data?.success) {
  224. errorMessage.value = error?.message || data?.message || "등록에 실패했습니다.";
  225. } else {
  226. successMessage.value = data.message || "아이템이 등록되었습니다.";
  227. setTimeout(() => {
  228. router.push("/site-manager/item/list");
  229. }, 1000);
  230. }
  231. } catch (e) {
  232. errorMessage.value = "서버 오류가 발생했습니다.";
  233. console.error("Save error:", e);
  234. } finally {
  235. isSaving.value = false;
  236. }
  237. };
  238. const goToList = () => router.push("/site-manager/item/list");
  239. </script>