Compare commits

...

164 Commits

Author SHA1 Message Date
6886c372aa Merge pull request 'feat(can): 소수점이 잘못 표기되던 버그 수정' (#79) from test into main
Reviewed-on: #79
2025-10-10 08:58:35 +00:00
Yu Sung
ee36120ed3 feat(can): 소수점이 잘못 표기되던 버그 수정 2025-10-10 17:53:27 +09:00
8dd3dcb770 Merge pull request 'test' (#78) from test into main
Reviewed-on: #78
2025-10-10 08:25:15 +00:00
Yu Sung
b5dbccf515 feat(can): 다국적 통화 표기 지원 2025-10-02 17:50:39 +09:00
Yu Sung
379e5b67f3 feat(can): 캔 리스트 조회 바뀐 API Endpoint(/admin/can)로 수정 2025-10-02 12:10:49 +09:00
Yu Sung
fd9ea2f5bb feat(can): 캔 등록시 결제 화폐단위 추가 2025-10-01 21:32:34 +09:00
Yu Sung
3c28367be9 feat(calculate-ratio): 정산 비율 수정/삭제 추가 2025-09-22 14:52:37 +09:00
Yu Sung
8f0958848d feat(calculate-ratio): 정산 비율 수정/삭제 추가 2025-09-22 14:35:37 +09:00
1a435b6074 Merge pull request 'test' (#77) from test into main
Reviewed-on: #77
2025-09-18 19:42:41 +00:00
Yu Sung
a4cf43b88a feat(character-list): 캐릭터 리스트 페이지 검색 추가 2025-09-18 20:04:20 +09:00
Yu Sung
40c5a6593e feat(original): 원작
- 원천 원작, 원천 원작 링크, 글/그림 작가, 제작사, 태그 추가
2025-09-18 18:45:14 +09:00
492859dae3 Merge pull request 'test' (#76) from test into main
Reviewed-on: #76
2025-09-16 06:16:32 +00:00
Yu Sung
edab727c22 캐릭터 배너 - 이미지 변수 변경하여 이미지가 표시되지 않던 버그 수정 2025-09-15 15:19:54 +09:00
Yu Sung
00b12d0edb feat(original): 캐릭터 등록/수정
- 원작 등록/삭제 추가
2025-09-15 06:53:39 +09:00
Yu Sung
6507b025de feat(original): 원작
- 등록, 수정, 삭제
- 캐릭터 연결, 해제 기능 추가
2025-09-15 04:27:22 +09:00
18b59b5598 Merge pull request 'test' (#75) from test into main
Reviewed-on: #75
2025-09-14 09:04:57 +00:00
Yu Sung
cd86973b60 fix(character): 캐릭터 등록 폼
- 코드 포맷팅 적용
2025-09-13 05:27:12 +09:00
Yu Sung
1e4dcffc17 feat(character-calculator): 캐릭터별 정산 추가 2025-09-13 05:25:35 +09:00
5fcdd7f06d Merge pull request '캐릭터 챗봇' (#74) from test into main
Reviewed-on: #74
2025-09-10 06:26:02 +00:00
Yu Sung
5ee0fe6a60 fix(chat): 인물관계 삭제 후 수정 저장 시 서버 반영되지 않던 문제 수정
수정 모드에서 saveCharacter가 변경 필드만 전송하면서 relationships 배열이 제외되어
삭제/수정 사항이 서버에 반영되지 않는 문제가 있었습니다. 수정 시 항상
relationships를 포함해 서버와 동기화되도록 변경했습니다.

- CharacterForm.vue: update 시 changedData.relationships 항상 포함
2025-09-09 14:54:18 +09:00
Yu Sung
199049ab7c feat(chat): 캐릭터 폼에 JSON 내보내기/가져오기 기능 추가
- 툴바에 'JSON 다운로드/업로드' 버튼 추가
- buildSerializablePayload, exportToJson, onImportFileChange, applyImportedData 메서드 구현
- 이미지(image, imageUrl) 및 isActive는 직렬화/역직렬화에서 제외
- 업로드 시 버전 검증 및 길이/개수 제한, 중요도(1~10) 보정 적용
- 사용자 알림 메시지(성공/오류) 한글화
2025-09-06 00:58:00 +09:00
Yu Sung
bc8833483a fix(character-image): 캐릭터 이미지 등록/수정
- 트리거 단어 최소 개수 3개로 수정
2025-09-02 15:31:24 +09:00
Yu Sung
b94aa54365 캐릭터 챗봇 큐레이션 추가 2025-08-28 19:38:21 +09:00
Yu Sung
478ef2e7fe 말투/특징적 표현 1000자, 대화 스타일 1000자로 변경 2025-08-28 15:20:14 +09:00
Yu Sung
63ebe9708f feat(character-image): 캐릭터 이미지 관리(목록/등록/수정/삭제/정렬) 추가 2025-08-22 02:25:37 +09:00
Yu Sung
071502d869 fix(character-form): 저장 버튼 비활성 문제 수정 및 필수 라벨 * 표시
- 등록(create) 모드에서 필수값 충족 시 버튼 활성화되도록 유효성 처리 정비

- 필수 항목 라벨에 빨간색 * 표시

- 인물관계 입력 필드의 검증 규칙을 v-form 유효성에서 제외

- 인물관계 필드 힌트 문구 개선
2025-08-15 01:37:46 +09:00
Yu Sung
806af4aba0 fix(character-form): 인물 관계 입력 레이아웃 3단 구성 및 입력 방식 수정
- relationshipType, currentStatus를 v-text-field로 변경하고 길이 제한(<=10자) 및 필수 입력 검증 추가
- 인물 관계 입력을 3단 레이아웃으로 재구성 (1행: 상대방 이름+관계명, 2행: 관계 타입+현재 상태+중요도, 3행: 관계 설명)
- addRelationship 로직 보강: 각 필드 substring 보정, 중요도 1~10 범위 보정, 최대 개수(10개) 체크
- 저장 로직 비교 함수에서 relationships를 객체 배열 비교 대상에 포함하여 변경 감지 정확도 개선

왜: 기존 TextField 하나로 관계를 모두 입력해 가독성과 구조화가 어려웠고, 선택형 필드 요구사항이 변경되어 직접 입력하도록 수정 필요
무엇: UI/검증/데이터 처리 전반을 요구사항에 맞게 분리 및 보강
2025-08-13 17:30:42 +09:00
Yu Sung
e09f654aba fix(character-form): 수정 모드에서 변경 사항만 있으면 저장 버튼 활성화
- isSaveDisabled 로직을 등록/수정 모드로 분리
- 수정(edit) 모드에서는 필수값 유효성과 무관하게 변경 감지 시 버튼 활성화
- 등록(create) 모드에서는 기존대로 폼 유효성으로 활성화 판단
- saveCharacter에서도 등록 모드에서만 필수값 유효성 검사를 강제하도록 수정

관련 파일: src/views/Chat/CharacterForm.vue
2025-08-13 00:55:35 +09:00
Yu Sung
30e08c862a fix(side-menu): 배너 등록 진입 시 캐릭터 리스트까지 활성화되던 문제 수정\n\n- /character 하위 메뉴에 exact 매칭 적용(:exact)\n- /character/banner 진입 시 /character가 함께 선택 표시되지 않도록 수정 2025-08-12 23:31:36 +09:00
Yu Sung
231539fd27 feat(캐릭터 배너): 등록 성공시에만 다이얼로그 닫고 배너 목록 새로고침하도록 수정 2025-08-12 23:12:17 +09:00
Yu Sung
8f502f6d4d fix(chat): 캐릭터 추가/수정 폼 저장 버튼 로직 및 유효성 수정
- 수정 모드 이미지 변경 강제 제거, 시스템 프롬프트 필수 규칙 추가, 저장 버튼 라벨 조건부 표기(저장/수정)
- 수정 모드: 변경사항 또는 새 이미지 선택 시에만 저장 활성화, 등록 모드: 유효성만 충족 시 저장 가능
- 왜: 수정 UX 개선 및 필수 입력 요건 충족
2025-08-12 22:19:46 +09:00
Yu Sung
38161af543 feat(chat): 캐릭터 리스트
- 검색창 제거
2025-08-12 21:53:20 +09:00
Yu Sung
ba248f7680 feat(chat): 캐릭터 리스트, 추가/수정 폼, 배너
- response의 데이터 구조에 맞춰서 코드 수정
2025-08-12 21:09:08 +09:00
Yu Sung
a3e82a81f8 feat(chat): 캐릭터 폼에 '한 줄 소개', 캐릭터 유형, 원작 정보 추가 및 API 필드 반영
CharacterForm.vue: 설명을 한 줄 소개(TextField)로 변경하고 MBTI 옆에 캐릭터 유형 Select 추가, 태그 아래 원작명/원작링크 필드 추가. api/character.js: createCharacter 요청에 characterType, originalTitle, originalLink 반영. 수정/등록 로직에 관련 필드 매핑 및 변경 필드 추출 반영. 왜: 신규 요구사항 반영 및 API/데이터 정합성 확보.
2025-08-12 02:46:32 +09:00
Yu Sung
efca5e445d feat(character-banner): 캐릭터 배너 등록 다이얼로그
- 캐릭터 검색 결과가 없으면 '검색결과가 없습니다.'라고 안내
2025-08-08 22:07:15 +09:00
Yu Sung
7ed23047e9 feat(character-banner): 캐릭터 배너 페이지 추가
- 리스트, 등록, 수정, 삭제 추가
- 페이징은 스크롤 로딩으로 구현
2025-08-08 22:03:11 +09:00
Yu Sung
bbacab88c5 feat(character): 캐릭터 로드, 저장, 수정
- success가 true여야만 다음 행동을 하도록 처리
2025-08-07 21:37:09 +09:00
Yu Sung
062bb4f7b2 feat(character): 캐릭터 삭제 API
- 삭제 API 호출 대신 isActive=false로 수정 API 호출하도록 변경
2025-08-07 19:11:03 +09:00
Yu Sung
6bd3a62134 refactor(character): 캐릭터 등록/수정 시 변경된 필드만 전송하도록 최적화 2025-08-07 18:53:07 +09:00
Yu Sung
d1f700842f feat(character): 캐릭터 상세 API
- api url 수정
2025-08-07 18:39:16 +09:00
Yu Sung
a9e832bc26 feat(character): 캐릭터 수정 API
- api url 수정
2025-08-07 18:38:01 +09:00
Yu Sung
80b298440b feat(character): 캐릭터 등록 API
- 모든 내용을 form 에 등록하던 방식을 image와 request(json string)으로 등록하도록 수정
2025-08-07 18:35:58 +09:00
Yu Sung
7f56d0b423 feat(character): 캐릭터 폼에 성격 특성, 세계관, 기억 기능 개선
- 성격 특성(personalities): 제목(trait)과 설명(description) 입력 UI 구현, 최대 10개
- 세계관(배경)(backgrounds): 제목(topic)과 설명(description) 입력 UI 구현, 최대 10개
- 기억(memories): 제목(title), 기억(content), 감정(emotion) 입력 UI 구현, 최대 20개
2025-08-07 18:17:59 +09:00
Yu Sung
72b1627f3f feat(character): 캐릭터 등록/수정 폼 개선
- 생년월일을 나이로 변경 (숫자만 입력 가능)
- 태그 기능 개선 (50자 이내, 20개 제한, 띄어쓰기 불가, 스페이스바로 등록)
- 인물관계 추가 (최대 200자, 최대 10개)
- 취미 목록 추가 (최대 100자, 최대 10개)
- 가치관 목록 추가 (최대 100자, 최대 10개)
- 목표 추가 (최대 200자, 최대 10개)
- 말투/특징적 표현 필드 추가 (최대 500자)
- 대화 스타일 추가 (최대 200자)
- 외모 설명 추가 (최대 1000자)
- 연령제한 제거
- UI 순서 변경 (채팅형태 입력 UI를 아래로 이동)
- 채팅형태 UI 개선 (최근 입력이 위쪽에 표시)
2025-08-07 17:45:48 +09:00
Yu Sung
13c85bb2a8 "feat(api): 캐릭터 리스트 API 수정 및 데이터 처리 로직 개선
- API 경로를 /admin/chat/character/list로 변경
- size 파라미터 기본값을 20으로 설정
- 응답 데이터 구조 변경 (items → content)
- total_page 계산 로직 수정 (전체 개수와 size로 계산)
- 태그 표시 기능 추가"
2025-08-07 17:01:13 +09:00
Yu Sung
3783714c75 feat(ui): 캐릭터 리스트 테이블 UI 개선
- 테이블 헤더 변경 (이름→캐릭터명, 설명→캐릭터 설명 등)
- 이미지 크기 100x100으로 설정
- 캐릭터 설명, 말투, 대화 스타일을 보기 버튼으로 표시
- 다이얼로그를 통해 상세 내용 표시 기능 추가
2025-08-07 16:36:45 +09:00
Yu Sung
49cd5a795b fix: 캐릭터 등록 폼
- 시스템 프롬프트, 태그 캡션 크기 16px로 수정
2025-08-05 16:56:25 +09:00
Yu Sung
94a989ea57 feat: 캐릭터 등록 폼 추가 2025-08-05 15:59:58 +09:00
Yu Sung
439cc21e57 feat: 사이드 메뉴 - 캐릭터 챗봇 메뉴 추가 2025-08-05 15:14:24 +09:00
Yu Sung
dbc46482b1 feat: 캐릭터 리스트 기본 UI 생성 2025-08-05 15:00:59 +09:00
Yu Sung
3aae253180 feat: .kiro/, .junie/ 아래에 들어있는 파일은 git에 포함되지 않도록 코드 추가 2025-08-05 14:46:27 +09:00
Yu Sung
89b2f1f740 fix: isLoading으로 표시된 변수 is_loading으로 변경 2025-08-05 12:15:06 +09:00
1e149f7e41 Merge pull request '쿠폰생성 - 쿠폰타입(포인트, 캔) 선택 추가' (#73) from test into main
Reviewed-on: #73
2025-06-10 11:01:52 +00:00
Yu Sung
09c6605aed 쿠폰생성 - 쿠폰타입(포인트, 캔) 선택 추가 2025-06-09 16:29:56 +09:00
aca3767a24 Merge pull request 'test' (#72) from test into main
Reviewed-on: #72
2025-05-20 07:21:28 +00:00
Yu Sung
0aff527266 포인트 정책 - 정책 등록 요청 후 응답이 오면 바로 is_loading을 false로 변경하여 신규 포인트 정책 리스트를 불러올 수 있도록 수정 2025-05-19 22:49:05 +09:00
Yu Sung
cea0887d90 포인트 정책 - 페이징 처리 2025-05-19 22:02:58 +09:00
Yu Sung
d3f98ec9cb 포인트 정책 - 페이징 처리 2025-05-17 17:51:24 +09:00
Yu Sung
d8e75f299b 포인트 정책 - 수정 버튼을 눌렀다가 취소했을 때 데이터가 초기화 되지 않아 포인트 등록이 불가능한 버그 수정 2025-05-17 17:35:26 +09:00
Yu Sung
256f65e370 feat: 포인트 정책 등록 - 지급유형(매일, 전체), 참여 가능 횟수 추가 2025-05-16 21:28:27 +09:00
d51655f15e Merge pull request '포인트 정책 등록/수정 페이지 추가' (#71) from test into main
Reviewed-on: #71
2025-04-24 03:28:52 +00:00
Yu Sung
7821f766e6 포인트 정책 등록/수정 페이지 추가 2025-04-23 17:35:09 +09:00
47dd32939f Merge pull request '일별 전체 회원 수 - 이메일, 구글, 카카오 회원 수 추가' (#70) from test into main
Reviewed-on: #70
2025-04-10 02:35:33 +00:00
Yu Sung
46f966f324 일별 전체 회원 수 - 이메일, 구글, 카카오 회원 수 추가 2025-04-10 11:26:13 +09:00
2e1891ab08 Merge pull request '회원리스트 - 로그인 타입 필드 추가' (#69) from test into main
Reviewed-on: #69
2025-04-09 10:44:30 +00:00
Yu Sung
35be9832e6 회원리스트 - 로그인 타입 필드 추가 2025-04-09 19:29:04 +09:00
99d70cc8f7 Merge pull request '이벤트 배너 수정 - 링크 지우기 추가' (#68) from test into main
Reviewed-on: #68
2025-04-07 14:48:10 +00:00
Yu Sung
ba14bd1673 이벤트 배너 수정 - 링크 지우기 추가 2025-04-03 15:40:51 +09:00
9f1675e82d Merge pull request '광고통계 - 로그인 수를 가장 오른쪽으로 이동' (#67) from test into main
Reviewed-on: #67
2025-04-02 02:11:35 +00:00
Yu Sung
0a47b5d33f 광고통계 - 로그인 수를 가장 오른쪽으로 이동 2025-04-02 11:07:31 +09:00
c2838be2ed Merge pull request '일별 전체 회원 수 통계' (#66) from test into main
Reviewed-on: #66
2025-03-31 03:54:56 +00:00
Yu Sung
c81b31ddeb 일별 전체 회원 수 통계
- 본인인증 수 추가
2025-03-31 12:48:07 +09:00
b5c2941c0d Merge pull request '앱 실행 수 추가' (#65) from test into main
Reviewed-on: #65
2025-03-28 05:58:43 +00:00
Yu Sung
2eb179a18e 앱 실행 수 추가 2025-03-28 12:27:10 +09:00
d5c01d8d23 Merge pull request '광고통계 - 빠른검색 (날짜 지정) 추가' (#64) from test into main
Reviewed-on: #64
2025-03-17 04:58:02 +00:00
Yu Sung
3ca2a36fa8 광고통계 - 빠른검색 (날짜 지정) 추가 2025-03-17 13:51:54 +09:00
7118b0649a Merge pull request '비밀번호 재설정 기능 추가' (#63) from test into main
Reviewed-on: #63
2025-03-17 03:09:41 +00:00
Yu Sung
f2f022531d 비밀번호 재설정 기능 추가 2025-03-17 11:48:53 +09:00
8f5346581e Merge pull request '일별 전체 회원 수 페이지 추가' (#62) from test into main
Reviewed-on: #62
2025-03-14 16:08:06 +00:00
Yu Sung
308a083f32 일별 전체 회원 수 페이지 추가 2025-03-15 00:25:45 +09:00
e43f2e30be Merge pull request '충전 이벤트, 이벤트 배너 - 기간 설정에 시간 추가' (#61) from test into main
Reviewed-on: #61
2025-03-14 03:34:42 +00:00
Yu Sung
6e1a7dba06 충전 이벤트 - 기간에 시간설정 추가 2025-03-14 02:51:37 +09:00
Yu Sung
1e8f9f41c6 이벤트 배너 - 날짜 설정시 시간 설정까지 할 수 있도록 수정 2025-03-14 02:27:26 +09:00
397fd267e0 Merge pull request 'test' (#60) from test into main
Reviewed-on: #60
2025-03-11 08:07:48 +00:00
Yu Sung
3575a4975b 광고 통계 - 조회 버튼 색상 변경 2025-03-11 16:58:26 +09:00
Yu Sung
421e0b2b5f 광고 통계 - 날짜 검색, 로그인 수 추가 2025-03-11 16:48:58 +09:00
fe4b88350b Merge pull request '광고 통계 페이지' (#59) from test into main
Reviewed-on: #59
2025-03-05 13:53:32 +00:00
Yu Sung
42492a7d55 광고 통계 페이지 2025-03-05 22:39:41 +09:00
537474e162 Merge pull request '마케팅 - 매체 파트너 코드 페이지 추가' (#58) from test into main
Reviewed-on: #58
2025-03-05 09:54:31 +00:00
Yu Sung
cc71a40f1b 마케팅 - 매체 파트너 코드 페이지 추가 2025-03-05 17:39:52 +09:00
b5abdf3cf5 Merge pull request 'test' (#57) from test into main
Reviewed-on: #57
2025-02-18 15:12:52 +00:00
Yu Sung
81bd7a2e3f 태그 큐레이션 상세페이지 추가 2025-02-18 17:02:50 +09:00
Yu Sung
1f8f2ff92e 태그 큐레이션 페이지 추가 2025-02-18 16:40:42 +09:00
Yu Sung
4420b02f29 콘텐츠 큐레이션 - 단편도 큐레이션 등록할 수 있도록 수정 2025-02-17 23:17:26 +09:00
Yu Sung
1f5506dbc4 큐레이션 아이템 - 드래그 앤 드롭으로 순서변경 기능 추가 2025-02-17 23:09:12 +09:00
a2e457b5e8 Merge pull request 'test' (#56) from test into main
Reviewed-on: #56
2025-02-09 13:25:35 +00:00
Yu Sung
bcd0ea090c 무료 추천 시리즈, 새로운 시리즈 - 순서 변경 시 안내메시지('수정되었습니다.') 추가 2025-02-05 02:09:48 +09:00
Yu Sung
efd50729f6 무료 추천 시리즈 수정 - 시리즈를 변경하였지만 반영되지 않던 버그 수정 2025-02-05 02:03:08 +09:00
Yu Sung
3a6426e2e1 새로운 시리즈 페이지 2025-02-05 02:01:56 +09:00
Yu Sung
73664768f9 무료 추천 시리즈 페이지 2025-02-05 01:34:36 +09:00
Yu Sung
697de48d9c 콘텐츠 큐레이션 수정 - 메인 탭, 시리즈 큐레이션 메뉴 제거 2025-02-04 00:41:32 +09:00
Yu Sung
de2f89bff1 큐레이션 상세페이지 추가 2025-02-03 20:52:23 +09:00
Yu Sung
a6bcea3076 콘텐츠 리스트
- 큐레이션 제거
2025-01-27 13:53:11 +09:00
Yu Sung
16a314a8e9 큐레이션 상세
- 리스트에서 제목 혹은 설명을 터치하면 상세페이지로 이동
- 상세페이지: 제목, 19금여부, 내용을 표시
2025-01-25 01:52:36 +09:00
Yu Sung
63fac77342 큐레이션 - 조회, 등록, 수정, UI
- 시리즈 큐레이션 여부 추가
2025-01-24 14:34:50 +09:00
Yu Sung
21058bcb4f 큐레이션 - 조회, 등록, 수정
- 탭 추가
2025-01-24 14:27:27 +09:00
Yu Sung
e1feff39f8 시리즈 메뉴 빈 페이지 추가
1. 무료 추천 시리즈
2. 새로운 시리즈
2025-01-22 21:09:26 +09:00
Yu Sung
befe04c243 시리즈 메뉴
1. 시리즈 디렉토리 생성
2. 시리즈 장르, 리스트 페이지 UI - 콘텐츠 디렉토리에서 시리즈 디렉토리로 이동
3. webpackChunk - content -> series 로 분리
2025-01-22 18:51:30 +09:00
Yu Sung
af45c0093e 콘텐츠 배너 등록/수정 레이어 팝업
- 탭 추가
2025-01-21 21:27:24 +09:00
Yu Sung
22b185c31a 콘텐츠 배너 등록/수정 페이지
- 탭 추가
2025-01-21 18:36:05 +09:00
05ddd417cd Merge pull request '콘텐츠 배너 등록/수정' (#54) from test into main
Reviewed-on: #54
2025-01-17 16:46:45 +00:00
e70426af68 Merge pull request 'test' (#53) from test into main
Reviewed-on: #53
2025-01-17 06:00:59 +00:00
81b33e1322 Merge pull request '오디션 지원 취소기능 적용' (#52) from test into main
Reviewed-on: #52
2025-01-08 06:34:54 +00:00
588fcfbe90 Merge pull request '오디션 지원자 연락처 표시' (#51) from test into main
Reviewed-on: #51
2025-01-07 20:10:23 +00:00
ff2c126382 Merge pull request '오디션 메뉴 추가' (#50) from test into main
Reviewed-on: #50
2025-01-07 17:20:32 +00:00
702daca29f Merge pull request '소다라이브 -> 보이스온' (#49) from test into main
Reviewed-on: #49
2024-11-21 12:59:05 +00:00
8e9008a3c1 Merge pull request '이벤트 기간 추가' (#48) from test into main
Reviewed-on: #48
2024-10-31 03:17:44 +00:00
5c0c00aad4 Merge pull request '크리에이터 리스트 - 프로필 이미지 다운로드 버튼 추가' (#47) from test into main
Reviewed-on: #47
2024-10-24 03:07:00 +00:00
e0949c6d73 Merge pull request 'test' (#46) from test into main
Reviewed-on: #46
2024-10-16 04:18:55 +00:00
0449bac8d5 Merge pull request '전체 개수 추가' (#45) from test into main
Reviewed-on: #45
2024-10-15 04:18:01 +00:00
d412c15c9d Merge pull request '시리즈 리스트 - 작품 개수 추가' (#44) from test into main
Reviewed-on: #44
2024-10-14 15:43:15 +00:00
ed16a6ddad Merge pull request '시리즈 리스트 페이지 추가' (#43) from test into main
Reviewed-on: #43
2024-10-14 10:14:19 +00:00
f06e2d41e0 Merge pull request '전체 크리에이터 수 추가' (#42) from test into main
Reviewed-on: #42
2024-09-26 11:38:53 +00:00
7505269db3 Merge pull request '크리에이터별 정산 - 페이징 추가' (#41) from test into main
Reviewed-on: #41
2024-08-01 05:16:04 +00:00
15eeb6943d Merge pull request '크리에이터 기준 라이브, 콘텐츠, 커뮤니티 합계 정산 페이지 추가' (#40) from test into main
Reviewed-on: #40
2024-07-08 14:41:28 +00:00
7e7ed46cea Merge pull request '크리에이터 기준 라이브, 콘텐츠, 커뮤니티 합계 정산 페이지 추가' (#39) from test into main
Reviewed-on: #39
2024-07-08 14:37:08 +00:00
fd01786649 Merge pull request 'test' (#38) from test into main
Reviewed-on: #38
2024-07-08 14:22:01 +00:00
c48c1c2f09 Merge pull request '크리에이터 정산비율 등록페이지 추가' (#37) from test into main
Reviewed-on: #37
2024-06-11 08:11:57 +00:00
9bcf3a3cdb Merge pull request '커뮤니티 정산' (#36) from test into main
Reviewed-on: #36
2024-06-06 14:59:58 +00:00
4c5b987d98 Merge pull request 'test' (#35) from test into main
Reviewed-on: #35
2024-06-03 22:24:23 +00:00
f168403048 Merge pull request '라이브 리스트 - 현재참여인원 추가' (#34) from test into main
Reviewed-on: #34
2024-05-28 18:16:18 +00:00
82ee1584e7 Merge pull request '커뮤니티 정산 페이지 추가' (#33) from test into main
Reviewed-on: #33
2024-05-28 16:13:16 +00:00
65cb918389 Merge pull request '시그니처 관리 - 재생 시간 등록/수정 기능 추가' (#32) from test into main
Reviewed-on: #32
2024-05-02 07:17:34 +00:00
784baf9a2f Merge pull request '시리즈 장르 - 등록/삭제 페이지 추가' (#31) from test into main
Reviewed-on: #31
2024-04-26 19:06:32 +00:00
7a85ac41cc Merge pull request '관리자 - 캔 충전현황' (#30) from test into main
Reviewed-on: #30
2024-04-01 11:34:15 +00:00
9d4c9437cf Merge pull request '관리자 - 캔 충전현황' (#29) from test into main
Reviewed-on: #29
2024-04-01 11:27:14 +00:00
68845aeae1 Merge pull request '콘텐츠 리스트 한정판 표시' (#28) from test into main
Reviewed-on: #28
2024-03-29 05:00:01 +00:00
bbdca29337 Merge pull request '콘텐츠 리스트' (#27) from test into main
Reviewed-on: #27
2024-03-28 06:43:25 +00:00
c14c041daa Merge pull request 'test' (#26) from test into main
Reviewed-on: #26
2024-03-19 07:50:16 +00:00
a515a144eb Merge pull request 'test' (#25) from test into main
Reviewed-on: #25
2024-03-13 11:42:23 +00:00
54a6773905 Merge pull request 'test' (#24) from test into main
Reviewed-on: #24
2024-03-12 07:54:10 +00:00
d97087b4e9 Merge pull request '시그니처 캔 등록 페이지 추가' (#23) from test into main
Reviewed-on: #23
2024-03-08 13:59:51 +00:00
ddb2449053 Merge pull request '파비콘 변경' (#22) from test into main
Reviewed-on: #22
2024-02-17 14:56:25 +00:00
8aca07cdf7 Merge pull request '콘텐츠 수정' (#21) from test into main
Reviewed-on: #21
2024-02-08 18:25:50 +00:00
0ba845d95a Merge pull request '콘텐츠 리스트 - 오픈예정일 추가' (#20) from test into main
Reviewed-on: #20
2024-01-11 09:27:25 +00:00
64b1fd5395 Merge pull request '수정 기능 추가' (#19) from test into main
Reviewed-on: #19
2024-01-03 15:25:28 +00:00
639bea70fa Merge pull request '쿠폰 관리 페이지 추가' (#18) from test into main
Reviewed-on: #18
2024-01-03 10:33:23 +00:00
6a89ba059b Merge pull request '푸시 발송 대상 지정 UI 추가' (#17) from test into main
Reviewed-on: #17
2023-11-24 07:01:10 +00:00
ff83041585 Merge pull request '연령제한 표시 추가' (#16) from test into main
Reviewed-on: #16
2023-11-21 16:25:21 +00:00
e660be0bf4 Merge pull request '일자별 콘텐츠 후원 페이지 - 유/무료 구분 추가' (#15) from test into main
Reviewed-on: #15
2023-11-14 13:36:49 +00:00
62cdd57069 Merge pull request '일자별 콘텐츠 후원 페이지 - 유/무료 구분 추가' (#14) from test into main
Reviewed-on: #14
2023-11-14 13:34:37 +00:00
f8346ed5ef Merge pull request '일자별 콘텐츠 후원 페이지 - 유/무료 구분 추가' (#13) from test into main
Reviewed-on: #13
2023-11-14 13:19:50 +00:00
9656b9a9d1 Merge pull request '일자별 콘텐츠 후원 페이지 추가' (#12) from test into main
Reviewed-on: #12
2023-11-14 08:57:15 +00:00
97a58266bb Merge pull request 'orderType 추가, 판매수 -> 누적 판매수 로 변경' (#11) from test into main
Reviewed-on: #11
2023-11-13 14:52:38 +00:00
8fc0cfa345 Merge pull request '콘텐츠별 누적 현황 페이지 - 총 콘텐츠 개수 표시' (#10) from test into main
Reviewed-on: #10
2023-11-13 14:08:26 +00:00
22f9c2287d Merge pull request '콘텐츠별 누적 현황 페이지 추가' (#9) from test into main
Reviewed-on: #9
2023-11-13 13:46:19 +00:00
9284f7d5c3 Merge pull request '콘텐츠 정산 - 엑셀 다운로드 추가' (#8) from test into main
Reviewed-on: #8
2023-11-13 08:46:43 +00:00
e6f27a4529 Merge pull request '콘텐츠 정산 - 헤더 순서 변경' (#7) from test into main
Reviewed-on: #7
2023-11-10 13:56:50 +00:00
6a33d1c024 Merge pull request '콘텐츠 정산 페이지 추가' (#6) from test into main
Reviewed-on: #6
2023-11-10 10:51:32 +00:00
3b83789c15 Merge pull request 'test' (#5) from test into main
Reviewed-on: #5
2023-10-06 15:09:02 +00:00
55f0ab9af3 Merge pull request '크리에이터 라이브 정산 - 인원 추가, 코인 -> 캔' (#4) from test into main
Reviewed-on: #4
2023-10-03 12:15:25 +00:00
9b168a6112 Merge pull request '크리에이터 라이브 정산 페이지 추가' (#3) from test into main
Reviewed-on: #3
2023-10-03 09:25:39 +00:00
c47937933e Merge pull request 'test' (#2) from test into main
Reviewed-on: #2
2023-08-25 07:50:32 +00:00
4744fe7d9a Merge pull request '채널공유 - 파이어베이스 링크, 도메인, 프로젝트명 변경' (#1) from test into main
Reviewed-on: #1
2023-08-22 03:39:44 +00:00
46 changed files with 11305 additions and 116 deletions

3
.gitignore vendored
View File

@@ -218,4 +218,7 @@ $RECYCLE.BIN/
# Windows shortcuts
*.lnk
.kiro/
.junie/
# End of https://www.toptal.com/developers/gitignore/api/webstorm,visualstudiocode,vuejs,macos,windows

View File

@@ -7,7 +7,7 @@ async function getAudioContentList(status, page) {
)
}
async function searchAudioContent(searchWord, page){
async function searchAudioContent(searchWord, page) {
return Vue.axios.get(
"/admin/audio-content/search?search_word=" + searchWord +
"&page=" + (page - 1) +
@@ -19,8 +19,8 @@ async function modifyAudioContent(request) {
return Vue.axios.put("/admin/audio-content", request)
}
async function getBannerList() {
return Vue.axios.get("/admin/audio-content/banner")
async function getBannerList(tabId) {
return Vue.axios.get("/admin/audio-content/banner?tabId=" + tabId)
}
async function saveBanner(formData) {
@@ -43,8 +43,8 @@ async function updateBannerOrders(ids) {
return Vue.axios.put('/admin/audio-content/banner/orders', {ids: ids})
}
async function getCurations() {
return Vue.axios.get("/admin/audio-content/curation")
async function getCurations(tabId) {
return Vue.axios.get("/admin/audio-content/curation?tabId=" + tabId)
}
async function saveCuration(request) {
@@ -63,6 +63,88 @@ async function getAudioContentThemeList() {
return Vue.axios.get("/admin/audio-content/theme")
}
async function getAudioContentMainTabList() {
return Vue.axios.get("/admin/audio-content/main/tab")
}
async function getCurationItems(curationId) {
return Vue.axios.get("/admin/audio-content/curation/items?curationId=" + curationId)
}
async function searchContentItem(curationId, searchWord) {
return Vue.axios.get("/admin/audio-content/curation/search/content?curationId=" + curationId + "&searchWord=" + searchWord)
}
async function searchSeriesItem(curationId, searchWord) {
return Vue.axios.get("/admin/audio-content/curation/search/series?curationId=" + curationId + "&searchWord=" + searchWord)
}
async function addItemToCuration(curationId, itemIdList) {
return Vue.axios.post(
"/admin/audio-content/curation/add/item",
{curationId: curationId, itemIdList: itemIdList}
)
}
async function removeItemInCuration(curationId, itemId) {
return Vue.axios.put(
"/admin/audio-content/curation/remove/item",
{curationId: curationId, itemId: itemId}
)
}
async function updateItemInCurationOrders(curationId, itemIds) {
return Vue.axios.put(
"/admin/audio-content/curation/orders/item",
{curationId: curationId, itemIds: itemIds}
)
}
async function getHashTagCurations() {
return Vue.axios.get("/admin/audio-content/tag/curation")
}
async function saveHashTagCuration(request) {
return Vue.axios.post("/admin/audio-content/tag/curation", request)
}
async function modifyHashTagCuration(request) {
return Vue.axios.put("/admin/audio-content/tag/curation", request)
}
async function updateHashTagCurationOrders(ids) {
return Vue.axios.put('/admin/audio-content/tag/curation/orders', {ids: ids})
}
async function getHashTagCurationItems(curationId) {
return Vue.axios.get('/admin/audio-content/tag/curation/items?curationId=' + curationId)
}
async function addItemToHashTagCuration(curationId, itemIdList) {
return Vue.axios.post(
"/admin/audio-content/tag/curation/add/item",
{curationId: curationId, itemIdList: itemIdList}
)
}
async function removeItemInHashTagCuration(curationId, itemId) {
return Vue.axios.put(
"/admin/audio-content/tag/curation/remove/item",
{curationId: curationId, itemId: itemId}
)
}
async function searchHashTagContentItem(curationId, searchWord) {
return Vue.axios.get("/admin/audio-content/tag/curation/search/content?curationId=" + curationId + "&searchWord=" + searchWord)
}
async function updateItemInHashTagCurationOrders(curationId, itemIds) {
return Vue.axios.put(
"/admin/audio-content/tag/curation/orders/item",
{curationId: curationId, itemIds: itemIds}
)
}
export {
getAudioContentList,
searchAudioContent,
@@ -75,5 +157,21 @@ export {
saveCuration,
modifyCuration,
updateCurationOrders,
getAudioContentThemeList
getAudioContentThemeList,
getAudioContentMainTabList,
getCurationItems,
searchSeriesItem,
searchContentItem,
addItemToCuration,
removeItemInCuration,
updateItemInCurationOrders,
getHashTagCurations,
saveHashTagCuration,
modifyHashTagCuration,
updateHashTagCurationOrders,
getHashTagCurationItems,
addItemToHashTagCuration,
removeItemInHashTagCuration,
searchHashTagContentItem,
updateItemInHashTagCurationOrders
}

View File

@@ -0,0 +1,32 @@
import Vue from 'vue';
async function getRecommendSeriesList(isFree) {
return Vue.axios.get("/admin/audio-content/series/recommend?isFree=" + isFree);
}
async function saveRecommendSeries(formData) {
return Vue.axios.post('/admin/audio-content/series/recommend', formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
async function modifyRecommendSeries(formData) {
return Vue.axios.put('/admin/audio-content/series/recommend', formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
async function updateRecommendSeriesOrders(ids) {
return Vue.axios.put('/admin/audio-content/series/recommend/orders', {ids: ids})
}
export {
getRecommendSeriesList,
saveRecommendSeries,
modifyRecommendSeries,
updateRecommendSeriesOrders
}

View File

@@ -24,7 +24,7 @@ async function getCalculateCommunityPost(startDate, endDate, page, size) {
}
async function getSettlementRatio(page) {
return Vue.axios.get('/admin/calculate/ratio?page=' + (page - 1) + "&size=20'");
return Vue.axios.get('/admin/calculate/ratio?page=' + (page - 1) + "&size=20");
}
async function createCreatorSettlementRatio(creatorSettlementRatio) {
@@ -57,6 +57,21 @@ async function getCalculateCommunityByCreator(startDate, endDate, page, size) {
)
}
async function updateCreatorSettlementRatio(creatorSettlementRatio) {
const request = {
memberId: creatorSettlementRatio.creator_id,
subsidy: creatorSettlementRatio.subsidy,
liveSettlementRatio: creatorSettlementRatio.liveSettlementRatio,
contentSettlementRatio: creatorSettlementRatio.contentSettlementRatio,
communitySettlementRatio: creatorSettlementRatio.communitySettlementRatio
};
return Vue.axios.post('/admin/calculate/ratio/update', request);
}
async function deleteCreatorSettlementRatio(memberId) {
return Vue.axios.post('/admin/calculate/ratio/delete/' + memberId);
}
export {
getCalculateLive,
getCalculateContent,
@@ -65,6 +80,8 @@ export {
getCalculateCommunityPost,
getSettlementRatio,
createCreatorSettlementRatio,
updateCreatorSettlementRatio,
deleteCreatorSettlementRatio,
getCalculateLiveByCreator,
getCalculateContentByCreator,
getCalculateCommunityByCreator

View File

@@ -5,11 +5,11 @@ async function deleteCan(id) {
}
async function getCans() {
return Vue.axios.get('/can');
return Vue.axios.get('/admin/can');
}
async function insertCan(can, rewardCan, price) {
const request = {can: can, rewardCan: rewardCan, price: price}
async function insertCan(can, rewardCan, price, currency) {
const request = {can: can, rewardCan: rewardCan, price: price, currency}
return Vue.axios.post('/admin/can', request);
}
@@ -22,8 +22,8 @@ async function getCouponList(page) {
return Vue.axios.get('/can/coupon?page=' + (page - 1) + "&size=20");
}
async function generateCoupon(couponName, can, validity, isMultipleUse, couponNumberCount) {
const request = {couponName, can, validity: validity + ' 23:59:59', isMultipleUse, couponNumberCount};
async function generateCoupon(couponName, couponType, can, validity, isMultipleUse, couponNumberCount) {
const request = {couponName, couponType, can, validity: validity + ' 23:59:59', isMultipleUse, couponNumberCount};
return Vue.axios.post('/can/coupon', request);
}

293
src/api/character.js Normal file
View File

@@ -0,0 +1,293 @@
import Vue from 'vue';
// 캐릭터 리스트
async function getCharacterList(page = 1, size = 20) {
return Vue.axios.get('/admin/chat/character/list', {
params: { page: page - 1, size }
})
}
// 캐릭터 검색 (배너용 기존 함수)
async function searchCharacters(searchTerm, page = 1, size = 20) {
return Vue.axios.get('/admin/chat/banner/search-character', {
params: { searchTerm, page: page - 1, size }
})
}
// 캐릭터 리스트 검색 (요구사항: /admin/chat/character/search)
async function searchCharacterList(searchTerm, page = 1, size = 20) {
return Vue.axios.get('/admin/chat/character/search', {
params: { searchTerm, page: page - 1, size }
})
}
// 캐릭터 상세 조회
async function getCharacter(id) {
return Vue.axios.get(`/admin/chat/character/${id}`)
}
// 내부 헬퍼: 빈 문자열을 null로 변환
function toNullIfBlank(value) {
if (typeof value === 'string') {
return value.trim() === '' ? null : value;
}
return value === '' ? null : value;
}
// 캐릭터 등록
async function createCharacter(characterData) {
const formData = new FormData()
// 이미지만 FormData에 추가
if (characterData.image) formData.append('image', characterData.image)
// 나머지 데이터는 JSON 문자열로 변환하여 request 필드에 추가
const requestData = {
name: toNullIfBlank(characterData.name),
systemPrompt: toNullIfBlank(characterData.systemPrompt),
description: toNullIfBlank(characterData.description),
age: toNullIfBlank(characterData.age),
gender: toNullIfBlank(characterData.gender),
mbti: toNullIfBlank(characterData.mbti),
characterType: toNullIfBlank(characterData.type),
originalWorkId: characterData.originalWorkId || null,
speechPattern: toNullIfBlank(characterData.speechPattern),
speechStyle: toNullIfBlank(characterData.speechStyle),
appearance: toNullIfBlank(characterData.appearance),
tags: characterData.tags || [],
hobbies: characterData.hobbies || [],
values: characterData.values || [],
goals: characterData.goals || [],
relationships: characterData.relationships || [],
personalities: characterData.personalities || [],
backgrounds: characterData.backgrounds || [],
memories: characterData.memories || []
}
formData.append('request', JSON.stringify(requestData))
return Vue.axios.post('/admin/chat/character/register', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 캐릭터 수정
async function updateCharacter(characterData, image = null) {
const formData = new FormData()
// 이미지가 있는 경우에만 FormData에 추가
if (image) formData.append('image', image)
// 변경된 데이터만 JSON 문자열로 변환하여 request 필드에 추가 ('' -> null 변환)
// characterData는 이미 변경된 필드만 포함하고 있음
const processed = {}
Object.keys(characterData).forEach(key => {
const value = characterData[key]
if (typeof value === 'string' || value === '') {
processed[key] = toNullIfBlank(value)
} else {
processed[key] = value
}
})
formData.append('request', JSON.stringify(processed))
return Vue.axios.put(`/admin/chat/character/update`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 캐릭터 배너 리스트 조회
async function getCharacterBannerList(page = 1, size = 20) {
return Vue.axios.get('/admin/chat/banner/list', {
params: { page: page - 1, size }
})
}
// 캐릭터 배너 등록
async function createCharacterBanner(bannerData) {
const formData = new FormData()
// 이미지 FormData에 추가
if (bannerData.image) formData.append('image', bannerData.image)
// 캐릭터 ID를 JSON 문자열로 변환하여 request 필드에 추가
const requestData = {
characterId: bannerData.characterId
}
formData.append('request', JSON.stringify(requestData))
return Vue.axios.post('/admin/chat/banner/register', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 캐릭터 배너 수정
async function updateCharacterBanner(bannerData) {
const formData = new FormData()
// 이미지가 있는 경우에만 FormData에 추가
if (bannerData.image) formData.append('image', bannerData.image)
// 캐릭터 ID와 배너 ID를 JSON 문자열로 변환하여 request 필드에 추가
const requestData = {
characterId: bannerData.characterId,
bannerId: bannerData.bannerId
}
formData.append('request', JSON.stringify(requestData))
return Vue.axios.put('/admin/chat/banner/update', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 캐릭터 배너 삭제
async function deleteCharacterBanner(bannerId) {
return Vue.axios.delete(`/admin/chat/banner/${bannerId}`)
}
// 캐릭터 배너 순서 변경
async function updateCharacterBannerOrder(bannerIds) {
return Vue.axios.put('/admin/chat/banner/orders', {ids: bannerIds})
}
// 캐릭터 이미지 리스트
async function getCharacterImageList(characterId, page = 1, size = 20) {
return Vue.axios.get('/admin/chat/character/image/list', {
params: { characterId, page: page - 1, size }
})
}
// 캐릭터 이미지 상세
async function getCharacterImage(imageId) {
return Vue.axios.get(`/admin/chat/character/image/${imageId}`)
}
// 캐릭터 이미지 등록
async function createCharacterImage(imageData) {
const formData = new FormData()
if (imageData.image) formData.append('image', imageData.image)
const requestData = {
characterId: imageData.characterId,
imagePriceCan: imageData.imagePriceCan,
messagePriceCan: imageData.messagePriceCan,
isAdult: imageData.isAdult,
triggers: imageData.triggers || []
}
formData.append('request', JSON.stringify(requestData))
return Vue.axios.post('/admin/chat/character/image/register', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// 캐릭터 이미지 수정 (트리거만 수정)
async function updateCharacterImage(imageData) {
const imageId = imageData.imageId
const payload = { triggers: imageData.triggers || [] }
return Vue.axios.put(`/admin/chat/character/image/${imageId}/triggers`, payload)
}
// 캐릭터 이미지 삭제
async function deleteCharacterImage(imageId) {
return Vue.axios.delete(`/admin/chat/character/image/${imageId}`)
}
// 캐릭터 이미지 순서 변경
async function updateCharacterImageOrder(characterId, imageIds) {
return Vue.axios.put('/admin/chat/character/image/orders', { characterId, ids: imageIds })
}
// 캐릭터 큐레이션 목록
async function getCharacterCurationList() {
return Vue.axios.get('/admin/chat/character/curation/list')
}
// 캐릭터 큐레이션 등록
async function createCharacterCuration({ title, isAdult, isActive }) {
return Vue.axios.post('/admin/chat/character/curation/register', { title, isAdult, isActive })
}
// 캐릭터 큐레이션 수정
// payload: { id: Long, title?, isAdult?, isActive? }
async function updateCharacterCuration(payload) {
return Vue.axios.put('/admin/chat/character/curation/update', payload)
}
// 캐릭터 큐레이션 삭제
async function deleteCharacterCuration(curationId) {
return Vue.axios.delete(`/admin/chat/character/curation/${curationId}`)
}
// 캐릭터 큐레이션 정렬 순서 변경
async function updateCharacterCurationOrder(ids) {
return Vue.axios.put('/admin/chat/character/curation/reorder', { ids })
}
// 큐레이션에 캐릭터 등록 (다중 등록)
// characterIds: Array<Long>
async function addCharacterToCuration(curationId, characterIds) {
return Vue.axios.post(`/admin/chat/character/curation/${curationId}/characters`, { characterIds })
}
// 큐레이션에서 캐릭터 삭제
async function removeCharacterFromCuration(curationId, characterId) {
return Vue.axios.delete(`/admin/chat/character/curation/${curationId}/characters/${characterId}`)
}
// 큐레이션 내 캐릭터 정렬 순서 변경
async function updateCurationCharactersOrder(curationId, characterIds) {
return Vue.axios.put(`/admin/chat/character/curation/${curationId}/characters/reorder`, { characterIds })
}
// 큐레이션 캐릭터 목록 조회 (가정된 엔드포인트)
async function getCharactersInCuration(curationId) {
return Vue.axios.get(`/admin/chat/character/curation/${curationId}/characters`)
}
// 캐릭터별 정산 목록
// params: { startDateStr, endDateStr, sort, page, size }
async function getCharacterCalculateList({ startDateStr, endDateStr, sort = 'TOTAL_SALES_DESC', page = 0, size = 30 }) {
return Vue.axios.get('/admin/chat/calculate/characters', {
params: { startDateStr, endDateStr, sort, page, size }
})
}
export {
getCharacterList,
searchCharacters,
searchCharacterList,
getCharacter,
createCharacter,
updateCharacter,
getCharacterBannerList,
createCharacterBanner,
updateCharacterBanner,
deleteCharacterBanner,
updateCharacterBannerOrder,
getCharacterImageList,
getCharacterImage,
createCharacterImage,
updateCharacterImage,
deleteCharacterImage,
updateCharacterImageOrder,
// Character Curation
getCharacterCurationList,
createCharacterCuration,
updateCharacterCuration,
deleteCharacterCuration,
updateCharacterCurationOrder,
addCharacterToCuration,
removeCharacterFromCuration,
updateCurationCharactersOrder,
getCharactersInCuration,
getCharacterCalculateList
}

24
src/api/marketing.js Normal file
View File

@@ -0,0 +1,24 @@
import Vue from 'vue';
async function createMediaPartner(request) {
return Vue.axios.post("/admin/marketing/media-partner", request)
}
async function updateMediaPartner(request) {
return Vue.axios.put("/admin/marketing/media-partner", request)
}
async function getMediaPartnerList(page) {
return Vue.axios.get("/admin/marketing/media-partner?page=" + (page - 1) + "&size=20")
}
async function getStatistics(startDate, endDate, page) {
return Vue.axios.get("/admin/marketing/statistics?startDateStr=" + startDate + "&endDateStr=" + endDate + "&page=" + (page - 1) + "&size=20")
}
export {
createMediaPartner,
updateMediaPartner,
getMediaPartnerList,
getStatistics
}

View File

@@ -47,6 +47,11 @@ async function getCreatorAllList() {
return Vue.axios.get("/admin/member/creator/all/list")
}
async function resetPassword(id) {
const request = {memberId: id}
return Vue.axios.post("/admin/member/password/reset", request)
}
export {
login,
getMemberList,
@@ -54,5 +59,6 @@ export {
getCreatorList,
searchCreator,
updateMember,
getCreatorAllList
getCreatorAllList,
resetPassword
}

View File

@@ -0,0 +1,10 @@
import Vue from 'vue';
async function getStatistics(startDate, endDate, page) {
return Vue.axios.get(
"/admin/member/statistics?startDateStr=" + startDate +
"&endDateStr=" + endDate + "&page=" + (page - 1) + "&size=30"
)
}
export { getStatistics }

87
src/api/original.js Normal file
View File

@@ -0,0 +1,87 @@
import Vue from 'vue';
// 공통: 값 그대로 전달 (빈 문자열 유지)
function toNullIfBlank(value) {
if (typeof value === 'string') {
return value.trim() === '' ? null : value;
}
return value === '' ? null : value;
}
// 원작 리스트
export async function getOriginalList(page = 1, size = 20) {
return Vue.axios.get('/admin/chat/original/list', {
params: { page: page - 1, size }
})
}
// 원작 등록
export async function createOriginal(data) {
const formData = new FormData();
if (data.image) formData.append('image', data.image);
const request = {
title: toNullIfBlank(data.title),
contentType: toNullIfBlank(data.contentType),
category: toNullIfBlank(data.category),
isAdult: !!data.isAdult,
description: toNullIfBlank(data.description),
originalLink: toNullIfBlank(data.originalLink), // 원천 원작 링크
originalWork: toNullIfBlank(data.originalWork),
writer: toNullIfBlank(data.writer),
studio: toNullIfBlank(data.studio),
originalLinks: Array.isArray(data.originalLinks) ? data.originalLinks : [],
tags: Array.isArray(data.tags) ? data.tags : []
};
formData.append('request', JSON.stringify(request));
return Vue.axios.post('/admin/chat/original/register', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// 원작 수정
export async function updateOriginal(data, image = null) {
const formData = new FormData();
if (image) formData.append('image', image);
const processed = {};
Object.keys(data).forEach(key => {
processed[key] = data[key];
})
formData.append('request', JSON.stringify(processed));
return Vue.axios.put('/admin/chat/original/update', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// 원작 삭제
export async function deleteOriginal(id) {
return Vue.axios.delete(`/admin/chat/original/${id}`)
}
// 원작 상세
export async function getOriginal(id) {
return Vue.axios.get(`/admin/chat/original/${id}`)
}
// 원작 속 캐릭터 조회
export async function getOriginalCharacters(id, page = 1, size = 20) {
return Vue.axios.get(`/admin/chat/original/${id}/characters`, {
params: { page: page - 1, size }
})
}
// 원작 검색
export async function searchOriginals(searchTerm) {
return Vue.axios.get('/admin/chat/original/search', {
params: { searchTerm }
})
}
// 원작에 캐릭터 연결
export async function assignCharactersToOriginal(id, characterIds = []) {
return Vue.axios.post(`/admin/chat/original/${id}/assign-characters`, { characterIds })
}
// 원작에서 캐릭터 연결 해제
export async function unassignCharactersFromOriginal(id, characterIds = []) {
return Vue.axios.post(`/admin/chat/original/${id}/unassign-characters`, { characterIds })
}

19
src/api/point_policy.js Normal file
View File

@@ -0,0 +1,19 @@
import Vue from 'vue';
async function getPointPolicyList(page) {
return Vue.axios.get("/admin/point-policies?page=" + (page - 1) + "&page_size=20")
}
async function createPointPolicyList(request) {
return Vue.axios.post("/admin/point-policies", request)
}
async function updatePointPolicyList(id, request) {
return Vue.axios.put("/admin/point-policies/" + id, request)
}
export {
getPointPolicyList,
createPointPolicyList,
updatePointPolicyList
}

View File

@@ -43,6 +43,7 @@
>
<v-list-item
:to="childItem.route"
:exact="childItem.route === '/character'"
active-class="blue white--text"
>
<v-list-item-title>{{ childItem.title }}</v-list-item-title>
@@ -95,6 +96,39 @@ export default {
let res = await api.getMenus();
if (res.status === 200 && res.data.success === true && res.data.data.length > 0) {
this.items = res.data.data
// 캐릭터 챗봇 메뉴 추가
this.items.push({
title: '캐릭터 챗봇',
route: null,
items: [
{
title: '배너 등록',
route: '/character/banner',
items: null
},
{
title: '캐릭터 리스트',
route: '/character',
items: null
},
{
title: '큐레이션',
route: '/character/curation',
items: null
},
{
title: '정산',
route: '/character/calculate',
items: null
},
{
title: '원작',
route: '/original-work',
items: null
},
]
})
} else {
this.notifyError("알 수 없는 오류가 발생했습니다. 다시 로그인 해주세요!")
this.logout();

View File

@@ -30,6 +30,11 @@ const routes = [
name: 'MemberList',
component: () => import(/* webpackChunkName: "member" */ '../views/Member/MemberList')
},
{
path: '/member/statistics',
name: 'MemberStatistics',
component: () => import(/* webpackChunkName: "member" */ '../views/Member/MemberStatisticsView.vue')
},
{
path: '/creator/tags',
name: 'CreatorTags',
@@ -80,15 +85,40 @@ const routes = [
name: 'ContentCuration',
component: () => import(/* webpackChunkName: "content" */ '../views/Content/ContentCuration.vue')
},
{
path: '/content/curation/detail',
name: 'ContentCurationDetail',
component: () => import(/* webpackChunkName: "content" */ '../views/Content/ContentCurationDetail.vue')
},
{
path: '/content/tag/curation',
name: 'ContentHashTagCuration',
component: () => import(/* webpackChunkName: "content" */ '../views/Content/ContentHashTagCuration.vue')
},
{
path: '/content/tag/curation/detail',
name: 'ContentHashTagCurationDetail',
component: () => import(/* webpackChunkName: "content" */ '../views/Content/ContentHashTagCurationDetail.vue')
},
{
path: '/content/series/list',
name: 'ContentSeriesList',
component: () => import(/* webpackChunkName: "content" */ '../views/Content/ContentSeriesList.vue')
component: () => import(/* webpackChunkName: "series" */ '../views/Series/ContentSeriesList.vue')
},
{
path: '/content/series/genre',
name: 'ContentSeriesGenre',
component: () => import(/* webpackChunkName: "content" */ '../views/Content/ContentSeriesGenre.vue')
component: () => import(/* webpackChunkName: "series" */ '../views/Series/ContentSeriesGenre.vue')
},
{
path: '/content/series/new',
name: 'ContentSeriesNew',
component: () => import(/* webpackChunkName: "series" */ '../views/Series/ContentSeriesNew.vue')
},
{
path: '/content/series/recommend-free',
name: 'ContentSeriesRecommendFree',
component: () => import(/* webpackChunkName: "series" */ '../views/Series/ContentSeriesRecommendFree.vue')
},
{
path: '/promotion/event',
@@ -105,6 +135,11 @@ const routes = [
name: 'ChargeEvent',
component: () => import(/* webpackChunkName: "promotion" */ '../views/Promotion/ChargeEvent.vue')
},
{
path: '/promotion/point-policy',
name: 'PointPolicyView',
component: () => import(/* webpackChunkName: "promotion" */ '../views/Promotion/PointPolicyView.vue')
},
{
path: '/can/management',
name: 'CoinView',
@@ -210,6 +245,71 @@ const routes = [
name: 'AuditionRoleDetailView',
component: () => import(/* webpackChunkName: "audition" */ '../views/Audition/AuditionRoleDetailView.vue')
},
{
path: '/marketing/media-partner-code',
name: 'MarketingMediaPartnerCodeView',
component: () => import(/* webpackChunkName: "marketing" */ '../views/Marketing/MarketingMediaPartnerCodeView.vue')
},
{
path: '/marketing/ad-statistics',
name: 'MarketingAdStatisticsView',
component: () => import(/* webpackChunkName: "marketing" */ '../views/Marketing/MarketingAdStatisticsView.vue')
},
{
path: '/character',
name: 'CharacterList',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterList.vue')
},
{
path: '/character/form',
name: 'CharacterForm',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterForm.vue')
},
{
path: '/character/banner',
name: 'CharacterBanner',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterBanner.vue')
},
{
path: '/character/images',
name: 'CharacterImageList',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterImageList.vue')
},
{
path: '/character/images/form',
name: 'CharacterImageForm',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterImageForm.vue')
},
{
path: '/character/curation',
name: 'CharacterCuration',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCuration.vue')
},
{
path: '/character/curation/detail',
name: 'CharacterCurationDetail',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCurationDetail.vue')
},
{
path: '/character/calculate',
name: 'CharacterCalculate',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCalculateList.vue')
},
{
path: '/original-work',
name: 'OriginalList',
component: () => import(/* webpackChunkName: "original" */ '../views/Chat/OriginalList.vue')
},
{
path: '/original-work/form',
name: 'OriginalForm',
component: () => import(/* webpackChunkName: "original" */ '../views/Chat/OriginalForm.vue')
},
{
path: '/original-work/detail',
name: 'OriginalDetail',
component: () => import(/* webpackChunkName: "original" */ '../views/Chat/OriginalDetail.vue')
},
]
},
{

View File

@@ -60,7 +60,7 @@
<v-card-text>
지급할 : {{ can }}
</v-card-text>
<v-card-actions v-show="!isLoading">
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
@@ -95,7 +95,7 @@ export default {
data() {
return {
show_confirm: false,
isLoading: false,
is_loading: false,
account_id: '',
method: '',
can: ''
@@ -124,7 +124,7 @@ export default {
return this.notifyError('캔은 숫자만 넣을 수 있습니다.')
}
if (!this.isLoading) {
if (!this.is_loading) {
this.show_confirm = true
}
},
@@ -134,8 +134,8 @@ export default {
},
async submit() {
if (!this.isLoading) {
this.isLoading = true
if (!this.is_loading) {
this.is_loading = true
try {
this.show_confirm = false

View File

@@ -14,7 +14,7 @@
<v-col>
<v-btn
block
color="#9970ff"
color="#3bb9f1"
dark
depressed
@click="showWriteDialog"
@@ -42,6 +42,10 @@
<span @click="getCouponNumberList(item)">{{ item.couponName }}</span>
</template>
<template v-slot:item.couponType="{ item }">
{{ item.couponType }}
</template>
<template v-slot:item.can="{ item }">
{{ item.can.toLocaleString('en-US') }}
</template>
@@ -132,6 +136,38 @@
label="발행수량"
/>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
쿠폰종류
</v-col>
<v-col
cols="8"
class="datepicker-wrapper"
>
<v-row>
<v-col>
<input
id="can_coupon"
v-model="coupon_type"
type="radio"
value="CAN"
>
<label for="can_coupon"> 쿠폰</label>
</v-col>
<v-col>
<input
id="point_coupon"
v-model="coupon_type"
type="radio"
value="POINT"
>
<label for="point_coupon"> 포인트 쿠폰</label>
</v-col>
</v-row>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
@@ -373,6 +409,7 @@ export default {
is_active: null,
is_multiple_use: false,
coupon_number_count: null,
coupon_type: 'CAN',
page: 1,
total_page: 0,
@@ -414,6 +451,12 @@ export default {
sortable: false,
value: 'couponName',
},
{
text: '쿠폰종류',
align: 'center',
sortable: false,
value: 'couponType',
},
{
text: '쿠폰금액',
align: 'center',
@@ -542,6 +585,7 @@ export default {
this.is_active = null
this.is_multiple_use = false
this.coupon_number_count = null
this.coupon_type = 'CAN'
},
showModifyDialog(value) {
@@ -628,6 +672,7 @@ export default {
try {
const res = await api.generateCoupon(
this.coupon_name,
this.coupon_type,
this.can,
this.validity,
this.is_multiple_use,

View File

@@ -21,7 +21,7 @@
<v-col>
<v-btn
block
color="#9970ff"
color="#3bb9f1"
dark
depressed
v-bind="attrs"
@@ -39,16 +39,16 @@
class="elevation-1"
hide-default-footer
>
<template v-slot:item.price="{ item }">
{{ item.price.toLocaleString('en-US') }}
<template v-slot:item.priceStr="{ item }">
{{ formatMoney(item.priceStr, item.currency) }}
</template>
<template v-slot:item.can="{ item }">
{{ item.can.toLocaleString('en-US') }}
{{ formatNumber(item.can) }}
</template>
<template v-slot:item.rewardCan="{ item }">
{{ item.rewardCan.toLocaleString('en-US') }}
{{ formatNumber(item.rewardCan) }}
</template>
<template v-slot:item.management="{ item }">
@@ -70,7 +70,13 @@
<v-card-text>
<v-text-field
v-model="price"
label="원화"
label="가격"
required
/>
<v-select
v-model="currency"
:items="currencies"
label="화폐 단위"
required
/>
</v-card-text>
@@ -125,12 +131,17 @@ export default {
price: null,
can: null,
reward_can: null,
currency: 'KRW',
currencies: [
{ text: 'KRW (한국 원)', value: 'KRW' },
{ text: 'USD (미국 달러)', value: 'USD' }
],
headers: [
{
text: '원화(VAT포함)',
text: '가격(VAT포함)',
align: 'center',
sortable: false,
value: 'price',
value: 'priceStr',
},
{
text: '충전캔',
@@ -173,9 +184,26 @@ export default {
this.can = null
this.price = null
this.reward_can = null
this.currency = 'KRW'
this.selected_can = null
},
formatMoney(priceStr, currencyCode, locale = navigator.language) {
const price = Number(priceStr);
const formatted = new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode
}).format(price);
return formatted.replace(/([^\d\s])(\d)/, '$1 $2');
},
formatNumber(num) {
return new Intl.NumberFormat(navigator.language, {
style: 'decimal'
}).format(num);
},
async getCans() {
this.isLoading = true
try {
@@ -204,13 +232,14 @@ export default {
async submit() {
this.isLoading = true
const res = await api.insertCan(this.can, this.reward_can, this.price)
const res = await api.insertCan(this.can, this.reward_can, this.price, this.currency)
if (res.status === 200 && res.data.success === true) {
this.show_dialog = false
this.can = null
this.price = null
this.reward_can = null
this.currency = 'KRW'
this.selected_can = null
this.notifySuccess(res.data.message || '등록되었습니다.')

View File

@@ -36,7 +36,7 @@
<v-col cols="2">
<v-btn
block
color="#9970ff"
color="#3bb9f1"
dark
depressed
@click="getChargeStatus"

View File

@@ -0,0 +1,583 @@
<template>
<div>
<v-toolbar dark>
<v-btn
icon
@click="goBack"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-spacer />
<v-toolbar-title>캐릭터 배너 관리</v-toolbar-title>
<v-spacer />
</v-toolbar>
<v-container>
<v-row>
<v-col cols="4">
<v-btn
color="primary"
dark
@click="showAddDialog"
>
배너 추가
</v-btn>
</v-col>
<v-spacer />
</v-row>
<!-- 로딩 표시 -->
<v-row v-if="isLoading && banners.length === 0">
<v-col class="text-center">
<v-progress-circular
indeterminate
color="primary"
size="64"
/>
</v-col>
</v-row>
<!-- 배너 그리드 -->
<v-row>
<draggable
v-model="banners"
class="row"
style="width: 100%"
:options="{ animation: 150 }"
@end="onDragEnd"
>
<v-col
v-for="banner in banners"
:key="banner.id"
cols="12"
sm="6"
md="4"
lg="3"
class="banner-item"
>
<v-card
class="mx-auto"
max-width="300"
>
<v-img
:src="banner.imagePath"
height="200"
contain
/>
<v-card-text class="text-center">
<div>{{ banner.characterName }}</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
small
color="primary"
@click="showEditDialog(banner)"
>
수정
</v-btn>
<v-btn
small
color="error"
@click="confirmDelete(banner)"
>
삭제
</v-btn>
<v-spacer />
</v-card-actions>
</v-card>
</v-col>
</draggable>
</v-row>
<!-- 데이터가 없을 표시 -->
<v-row v-if="!isLoading && banners.length === 0">
<v-col class="text-center">
<p>등록된 배너가 없습니다.</p>
</v-col>
</v-row>
<!-- 무한 스크롤 로딩 -->
<v-row v-if="isLoading && banners.length > 0">
<v-col class="text-center">
<v-progress-circular
indeterminate
color="primary"
/>
</v-col>
</v-row>
</v-container>
<!-- 배너 추가/수정 다이얼로그 -->
<v-dialog
v-model="showDialog"
max-width="600px"
persistent
>
<v-card>
<v-card-title>
<span class="headline">{{ isEdit ? '배너 수정' : '배너 추가' }}</span>
</v-card-title>
<v-card-text>
<v-container>
<v-row>
<v-col cols="12">
<v-file-input
v-model="bannerForm.image"
label="배너 이미지"
accept="image/*"
prepend-icon="mdi-camera"
show-size
truncate-length="15"
:rules="imageRules"
outlined
/>
</v-col>
</v-row>
<v-row v-if="previewImage || (isEdit && bannerForm.imageUrl)">
<v-col
cols="12"
class="text-center"
>
<v-img
:src="previewImage || bannerForm.imageUrl"
max-height="200"
contain
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-text-field
v-model="searchKeyword"
label="캐릭터 검색"
outlined
@keyup.enter="searchCharacter"
/>
</v-col>
</v-row>
<v-row v-if="searchResults.length > 0">
<v-col cols="12">
<v-list>
<v-list-item
v-for="character in searchResults"
:key="character.id"
@click="selectCharacter(character)"
>
<v-list-item-avatar>
<v-img :src="character.imageUrl" />
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>{{ character.name }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-col>
</v-row>
<v-row v-if="searchPerformed && searchResults.length === 0">
<v-col cols="12">
<v-alert
type="info"
outlined
>
검색결과가 없습니다.
</v-alert>
</v-col>
</v-row>
<v-row v-if="selectedCharacter">
<v-col cols="12">
<v-alert
type="info"
outlined
>
<v-row align="center">
<v-col cols="auto">
<v-avatar size="50">
<v-img :src="selectedCharacter.imageUrl" />
</v-avatar>
</v-col>
<v-col>
<div class="font-weight-medium">
선택된 캐릭터: {{ selectedCharacter.name }}
</div>
</v-col>
</v-row>
</v-alert>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="closeDialog"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
:disabled="!isFormValid || isSubmitting"
:loading="isSubmitting"
@click="saveBanner"
>
저장
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 삭제 확인 다이얼로그 -->
<v-dialog
v-model="showDeleteDialog"
max-width="400"
>
<v-card>
<v-card-title class="headline">
배너 삭제
</v-card-title>
<v-card-text>
삭제 할까요?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="showDeleteDialog = false"
>
취소
</v-btn>
<v-btn
color="red darken-1"
text
:loading="isSubmitting"
@click="deleteBanner"
>
삭제
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import {
getCharacterBannerList,
createCharacterBanner,
updateCharacterBanner,
deleteCharacterBanner,
updateCharacterBannerOrder,
searchCharacters
} from '@/api/character';
import draggable from 'vuedraggable';
export default {
name: 'CharacterBanner',
components: {
draggable
},
data() {
return {
isLoading: false,
isSubmitting: false,
banners: [],
page: 1,
hasMoreItems: true,
showDialog: false,
showDeleteDialog: false,
isEdit: false,
selectedBanner: null,
selectedCharacter: null,
searchKeyword: '',
searchResults: [],
searchPerformed: false,
previewImage: null,
bannerForm: {
image: null,
imageUrl: '',
characterId: null,
bannerId: null
},
imageRules: [
v => !!v || this.isEdit || '이미지를 선택하세요'
]
};
},
computed: {
isFormValid() {
return (this.bannerForm.image || (this.isEdit && this.bannerForm.imageUrl)) && this.selectedCharacter;
}
},
watch: {
'bannerForm.image': {
handler(newImage) {
if (newImage) {
this.createImagePreview(newImage);
} else {
this.previewImage = null;
}
}
}
},
mounted() {
this.loadBanners();
window.addEventListener('scroll', this.handleScroll);
},
beforeDestroy() {
window.removeEventListener('scroll', this.handleScroll);
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message);
},
notifySuccess(message) {
this.$dialog.notify.success(message);
},
goBack() {
this.$router.push('/character');
},
async loadBanners() {
if (this.isLoading || !this.hasMoreItems) return;
this.isLoading = true;
try {
const response = await getCharacterBannerList(this.page);
if (response && response.status === 200 && response.data && response.data.success === true) {
const data = response.data.data;
const newBanners = data.content || [];
this.banners = [...this.banners, ...newBanners];
// 더 불러올 데이터가 있는지 확인
this.hasMoreItems = newBanners.length > 0;
this.page++;
} else {
this.notifyError('배너 목록을 불러오는데 실패했습니다.');
}
} catch (error) {
this.notifyError('배너 목록을 불러오는데 실패했습니다.');
} finally {
this.isLoading = false;
}
},
handleScroll() {
const scrollPosition = window.innerHeight + window.scrollY;
const documentHeight = document.documentElement.offsetHeight;
// 스크롤이 페이지 하단에 도달하면 추가 데이터 로드
if (scrollPosition >= documentHeight - 200 && !this.isLoading && this.hasMoreItems) {
this.loadBanners();
}
},
showAddDialog() {
this.isEdit = false;
this.selectedCharacter = null;
this.bannerForm = {
image: null,
imageUrl: '',
characterId: null,
bannerId: null
};
this.previewImage = null;
this.searchKeyword = '';
this.searchResults = [];
this.searchPerformed = false;
this.showDialog = true;
},
showEditDialog(banner) {
this.isEdit = true;
this.selectedBanner = banner;
this.selectedCharacter = {
id: banner.characterId,
name: banner.characterName,
imageUrl: banner.characterImageUrl
};
this.bannerForm = {
image: null,
imageUrl: banner.imageUrl,
characterId: banner.characterId,
bannerId: banner.id
};
this.previewImage = null;
this.searchKeyword = '';
this.searchResults = [];
this.searchPerformed = false;
this.showDialog = true;
},
closeDialog() {
this.showDialog = false;
this.selectedCharacter = null;
this.bannerForm = {
image: null,
imageUrl: '',
characterId: null,
bannerId: null
};
this.previewImage = null;
this.searchKeyword = '';
this.searchResults = [];
this.searchPerformed = false;
},
confirmDelete(banner) {
this.selectedBanner = banner;
this.showDeleteDialog = true;
},
createImagePreview(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
this.previewImage = e.target.result;
};
reader.readAsDataURL(file);
},
async searchCharacter() {
if (!this.searchKeyword || this.searchKeyword.length < 2) {
this.notifyError('검색어를 2글자 이상 입력하세요.');
return;
}
try {
const response = await searchCharacters(this.searchKeyword);
if (response && response.status === 200 && response.data && response.data.success === true) {
const data = response.data.data;
this.searchResults = data.content || [];
this.searchPerformed = true;
}
} catch (error) {
console.error('캐릭터 검색 오류:', error);
this.notifyError('캐릭터 검색에 실패했습니다.');
}
},
selectCharacter(character) {
this.selectedCharacter = character;
this.bannerForm.characterId = character.id;
this.searchResults = [];
},
async saveBanner() {
if (!this.isFormValid || this.isSubmitting) return;
this.isSubmitting = true;
try {
if (this.isEdit) {
// 배너 수정
const response = await updateCharacterBanner({
image: this.bannerForm.image,
characterId: this.selectedCharacter.id,
bannerId: this.bannerForm.bannerId
});
if (response && response.status === 200 && response.data && response.data.success === true) {
this.notifySuccess('배너가 수정되었습니다.');
} else {
this.notifyError('배너 수정을 실패했습니다.');
}
} else {
// 배너 추가
const response = await createCharacterBanner({
image: this.bannerForm.image,
characterId: this.selectedCharacter.id
});
if (response && response.status === 200 && response.data && response.data.success === true) {
this.notifySuccess('배너가 추가되었습니다.');
// 다이얼로그 닫고 배너 목록 새로고침
this.closeDialog();
this.refreshBanners();
} else {
this.notifyError('배너 추가를 실패했습니다.');
}
}
} catch (error) {
console.error('배너 저장 오류:', error);
this.notifyError('배너 저장에 실패했습니다.');
} finally {
this.isSubmitting = false;
}
},
async deleteBanner() {
if (!this.selectedBanner || this.isSubmitting) return;
this.isSubmitting = true;
try {
const response = await deleteCharacterBanner(this.selectedBanner.id);
if (response && response.status === 200 && response.data && response.data.success === true) {
this.notifySuccess('배너가 삭제되었습니다.');
this.showDeleteDialog = false;
this.refreshBanners();
} else {
this.notifyError('배너 삭제에 실패했습니다.');
}
} catch (error) {
console.error('배너 삭제 오류:', error);
this.notifyError('배너 삭제에 실패했습니다.');
} finally {
this.isSubmitting = false;
}
},
refreshBanners() {
// 배너 목록 초기화 후 다시 로드
this.banners = [];
this.page = 1;
this.hasMoreItems = true;
this.loadBanners();
},
async onDragEnd() {
// 드래그 앤 드롭으로 순서 변경 후 API 호출
try {
const bannerIds = this.banners.map(banner => banner.id);
const response = await updateCharacterBannerOrder(bannerIds);
if (response && response.status === 200 && response.data && response.data.success === true) {
this.notifySuccess('배너 순서가 변경되었습니다.');
} else {
this.notifyError('배너 순서 변경에 실패했습니다.');
}
} catch (error) {
console.error('배너 순서 변경 오류:', error);
this.notifyError('배너 순서 변경에 실패했습니다.');
// 실패 시 목록 새로고침
this.refreshBanners();
}
}
}
};
</script>
<style scoped>
.banner-item {
transition: all 0.3s;
}
.banner-item:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
</style>

View File

@@ -0,0 +1,315 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>캐릭터 정산</v-toolbar-title>
<v-spacer />
</v-toolbar>
<v-container>
<v-row class="justify-center align-center text-center">
<v-col
cols="12"
md="3"
>
<v-menu
ref="menuStart"
v-model="menuStart"
:close-on-content-click="false"
transition="scale-transition"
offset-y
min-width="auto"
>
<template v-slot:activator="{ on, attrs }">
<v-text-field
v-model="filters.startDateStr"
label="시작일"
readonly
dense
v-bind="attrs"
clearable
v-on="on"
/>
</template>
<v-date-picker
v-model="filters.startDateStr"
:max="filters.endDateStr && filters.endDateStr < todayStr ? filters.endDateStr : todayStr"
locale="ko-kr"
@input="$refs.menuStart.save(filters.startDateStr)"
/>
</v-menu>
</v-col>
<v-col
cols="12"
md="3"
>
<v-menu
ref="menuEnd"
v-model="menuEnd"
:close-on-content-click="false"
transition="scale-transition"
offset-y
min-width="auto"
>
<template v-slot:activator="{ on, attrs }">
<v-text-field
v-model="filters.endDateStr"
label="종료일"
readonly
dense
v-bind="attrs"
clearable
v-on="on"
/>
</template>
<v-date-picker
v-model="filters.endDateStr"
:min="filters.startDateStr"
:max="todayStr"
locale="ko-kr"
@input="$refs.menuEnd.save(filters.endDateStr)"
/>
</v-menu>
</v-col>
<v-col
cols="12"
md="3"
>
<v-select
v-model="filters.sort"
:items="sortItems"
label="정렬"
item-text="text"
item-value="value"
dense
/>
</v-col>
<v-col
cols="12"
md="3"
class="d-flex justify-center align-center"
>
<v-btn
color="primary"
small
:loading="is_loading"
@click="fetchList(1)"
>
조회
</v-btn>
<v-btn
class="ml-2"
text
small
@click="resetFilters"
>
초기화
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col>
<v-simple-table class="elevation-10 text-center">
<template>
<thead>
<tr>
<th class="text-center">
이미지
</th>
<th class="text-center">
캐릭터명
</th>
<th class="text-center">
이미지 단독 구매()
</th>
<th class="text-center">
메시지 구매()
</th>
<th class="text-center">
채팅 횟수 구매()
</th>
<th class="text-center">
합계()
</th>
<th class="text-center">
합계(원화)
</th>
<th class="text-center">
정산금액()
</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in items"
:key="item.characterId"
>
<td align="center">
<v-img
:src="item.characterImage"
max-width="64"
max-height="64"
class="rounded-circle"
contain
/>
</td>
<td class="text-center">
{{ item.name }}
</td>
<td class="text-center">
{{ formatNumber(item.imagePurchaseCan) }}
</td>
<td class="text-center">
{{ formatNumber(item.messagePurchaseCan) }}
</td>
<td class="text-center">
{{ formatNumber(item.quotaPurchaseCan) }}
</td>
<td class="text-center font-weight-bold">
{{ formatNumber(item.totalCan) }}
</td>
<td class="text-center">
{{ formatCurrency(item.totalKrw) }}
</td>
<td class="text-center font-weight-bold">
{{ formatCurrency(item.settlementKrw) }}
</td>
</tr>
<tr v-if="!is_loading && items.length === 0">
<td
colspan="7"
class="text-center grey--text"
>
데이터가 없습니다.
</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-col>
</v-row>
<v-row class="text-center">
<v-col>
<v-pagination
v-model="page"
:length="total_page"
circle
@input="onPageChange"
/>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script>
import { getCharacterCalculateList } from "@/api/character";
function formatDate(date) {
const pad = (n) => n.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
export default {
name: "CharacterCalculateList",
data() {
const today = new Date();
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(today.getDate() - 7);
return {
is_loading: false,
menuStart: false,
menuEnd: false,
todayStr: formatDate(today),
page: 1,
size: 30,
total_page: 1,
total_count: 0,
items: [],
sortItems: [
{ text: "매출순", value: "TOTAL_SALES_DESC" },
{ text: "최신캐릭터순", value: "LATEST_DESC" }
],
filters: {
startDateStr: formatDate(new Date(today.getFullYear(), today.getMonth(), 1)),
endDateStr: formatDate(today),
sort: "TOTAL_SALES_DESC"
}
};
},
created() {
this.fetchList(1);
},
methods: {
notifyError(message) {
this.$dialog && this.$dialog.notify ? this.$dialog.notify.error(message) : alert(message);
},
onPageChange() {
this.fetchList(this.page);
},
resetFilters() {
const today = new Date();
// 이번 달 1일로 시작일 설정
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
this.filters.startDateStr = formatDate(firstDay);
// 종료일은 오늘
this.filters.endDateStr = formatDate(today);
this.filters.sort = "TOTAL_SALES_DESC";
// 페이지를 1로 리셋하고 목록 조회
this.fetchList(1);
},
async fetchList(page = 1) {
if (this.is_loading) return;
this.is_loading = true;
try {
const params = {
startDateStr: this.filters.startDateStr || null,
endDateStr: this.filters.endDateStr || null,
sort: this.filters.sort,
page: (page - 1),
size: this.size
};
const res = await getCharacterCalculateList(params);
if (res && res.status === 200) {
const data = res.data && res.data.data ? res.data.data : res.data;
if (data) {
this.total_count = data.totalCount || 0;
this.items = data.items || [];
const totalPage = Math.ceil(this.total_count / this.size);
this.total_page = totalPage > 0 ? totalPage : 1;
this.page = page;
} else {
this.items = [];
this.total_count = 0;
this.total_page = 1;
}
} else {
this.notifyError("목록 조회 중 오류가 발생했습니다.");
}
} catch (e) {
console.error("정산 목록 조회 오류:", e);
this.notifyError("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.");
} finally {
this.is_loading = false;
}
},
formatNumber(n) {
const num = Number(n || 0);
return num.toLocaleString("ko-KR");
},
formatCurrency(n) {
const num = Number(n || 0);
return num.toLocaleString("ko-KR");
}
}
};
</script>
<style scoped>
.v-simple-table {
width: 100%;
}
</style>

View File

@@ -0,0 +1,341 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>캐릭터 큐레이션</v-toolbar-title>
<v-spacer />
</v-toolbar>
<v-container>
<v-row class="mb-4">
<v-col
cols="12"
sm="4"
>
<v-btn
color="primary"
dark
@click="showWriteDialog"
>
큐레이션 등록
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col>
<v-data-table
:headers="headers"
:items="curations"
:loading="isLoading"
item-key="id"
class="elevation-1"
hide-default-footer
disable-pagination
>
<template v-slot:body="props">
<draggable
v-model="props.items"
tag="tbody"
@end="onDragEnd(props.items)"
>
<tr
v-for="item in props.items"
:key="item.id"
>
<td @click="goDetail(item)">
{{ item.title }}
</td>
<td @click="goDetail(item)">
<h3 v-if="item.isAdult">
O
</h3>
<h3 v-else>
X
</h3>
</td>
<td>
<v-row>
<v-col class="text-center">
<v-btn
small
color="primary"
:disabled="isLoading"
@click="showModifyDialog(item)"
>
수정
</v-btn>
</v-col>
<v-col class="text-center">
<v-btn
small
color="error"
:disabled="isLoading"
@click="confirmDelete(item)"
>
삭제
</v-btn>
</v-col>
</v-row>
</td>
</tr>
</draggable>
</template>
</v-data-table>
</v-col>
</v-row>
</v-container>
<!-- 등록/수정 다이얼로그 -->
<v-dialog
v-model="showDialog"
max-width="600px"
persistent
>
<v-card>
<v-card-title>
<span class="headline">{{ isModify ? '큐레이션 수정' : '큐레이션 등록' }}</span>
</v-card-title>
<v-card-text>
<v-container>
<v-row>
<v-col cols="12">
<v-text-field
v-model="form.title"
label="제목"
outlined
required
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-checkbox
v-model="form.isAdult"
label="19금"
/>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
text
color="blue darken-1"
@click="closeDialog"
>
취소
</v-btn>
<v-btn
text
color="blue darken-1"
:loading="isSubmitting"
:disabled="!isFormValid || isSubmitting"
@click="saveCuration"
>
저장
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 삭제 확인 다이얼로그 -->
<v-dialog
v-model="showDeleteDialog"
max-width="400px"
>
<v-card>
<v-card-title class="headline">
큐레이션 삭제
</v-card-title>
<v-card-text>"{{ selectedCuration && selectedCuration.title }}"() 삭제하시겠습니까?</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
text
color="blue darken-1"
@click="showDeleteDialog = false"
>
취소
</v-btn>
<v-btn
text
color="red darken-1"
:loading="isSubmitting"
@click="deleteCuration"
>
삭제
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import draggable from 'vuedraggable';
import {
getCharacterCurationList,
createCharacterCuration,
updateCharacterCuration,
deleteCharacterCuration,
updateCharacterCurationOrder
} from '@/api/character';
export default {
name: 'CharacterCuration',
components: { draggable },
data() {
return {
isLoading: false,
isSubmitting: false,
curations: [],
headers: [
{ text: '제목', align: 'center', sortable: false, value: 'title' },
{ text: '19금', align: 'center', sortable: false, value: 'isAdult' },
{ text: '관리', align: 'center', sortable: false, value: 'management' }
],
showDialog: false,
isModify: false,
form: { id: null, title: '', isAdult: false },
selectedCuration: null,
showDeleteDialog: false
};
},
computed: {
isFormValid() {
return this.form.title && this.form.title.trim().length > 0;
}
},
async created() {
await this.loadCurations();
},
methods: {
notifyError(message) { this.$dialog.notify.error(message); },
notifySuccess(message) { this.$dialog.notify.success(message); },
async loadCurations() {
this.isLoading = true;
try {
const res = await getCharacterCurationList();
if (res.status === 200 && res.data && res.data.success === true) {
this.curations = res.data.data || [];
} else {
this.notifyError(res.data.message || '목록을 불러오지 못했습니다.');
}
} catch (e) {
this.notifyError('목록을 불러오지 못했습니다.');
} finally {
this.isLoading = false;
}
},
onDragEnd(items) {
const ids = items.map(i => i.id);
this.updateOrders(ids);
},
async updateOrders(ids) {
try {
const res = await updateCharacterCurationOrder(ids);
if (res.status === 200 && res.data && res.data.success === true) {
this.notifySuccess('순서가 변경되었습니다.');
} else {
this.notifyError(res.data.message || '순서 변경에 실패했습니다.');
}
} catch (e) {
this.notifyError('순서 변경에 실패했습니다.');
}
},
goDetail(item) {
this.$router.push({
name: 'CharacterCurationDetail',
params: { curationId: item.id, title: item.title, isAdult: item.isAdult }
});
},
showWriteDialog() {
this.isModify = false;
this.form = { id: null, title: '', isAdult: false };
this.showDialog = true;
},
showModifyDialog(item) {
this.isModify = true;
this.form = { id: item.id, title: item.title, isAdult: item.isAdult };
this.showDialog = true;
},
closeDialog() {
this.showDialog = false;
this.form = { id: null, title: '', isAdult: false };
},
async saveCuration() {
if (this.isSubmitting || !this.isFormValid) return;
this.isSubmitting = true;
try {
if (this.isModify) {
const payload = { id: this.form.id };
if (this.form.title) payload.title = this.form.title;
payload.isAdult = this.form.isAdult;
const res = await updateCharacterCuration(payload);
if (res.status === 200 && res.data && res.data.success === true) {
this.notifySuccess('수정되었습니다.');
this.closeDialog();
await this.loadCurations();
} else {
this.notifyError(res.data.message || '수정에 실패했습니다.');
}
} else {
const res = await createCharacterCuration({
title: this.form.title,
isAdult: this.form.isAdult,
isActive: true
});
if (res.status === 200 && res.data && res.data.success === true) {
this.notifySuccess('등록되었습니다.');
this.closeDialog();
await this.loadCurations();
} else {
this.notifyError(res.data.message || '등록에 실패했습니다.');
}
}
} catch (e) {
this.notifyError('저장 중 오류가 발생했습니다.');
} finally {
this.isSubmitting = false;
}
},
confirmDelete(item) {
this.selectedCuration = item;
this.showDeleteDialog = true;
},
async deleteCuration() {
if (!this.selectedCuration) return;
this.isSubmitting = true;
try {
const res = await deleteCharacterCuration(this.selectedCuration.id);
if (res.status === 200 && res.data && res.data.success === true) {
this.notifySuccess('삭제되었습니다.');
this.showDeleteDialog = false;
await this.loadCurations();
} else {
this.notifyError(res.data.message || '삭제에 실패했습니다.');
}
} catch (e) {
this.notifyError('삭제에 실패했습니다.');
} finally {
this.isSubmitting = false;
}
}
}
};
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,429 @@
<template>
<div>
<v-toolbar dark>
<v-btn
icon
@click="goBack"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-spacer />
<v-toolbar-title>{{ title }}</v-toolbar-title>
<v-spacer />
</v-toolbar>
<v-container>
<v-row class="mb-2">
<v-col
cols="4"
class="text-right"
>
19 :
</v-col>
<v-col cols="8">
{{ isAdult ? 'O' : 'X' }}
</v-col>
</v-row>
<v-row class="mb-4">
<v-col
cols="12"
sm="4"
>
<v-btn
color="primary"
dark
@click="openAddDialog"
>
캐릭터 등록
</v-btn>
</v-col>
</v-row>
<v-row>
<draggable
v-model="characters"
class="row"
style="width: 100%"
:options="{ animation: 150 }"
@end="onDragEnd"
>
<v-col
v-for="ch in characters"
:key="ch.id"
cols="12"
sm="6"
md="4"
lg="3"
class="mb-4"
>
<v-card>
<v-img
:src="ch.imageUrl"
height="200"
contain
/>
<v-card-text class="text-center">
{{ ch.name }}
</v-card-text>
<v-card-text class="text-center">
{{ ch.description }}
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
small
color="error"
@click="confirmRemove(ch)"
>
삭제
</v-btn>
<v-spacer />
</v-card-actions>
</v-card>
</v-col>
</draggable>
</v-row>
<v-row v-if="isLoading && characters.length === 0">
<v-col class="text-center">
<v-progress-circular
indeterminate
color="primary"
size="48"
/>
</v-col>
</v-row>
<v-row v-if="!isLoading && characters.length === 0">
<v-col class="text-center">
등록된 캐릭터가 없습니다.
</v-col>
</v-row>
</v-container>
<!-- 등록 다이얼로그 -->
<v-dialog
v-model="showAddDialog"
max-width="700px"
persistent
>
<v-card>
<v-card-title>캐릭터 등록</v-card-title>
<v-card-text>
<v-text-field
v-model="searchWord"
label="캐릭터 검색"
outlined
@keyup.enter="search"
/>
<v-btn
color="primary"
small
class="mb-2"
@click="search"
>
검색
</v-btn>
<v-row v-if="searchResults.length > 0 || addList.length > 0">
<v-col>
검색결과
<v-simple-table>
<template v-slot:default>
<thead>
<tr>
<th class="text-center">
이름
</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="item in searchResults"
:key="item.id"
>
<td>{{ item.name }}</td>
<td>
<v-btn
small
color="primary"
@click="addItem(item)"
>
추가
</v-btn>
</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-col>
<v-col v-if="addList.length > 0">
추가할 캐릭터
<v-simple-table>
<template>
<thead>
<tr>
<th class="text-center">
이름
</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="item in addList"
:key="item.id"
>
<td>{{ item.name }}</td>
<td>
<v-btn
small
color="error"
@click="removeItem(item)"
>
제거
</v-btn>
</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-col>
</v-row>
<v-alert
v-else-if="searchPerformed"
type="info"
outlined
>
검색결과가 없습니다.
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
text
color="blue darken-1"
@click="closeAddDialog"
>
취소
</v-btn>
<v-btn
text
color="blue darken-1"
:disabled="addList.length === 0 || isSubmitting"
:loading="isSubmitting"
@click="addItemInCuration"
>
추가
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 삭제 확인 다이얼로그 -->
<v-dialog
v-model="showDeleteDialog"
max-width="420px"
>
<v-card>
<v-card-title class="headline">
캐릭터 삭제
</v-card-title>
<v-card-text>"{{ targetCharacter && targetCharacter.name }}"() 큐레이션에서 삭제할까요?</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
text
color="blue darken-1"
@click="showDeleteDialog = false"
>
취소
</v-btn>
<v-btn
text
color="red darken-1"
:loading="isSubmitting"
@click="removeTarget"
>
삭제
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import draggable from 'vuedraggable';
import {
getCharactersInCuration,
addCharacterToCuration,
removeCharacterFromCuration,
updateCurationCharactersOrder,
searchCharacters
} from '@/api/character';
export default {
name: 'CharacterCurationDetail',
components: { draggable },
data() {
return {
isLoading: false,
isSubmitting: false,
curationId: null,
title: '',
isAdult: false,
characters: [],
showAddDialog: false,
showDeleteDialog: false,
targetCharacter: null,
searchWord: '',
searchResults: [],
searchPerformed: false,
addList: []
};
},
async created() {
this.curationId = this.$route.params.curationId;
this.title = this.$route.params.title;
this.isAdult = this.$route.params.isAdult;
await this.loadCharacters();
},
methods: {
notifyError(message) { this.$dialog.notify.error(message); },
notifySuccess(message) { this.$dialog.notify.success(message); },
goBack() { this.$router.push({ name: 'CharacterCuration' }); },
async loadCharacters() {
this.isLoading = true;
try {
const res = await getCharactersInCuration(this.curationId);
if (res.status === 200 && res.data && res.data.success === true) {
this.characters = res.data.data || [];
} else {
this.notifyError(res.data.message || '캐릭터 목록을 불러오지 못했습니다.');
}
} catch (e) {
this.notifyError('캐릭터 목록을 불러오지 못했습니다.');
} finally {
this.isLoading = false;
}
},
openAddDialog() {
this.showAddDialog = true;
this.searchWord = '';
this.searchResults = [];
this.addList = [];
this.searchPerformed = false;
},
closeAddDialog() {
this.showAddDialog = false;
this.searchWord = '';
this.searchResults = [];
this.addList = [];
this.searchPerformed = false;
},
async search() {
if (!this.searchWord || this.searchWord.length < 2) {
this.notifyError('검색어를 2글자 이상 입력하세요.');
return;
}
try {
const res = await searchCharacters(this.searchWord);
if (res.status === 200 && res.data && res.data.success === true) {
const data = res.data.data;
const list = data.content || [];
const existingIds = new Set(this.characters.map(c => c.id));
const pendingIds = new Set(this.addList.map(c => c.id));
this.searchResults = list.filter(item => !existingIds.has(item.id) && !pendingIds.has(item.id));
this.searchPerformed = true;
} else {
this.notifyError(res.data.message || '검색에 실패했습니다.');
}
} catch (e) {
this.notifyError('검색에 실패했습니다.');
}
},
addItem(item) {
// 검색결과에서 제거하고 추가 목록에 삽입 (중복 방지)
if (!this.addList.find(t => t.id === item.id)) {
this.addList.push(item);
}
this.searchResults = this.searchResults.filter(t => t.id !== item.id);
},
removeItem(item) {
this.addList = this.addList.filter(t => t.id !== item.id);
// 제거 시 검색결과에 다시 추가
if (!this.searchResults.find(t => t.id === item.id)) {
this.searchResults.push(item);
}
},
async addItemInCuration() {
if (!this.addList || this.addList.length === 0) return;
this.isSubmitting = true;
try {
const ids = this.addList.map(i => i.id);
const res = await addCharacterToCuration(this.curationId, ids);
if (res.status === 200 && res.data && res.data.success === true) {
this.notifySuccess(`${this.addList.length}명 추가되었습니다.`);
this.closeAddDialog();
await this.loadCharacters();
} else {
this.notifyError((res.data && res.data.message) || '추가에 실패했습니다.');
}
} catch (e) {
this.notifyError('추가에 실패했습니다.');
} finally {
this.isSubmitting = false;
}
},
confirmRemove(item) { this.targetCharacter = item; this.showDeleteDialog = true; },
async removeTarget() {
if (!this.targetCharacter) return;
this.isSubmitting = true;
try {
const res = await removeCharacterFromCuration(this.curationId, this.targetCharacter.id);
if (res.status === 200 && res.data && res.data.success === true) {
this.notifySuccess('삭제되었습니다.');
this.showDeleteDialog = false;
await this.loadCharacters();
} else {
this.notifyError(res.data.message || '삭제에 실패했습니다.');
}
} catch (e) {
this.notifyError('삭제에 실패했습니다.');
} finally {
this.isSubmitting = false;
}
},
async onDragEnd() {
try {
const ids = this.characters.map(c => c.id);
const res = await updateCurationCharactersOrder(this.curationId, ids);
if (res.status === 200 && res.data && res.data.success === true) {
this.notifySuccess('순서가 변경되었습니다.');
} else {
this.notifyError(res.data.message || '순서 변경에 실패했습니다.');
}
} catch (e) {
this.notifyError('순서 변경에 실패했습니다.');
}
}
}
};
</script>
<style scoped>
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,306 @@
<template>
<div>
<v-toolbar dark>
<v-btn
icon
@click="goBack"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-spacer />
<v-toolbar-title>{{ isEdit ? '이미지 수정' : '이미지 등록' }}</v-toolbar-title>
<v-spacer />
</v-toolbar>
<v-container>
<v-card class="pa-4">
<v-form
ref="form"
v-model="isFormValid"
>
<v-card-text>
<v-row>
<v-col
v-show="!isEdit"
cols="12"
md="6"
>
<v-file-input
v-if="!isEdit"
v-model="form.image"
label="이미지 (800x1000 비율 권장)"
accept="image/*"
prepend-icon="mdi-camera"
show-size
truncate-length="15"
outlined
dense
:rules="imageRules"
/>
</v-col>
<v-col
v-if="previewImage || form.imageUrl"
cols="12"
:md="isEdit ? 12 : 6"
>
<div class="text-center">
<v-img
:src="previewImage || form.imageUrl"
max-height="240"
:aspect-ratio="0.8"
contain
/>
</div>
</v-col>
</v-row>
<v-row>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model.number="form.soloPurchasePriceCan"
label="이미지 단독 구매 가격(캔)"
type="number"
min="0"
outlined
dense
:disabled="isEdit"
/>
</v-col>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model.number="form.messagePurchasePriceCan"
label="메시지에서 구매 가격(캔)"
type="number"
min="0"
outlined
dense
:disabled="isEdit"
/>
</v-col>
</v-row>
<v-row>
<v-col
cols="12"
md="6"
>
<v-switch
v-model="form.adult"
label="성인 이미지 여부"
inset
:disabled="isEdit"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-combobox
v-model="triggers"
label="트리거 단어 입력"
multiple
chips
small-chips
deletable-chips
outlined
dense
:rules="triggerRules"
@keydown.space.prevent="addTrigger"
>
<template v-slot:selection="{ attrs, item, select, selected }">
<v-chip
v-bind="attrs"
:input-value="selected"
close
@click="select"
@click:close="removeTrigger(item)"
>
{{ item }}
</v-chip>
</template>
</v-combobox>
<div class="caption grey--text text--darken-1">
트리거를 입력하고 엔터를 누르면 추가됩니다. (20 이내, 최소 3, 최대 10)
</div>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
text
color="blue darken-1"
@click="goBack"
>
취소
</v-btn>
<v-btn
text
color="blue darken-1"
:disabled="!canSubmit || isSubmitting"
:loading="isSubmitting"
@click="save"
>
저장
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-container>
</div>
</template>
<script>
import { createCharacterImage, updateCharacterImage, getCharacterImage } from '@/api/character'
export default {
name: 'CharacterImageForm',
data() {
return {
isEdit: !!this.$route.query.imageId,
isSubmitting: false,
isFormValid: false,
characterId: Number(this.$route.query.characterId),
imageId: this.$route.query.imageId ? Number(this.$route.query.imageId) : null,
form: {
image: null,
imageUrl: '',
soloPurchasePriceCan: null,
messagePurchasePriceCan: null,
adult: false
},
previewImage: null,
triggers: [],
triggerRules: [
v => (v && v.length >= 3 && v.length <= 10) || '트리거는 최소 3개, 최대 10개까지 등록 가능합니다'
],
imageRules: [
v => !!v || '이미지를 선택하세요'
]
}
},
computed: {
canSubmit() {
const triggersValid = this.triggers && this.triggers.length >= 3 && this.triggers.length <= 10
if (this.isEdit) return triggersValid
return !!this.form.image && triggersValid
}
},
watch: {
'form.image'(newVal) {
if (!this.isEdit) {
if (newVal) this.createImagePreview(newVal)
else this.previewImage = null
}
}
},
created() {
if (!this.characterId) {
this.notifyError('캐릭터 ID가 없습니다.')
this.goBack();
return
}
if (this.isEdit && this.imageId) {
this.loadDetail()
}
},
methods: {
notifyError(m) { this.$dialog.notify.error(m) },
notifySuccess(m) { this.$dialog.notify.success(m) },
goBack() {
this.$router.push({ path: '/character/images', query: { characterId: this.characterId, name: this.$route.query.name || '' } })
},
createImagePreview(file) {
const reader = new FileReader()
reader.onload = e => { this.previewImage = e.target.result }
reader.readAsDataURL(file)
},
addTrigger(e) {
const value = (e.target.value || '').trim()
if (!value) return
if (value.length > 20) {
this.notifyError('트리거는 20자 이내여야 합니다.')
return
}
if (this.triggers.length >= 10) {
this.notifyError('트리거는 최대 10개까지 등록 가능합니다.')
return
}
if (!this.triggers.includes(value)) this.triggers.push(value)
e.target.value = ''
},
removeTrigger(item) {
this.triggers = this.triggers.filter(t => t !== item)
},
async loadDetail() {
try {
const resp = await getCharacterImage(this.imageId)
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
const d = resp.data.data
// 수정 시 트리거만 노출하며 나머지는 비활성화
this.form.imageUrl = d.imageUrl
this.form.soloPurchasePriceCan = d.imagePriceCan
this.form.messagePurchasePriceCan = d.messagePriceCan
this.form.adult = d.isAdult
this.triggers = d.triggers || []
} else {
this.notifyError('이미지 정보를 불러오지 못했습니다.')
}
} catch (e) {
console.error('이미지 상세 오류:', e)
this.notifyError('이미지 정보를 불러오지 못했습니다.')
}
},
async save() {
if (this.isSubmitting) return
// 트리거 개수 검증: 최소 3개, 최대 10개
if (!this.triggers || this.triggers.length < 3 || this.triggers.length > 10) {
this.notifyError('트리거는 최소 3개, 최대 10개여야 합니다.')
return
}
this.isSubmitting = true
try {
if (this.isEdit) {
const resp = await updateCharacterImage({ imageId: this.imageId, triggers: this.triggers })
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
this.notifySuccess('수정되었습니다.')
this.goBack()
} else {
this.notifyError('수정에 실패했습니다.')
}
} else {
const resp = await createCharacterImage({
characterId: this.characterId,
image: this.form.image,
imagePriceCan: this.form.soloPurchasePriceCan,
messagePriceCan: this.form.messagePurchasePriceCan,
isAdult: this.form.adult,
triggers: this.triggers
})
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
this.notifySuccess('등록되었습니다.')
this.goBack()
} else {
this.notifyError('등록에 실패했습니다.')
}
}
} catch (e) {
console.error('이미지 저장 오류:', e)
this.notifyError('작업 중 오류가 발생했습니다.')
} finally {
this.isSubmitting = false
}
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,325 @@
<template>
<div>
<v-toolbar dark>
<v-btn
icon
@click="goBack"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-spacer />
<v-toolbar-title>캐릭터 이미지 관리</v-toolbar-title>
<v-spacer />
</v-toolbar>
<v-container>
<v-row class="align-center mb-4">
<v-col
cols="12"
md="6"
>
<div class="subtitle-1">
캐릭터: {{ characterName || characterId }}
</div>
</v-col>
<v-col
cols="12"
md="6"
class="text-right"
>
<v-btn
color="primary"
dark
@click="goToAdd"
>
이미지 추가
</v-btn>
</v-col>
</v-row>
<!-- 로딩 -->
<v-row v-if="isLoading && images.length === 0">
<v-col class="text-center">
<v-progress-circular
indeterminate
color="primary"
size="48"
/>
</v-col>
</v-row>
<!-- 목록 -->
<draggable
v-if="images.length > 0"
v-model="images"
class="image-grid"
:options="{ animation: 150 }"
@end="onDragEnd"
>
<div
v-for="img in images"
:key="img.id"
class="image-card"
>
<v-card>
<div class="image-wrapper">
<v-img
:src="img.imageUrl"
:aspect-ratio="0.8"
contain
/>
<div
v-if="img.isAdult"
class="ribbon"
>
성인
</div>
</div>
<v-card-text class="pt-2">
<div class="price-row d-flex align-center">
<div class="price-label">
단독 :
</div>
<div class="price-value">
{{ img.imagePriceCan }}
</div>
</div>
<div class="price-row d-flex align-center">
<div class="price-label">
메시지 :
</div>
<div class="price-value">
{{ img.messagePriceCan }}
</div>
</div>
<div class="mt-2">
<v-chip
v-for="(t, i) in (img.triggers || [])"
:key="i"
small
class="ma-1"
color="primary"
text-color="white"
>
{{ t }}
</v-chip>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
small
color="primary"
@click="goToEdit(img)"
>
수정
</v-btn>
<v-btn
small
color="error"
@click="confirmDelete(img)"
>
삭제
</v-btn>
</v-card-actions>
</v-card>
</div>
</draggable>
<!-- 데이터 없음 -->
<v-row v-if="!isLoading && images.length === 0">
<v-col class="text-center grey--text">
등록된 이미지가 없습니다.
</v-col>
</v-row>
<!-- 삭제 확인 다이얼로그 -->
<v-dialog
v-model="showDeleteDialog"
max-width="400"
>
<v-card>
<v-card-title class="headline">
이미지 삭제
</v-card-title>
<v-card-text>삭제하시겠습니까?</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
text
color="blue darken-1"
@click="showDeleteDialog = false"
>
취소
</v-btn>
<v-btn
text
color="red darken-1"
:loading="isSubmitting"
@click="deleteImage"
>
삭제
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</div>
</template>
<script>
import { getCharacterImageList, deleteCharacterImage, updateCharacterImageOrder } from '@/api/character'
import draggable from 'vuedraggable'
export default {
name: 'CharacterImageList',
components: { draggable },
data() {
return {
isLoading: false,
isSubmitting: false,
images: [],
characterId: null,
characterName: this.$route.query.name || '',
showDeleteDialog: false,
selectedImage: null
}
},
created() {
this.characterId = Number(this.$route.query.characterId)
if (!this.characterId) {
this.notifyError('캐릭터 ID가 없습니다.');
this.goBack()
return
}
this.loadImages()
},
methods: {
notifyError(message) { this.$dialog.notify.error(message) },
notifySuccess(message) { this.$dialog.notify.success(message) },
goBack() { this.$router.push('/character') },
async loadImages() {
this.isLoading = true
try {
const resp = await getCharacterImageList(this.characterId, 1, 20)
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
const data = resp.data.data
this.images = (data.content || data || [])
} else {
this.notifyError('이미지 목록을 불러오지 못했습니다.')
}
} catch (e) {
console.error('이미지 목록 오류:', e)
this.notifyError('이미지 목록 조회 중 오류가 발생했습니다.')
} finally {
this.isLoading = false
}
},
goToAdd() {
this.$router.push({ path: '/character/images/form', query: { characterId: this.characterId, name: this.characterName } })
},
goToEdit(img) {
this.$router.push({ path: '/character/images/form', query: { characterId: this.characterId, imageId: img.id, name: this.characterName } })
},
confirmDelete(img) {
this.selectedImage = img
this.showDeleteDialog = true
},
async deleteImage() {
if (!this.selectedImage || this.isSubmitting) return
this.isSubmitting = true
try {
const resp = await deleteCharacterImage(this.selectedImage.id)
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
this.notifySuccess('삭제되었습니다.')
this.showDeleteDialog = false
await this.loadImages()
} else {
this.notifyError('삭제에 실패했습니다.')
}
} catch (e) {
console.error('이미지 삭제 오류:', e)
this.notifyError('삭제 중 오류가 발생했습니다.')
} finally {
this.isSubmitting = false
}
},
async onDragEnd() {
try {
const ids = this.images.map(img => img.id)
const resp = await updateCharacterImageOrder(this.characterId, ids)
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
this.notifySuccess('이미지 순서가 변경되었습니다.')
} else {
this.notifyError('이미지 순서 변경에 실패했습니다.')
await this.loadImages()
}
} catch (e) {
console.error('이미지 순서 변경 오류:', e)
this.notifyError('이미지 순서 변경에 실패했습니다.')
await this.loadImages()
}
}
}
}
</script>
<style scoped>
.image-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
}
.image-card {
width: 100%;
}
@media (max-width: 1264px) {
.image-grid { grid-template-columns: repeat(4, 1fr); }
}
@media (max-width: 960px) {
.image-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 600px) {
.image-grid { grid-template-columns: repeat(2, 1fr); }
}
/* Image wrapper for overlays */
.image-wrapper {
position: relative;
}
/* Ribbon style for adult indicator */
.ribbon {
position: absolute;
top: 0;
right: 0;
z-index: 2;
background: #e53935; /* red darken-1 */
color: #fff;
padding: 6px 20px;
font-weight: 500;
font-size: 14px;
text-transform: none;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
pointer-events: none;
}
/* Price rows styling */
.price-row {
font-size: 16px;
line-height: 1.6;
margin-bottom: 4px;
}
.price-label {
width: 72px; /* 긴 쪽 기준으로 라벨 고정폭 */
text-align: left;
color: rgba(0,0,0,0.6);
font-weight: 700;
}
.price-value {
flex: 1;
font-weight: 700;
color: rgba(0,0,0,0.87);
text-align: left;
}
</style>

View File

@@ -0,0 +1,438 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>캐릭터 리스트</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-container>
<v-row align="center">
<v-col cols="4">
<v-btn
color="primary"
dark
@click="showAddDialog"
>
캐릭터 추가
</v-btn>
</v-col>
<v-col
cols="8"
class="d-flex justify-end align-center"
>
<v-text-field
v-model="searchTerm"
label="검색어"
placeholder="캐릭터명, 태그, mbti 검색"
outlined
dense
hide-details
style="max-width: 320px;"
class="mr-2"
@keyup.enter="onSearch"
/>
<v-btn
:style="{ backgroundColor: '#3bb9f1', color: 'white' }"
:disabled="is_loading"
@click="onSearch"
>
검색
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col>
<v-simple-table class="elevation-10">
<template>
<thead>
<tr>
<th class="text-center">
ID
</th>
<th class="text-center">
이미지
</th>
<th class="text-center">
캐릭터명
</th>
<th class="text-center">
성별
</th>
<th class="text-center">
나이
</th>
<th class="text-center">
캐릭터 설명
</th>
<th class="text-center">
MBTI
</th>
<th class="text-center">
말투
</th>
<th class="text-center">
대화 스타일
</th>
<th class="text-center">
태그
</th>
<th class="text-center">
등록일
</th>
<th class="text-center">
수정일
</th>
<th class="text-center">
관리
</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in characters"
:key="item.id"
>
<td>{{ item.id }}</td>
<td align="center">
<v-img
max-width="100"
max-height="100"
:src="item.imageUrl"
class="rounded-circle"
/>
</td>
<td>{{ item.name }}</td>
<td>{{ item.gender || '-' }}</td>
<td>{{ item.age || '-' }}</td>
<td>
<v-btn
small
color="info"
@click="showDetailDialog(item, 'description')"
>
보기
</v-btn>
</td>
<td>{{ item.mbti || '-' }}</td>
<td>
<v-btn
small
color="info"
@click="showDetailDialog(item, 'speechPattern')"
>
보기
</v-btn>
</td>
<td>
<v-btn
small
color="info"
@click="showDetailDialog(item, 'speechStyle')"
>
보기
</v-btn>
</td>
<td>
<div v-if="item.tags && item.tags.length > 0">
<v-chip
v-for="(tag, index) in item.tags"
:key="index"
small
class="ma-1"
color="primary"
text-color="white"
>
{{ tag }}
</v-chip>
</div>
<span v-else>-</span>
</td>
<td>{{ item.createdAt }}</td>
<td>{{ item.updatedAt || '-' }}</td>
<td>
<v-row>
<v-col>
<v-btn
small
color="primary"
:disabled="is_loading"
@click="showEditDialog(item)"
>
수정
</v-btn>
</v-col>
<v-col>
<v-btn
small
color="info"
:disabled="is_loading"
@click="goToImageList(item)"
>
이미지
</v-btn>
</v-col>
<v-col>
<v-btn
small
color="error"
:disabled="is_loading"
@click="deleteConfirm(item)"
>
삭제
</v-btn>
</v-col>
</v-row>
</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-col>
</v-row>
<v-row class="text-center">
<v-col>
<v-pagination
v-model="page"
:length="total_page"
circle
@input="next"
/>
</v-col>
</v-row>
</v-container>
<!-- 삭제 확인 다이얼로그 -->
<v-dialog
v-model="show_delete_confirm_dialog"
max-width="400px"
persistent
>
<v-card>
<v-card-text />
<v-card-text>
"{{ selected_character.name }}"() 삭제하시겠습니까?
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="closeDeleteDialog"
>
취소
</v-btn>
<v-btn
color="red darken-1"
text
@click="deleteCharacter"
>
확인
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 상세 내용 다이얼로그 -->
<v-dialog
v-model="show_detail_dialog"
max-width="600px"
>
<v-card>
<v-card-title>
{{ detail_title }}
</v-card-title>
<v-divider />
<v-card-text class="pt-4">
<div style="white-space: pre-wrap;">
{{ detail_content }}
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="primary"
text
@click="closeDetailDialog"
>
닫기
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import { getCharacterList, updateCharacter, searchCharacterList } from '@/api/character'
export default {
name: "CharacterList",
data() {
return {
is_loading: false,
show_delete_confirm_dialog: false,
show_detail_dialog: false,
detail_type: '',
detail_content: '',
detail_title: '',
page: 1,
total_page: 0,
characters: [],
selected_character: {},
searchTerm: ''
}
},
async created() {
await this.getCharacters()
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
showDetailDialog(item, type) {
this.selected_character = item;
this.detail_type = type;
// 타입에 따라 제목과 내용 설정
switch(type) {
case 'description':
this.detail_title = '캐릭터 설명';
this.detail_content = item.description || '내용이 없습니다.';
break;
case 'speechPattern':
this.detail_title = '말투';
this.detail_content = item.speechPattern || '내용이 없습니다.';
break;
case 'speechStyle':
this.detail_title = '대화 스타일';
this.detail_content = item.speechStyle || '내용이 없습니다.';
break;
default:
this.detail_title = '';
this.detail_content = '';
}
this.show_detail_dialog = true;
},
closeDetailDialog() {
this.show_detail_dialog = false;
this.detail_type = '';
this.detail_content = '';
this.detail_title = '';
},
showAddDialog() {
// 페이지로 이동
this.$router.push('/character/form');
},
goToImageList(item) {
this.$router.push({
path: '/character/images',
query: { characterId: item.id, name: item.name }
})
},
showEditDialog(item) {
// 페이지로 이동하면서 id 전달
this.$router.push({
path: '/character/form',
query: { id: item.id }
});
},
deleteConfirm(item) {
this.selected_character = item
this.show_delete_confirm_dialog = true
},
closeDeleteDialog() {
this.show_delete_confirm_dialog = false
this.selected_character = {}
},
async deleteCharacter() {
if (this.is_loading) return;
this.is_loading = true
try {
// 삭제 대신 isActive를 false로 설정하여 비활성화
const updateData = {
id: this.selected_character.id,
isActive: false
};
await updateCharacter(updateData);
this.closeDeleteDialog();
this.notifySuccess('삭제되었습니다.');
await this.getCharacters();
} catch (e) {
console.error('캐릭터 삭제 오류:', e);
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
} finally {
this.is_loading = false;
}
},
async next() {
await this.getCharacters()
},
onSearch() {
this.page = 1;
this.getCharacters();
},
async getCharacters() {
this.is_loading = true
try {
const hasSearch = this.searchTerm && this.searchTerm.trim() !== '';
const response = hasSearch
? await searchCharacterList(this.searchTerm.trim(), this.page, 20)
: await getCharacterList(this.page);
if (response && response.status === 200) {
if (response.data.success === true) {
const data = response.data.data;
this.characters = data.content || [];
const total_page = Math.ceil((data.totalCount || 0) / 20);
this.total_page = total_page <= 0 ? 1 : total_page;
} else {
this.notifyError('응답 데이터가 없습니다.');
}
} else {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
}
} catch (e) {
console.error('캐릭터 목록 조회 오류:', e);
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
} finally {
this.is_loading = false;
}
},
}
}
</script>
<style scoped>
.v-data-table {
width: 100%;
}
</style>

View File

@@ -0,0 +1,356 @@
<template>
<div>
<v-toolbar dark>
<v-btn
icon
@click="goBack"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-spacer />
<v-toolbar-title>원작 상세</v-toolbar-title>
<v-spacer />
<v-btn
color="primary"
@click="openAssignDialog"
>
캐릭터 연결
</v-btn>
</v-toolbar>
<v-container>
<v-card
v-if="detail"
class="pa-4"
>
<v-row>
<v-col
cols="12"
md="4"
>
<v-img
:src="detail.imageUrl"
contain
height="240"
/>
</v-col>
<v-col
cols="12"
md="8"
>
<h2>{{ detail.title }}</h2>
<div class="mt-2">
콘텐츠 타입: {{ detail.contentType || '-' }}
</div>
<div>카테고리(장르): {{ detail.category || '-' }}</div>
<div>19 여부: {{ detail.isAdult ? '예' : '아니오' }}</div>
<div>원천 원작: {{ detail.originalWork || '-' }}</div>
<div class="mt-1">
원천 원작 링크:
<a
v-if="detail.originalLink"
:href="detail.originalLink"
target="_blank"
rel="noopener"
>{{ detail.originalLink }}</a>
<span v-else>-</span>
</div>
<div>/그림: {{ detail.writer || '-' }}</div>
<div>제작사: {{ detail.studio || '-' }}</div>
<div class="mt-1">
원작 링크:
<template v-if="detail.originalLinks && detail.originalLinks.length">
<div>
<div
v-for="(link, idx) in detail.originalLinks"
:key="idx"
>
<a
:href="link"
target="_blank"
rel="noopener"
>{{ link }}</a>
</div>
</div>
</template>
<span v-else>-</span>
</div>
<div class="mt-1">
태그:
<template v-if="detail.tags && detail.tags.length">
<v-chip
v-for="(t, i) in detail.tags"
:key="i"
small
class="mr-1 mb-1"
>
{{ t }}
</v-chip>
</template>
<span v-else>-</span>
</div>
<div class="mt-2">
작품 소개:
</div>
<div style="white-space:pre-wrap;">
{{ detail.description || '-' }}
</div>
</v-col>
</v-row>
</v-card>
<v-card class="pa-4 mt-6">
<div class="d-flex align-center mb-4">
<h3>연결된 캐릭터</h3>
<v-spacer />
</div>
<v-row>
<v-col
v-for="c in characters"
:key="c.id"
cols="12"
sm="6"
md="4"
lg="3"
>
<v-card>
<v-img
:src="c.imagePath"
height="180"
contain
/>
<v-card-title class="text-no-wrap">
{{ c.name }}
</v-card-title>
<v-card-actions>
<v-spacer />
<v-btn
small
color="error"
@click="unassign([c.id])"
>
해제
</v-btn>
<v-spacer />
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-row v-if="isLoadingCharacters">
<v-col class="text-center">
<v-progress-circular
indeterminate
color="primary"
/>
</v-col>
</v-row>
</v-card>
</v-container>
<v-dialog
v-model="assignDialog"
max-width="800"
>
<v-card>
<v-card-title>캐릭터 연결</v-card-title>
<v-card-text>
<v-text-field
v-model="searchKeyword"
label="캐릭터 검색"
outlined
dense
@input="onSearchInput"
/>
<v-data-table
v-model="selectedToAssign"
:headers="headers"
:items="searchResults"
:loading="searchLoading"
item-key="id"
show-select
:items-per-page="5"
>
<template v-slot:item.imageUrl="{ item }">
<v-img
:src="item.imagePath"
max-width="60"
max-height="60"
class="rounded-circle"
/>
</template>
</v-data-table>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
text
@click="assignDialog = false"
>
취소
</v-btn>
<v-btn
color="primary"
:disabled="selectedToAssign.length===0"
@click="assign"
>
연결
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import { getOriginal, getOriginalCharacters, assignCharactersToOriginal, unassignCharactersFromOriginal } from '@/api/original'
import { searchCharacters } from '@/api/character'
export default {
name: 'OriginalDetail',
data() {
return {
id: null,
detail: null,
characters: [],
page: 1,
hasMore: true,
isLoadingCharacters: false,
assignDialog: false,
searchKeyword: '',
searchLoading: false,
searchResults: [],
selectedToAssign: [],
headers: [
{ text: '이미지', value: 'imageUrl', sortable: false },
{ text: '이름', value: 'name' },
{ text: 'ID', value: 'id' }
],
debounceTimer: null
}
},
created() {
this.id = this.$route.query.id
if (!this.id) {
this.$dialog.notify.error('잘못된 접근입니다.');
this.$router.push('/original-work');
return;
}
this.loadDetail();
this.loadCharacters();
window.addEventListener('scroll', this.onScroll);
},
beforeDestroy() {
window.removeEventListener('scroll', this.onScroll);
},
methods: {
notifyError(message) { this.$dialog.notify.error(message) },
notifySuccess(message) { this.$dialog.notify.success(message) },
goBack() { this.$router.push('/original-work') },
async loadDetail() {
try {
const res = await getOriginal(this.id);
if (res.status === 200 && res.data.success === true) {
this.detail = res.data.data;
} else {
this.notifyError('상세 조회 실패');
}
} catch (e) {
this.notifyError('상세 조회 실패');
}
},
async loadCharacters() {
if (this.isLoadingCharacters || !this.hasMore) return;
this.isLoadingCharacters = true;
try {
const res = await getOriginalCharacters(this.id, this.page);
if (res.status === 200 && res.data.success === true) {
const content = res.data.data?.content || [];
this.characters = this.characters.concat(content);
this.hasMore = content.length > 0;
this.page++;
} else {
this.notifyError('캐릭터 목록 조회 실패');
}
} catch (e) {
this.notifyError('캐릭터 목록 조회 실패');
} finally {
this.isLoadingCharacters = false;
}
},
onScroll() {
const scrollPosition = window.innerHeight + window.scrollY;
const documentHeight = document.documentElement.offsetHeight;
if (scrollPosition >= documentHeight - 200 && !this.isLoadingCharacters && this.hasMore) {
this.loadCharacters();
}
},
openAssignDialog() {
this.assignDialog = true;
this.searchKeyword = '';
this.searchResults = [];
this.selectedToAssign = [];
},
onSearchInput() {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(this.search, 300);
},
async search() {
if (!this.searchKeyword || !this.searchKeyword.trim()) {
this.searchResults = [];
return;
}
this.searchLoading = true;
try {
const res = await searchCharacters(this.searchKeyword.trim(), 1, 20);
if (res.status === 200 && res.data.success === true) {
this.searchResults = res.data.data?.content || [];
} else {
this.notifyError('검색 실패');
}
} catch (e) {
this.notifyError('검색 실패');
} finally {
this.searchLoading = false;
}
},
async assign() {
if (this.selectedToAssign.length === 0) return;
try {
const ids = this.selectedToAssign.map(x => x.id);
const res = await assignCharactersToOriginal(this.id, ids);
if (res.status === 200 && res.data.success === true) {
this.notifySuccess('연결되었습니다.');
this.assignDialog = false;
// 목록 초기화 후 재조회
this.characters = [];
this.page = 1;
this.hasMore = true;
this.loadCharacters();
} else {
this.notifyError('연결 실패');
}
} catch (e) {
this.notifyError('연결 실패');
}
},
async unassign(ids) {
if (!ids || ids.length === 0) return;
try {
const res = await unassignCharactersFromOriginal(this.id, ids);
if (res.status === 200 && res.data.success === true) {
this.notifySuccess('해제되었습니다.');
this.characters = this.characters.filter(c => !ids.includes(c.id));
} else {
this.notifyError('해제 실패');
}
} catch (e) {
this.notifyError('해제 실패');
}
}
}
}
</script>
<style scoped>
.text-no-wrap { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>

View File

@@ -0,0 +1,505 @@
<template>
<div>
<v-toolbar dark>
<v-btn
icon
@click="goBack"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-spacer />
<v-toolbar-title>{{ isEdit ? '원작 수정' : '원작 등록' }}</v-toolbar-title>
<v-spacer />
</v-toolbar>
<v-container>
<v-card class="pa-4">
<v-form
ref="form"
v-model="isFormValid"
>
<v-card-text>
<v-row>
<v-col
cols="12"
md="6"
>
<v-file-input
v-model="form.image"
label="이미지"
accept="image/*"
prepend-icon="mdi-camera"
outlined
dense
:class="{ 'required-asterisk': !isEdit }"
:rules="imageRules"
/>
</v-col>
<v-col
v-if="previewImage || form.imageUrl"
cols="12"
md="6"
>
<div class="text-center">
<v-avatar size="150">
<v-img
:src="previewImage || form.imageUrl"
contain
/>
</v-avatar>
</div>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-text-field
v-model="form.title"
label="제목"
outlined
dense
:rules="[v=>!!v||'제목은 필수입니다']"
class="required-asterisk"
/>
</v-col>
</v-row>
<v-row>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="form.contentType"
label="콘텐츠 타입"
outlined
dense
:rules="contentTypeRules"
:class="{ 'required-asterisk': !isEdit }"
/>
</v-col>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="form.category"
label="카테고리(장르)"
outlined
dense
:rules="categoryRules"
:class="{ 'required-asterisk': !isEdit }"
/>
</v-col>
</v-row>
<!-- 추가 메타 정보 (요구 순서: /그림, 제작사, 원천원작, 원천 원작 링크) -->
<v-row>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="form.writer"
label="글/그림"
outlined
dense
/>
</v-col>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="form.studio"
label="제작사"
outlined
dense
/>
</v-col>
</v-row>
<v-row>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="form.originalWork"
label="원천 원작"
outlined
dense
/>
</v-col>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="form.originalLink"
label="원천 원작 링크"
outlined
dense
:rules="originalLinkRules"
:class="{ 'required-asterisk': !isEdit }"
/>
</v-col>
</v-row>
<v-row>
<v-col
cols="12"
md="12"
>
<v-switch
v-model="form.isAdult"
label="19금 여부"
inset
/>
</v-col>
</v-row>
<!-- 원작 링크(여러 ) 추가 -->
<v-row>
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="mb-2">
원작 링크
</h3>
<v-row>
<v-col cols="11">
<v-text-field
v-model="newOriginalLink"
label="원작 링크 추가"
outlined
dense
@keyup.enter="addOriginalLink"
/>
</v-col>
<v-col cols="1">
<v-btn
color="primary"
class="mt-1"
block
:disabled="!newOriginalLink || !newOriginalLink.trim()"
@click="addOriginalLink"
>
추가
</v-btn>
</v-col>
</v-row>
<v-card
outlined
class="mt-2"
>
<v-list v-if="form.originalLinks && form.originalLinks.length > 0">
<v-list-item
v-for="(link, idx) in form.originalLinks"
:key="idx"
>
<v-list-item-content>
<v-list-item-title class="text-truncate">
{{ link }}
</v-list-item-title>
</v-list-item-content>
<v-list-item-action>
<v-btn
small
color="error"
@click="removeOriginalLink(idx)"
>
삭제
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list>
<v-card-text
v-else
class="grey--text"
>
추가된 원작 링크가 없습니다.
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 태그 -->
<v-row>
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="mb-2">
태그
</h3>
<v-combobox
v-model="form.tags"
multiple
chips
small-chips
deletable-chips
outlined
dense
label="태그를 입력 후 엔터로 추가"
@keydown.space.prevent="onTagSpace"
>
<template v-slot:selection="{ attrs, item, select, selected }">
<v-chip
v-bind="attrs"
:input-value="selected"
close
@click="select"
@click:close="removeTag(item)"
>
{{ item }}
</v-chip>
</template>
</v-combobox>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-textarea
v-model="form.description"
label="작품 소개"
outlined
rows="4"
:rules="descriptionRules"
:class="{ 'required-asterisk': !isEdit }"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="primary"
:disabled="!canSubmit"
@click="onSubmit"
>
{{ isEdit ? '수정' : '등록' }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-container>
</div>
</template>
<script>
import { createOriginal, updateOriginal, getOriginal } from '@/api/original'
export default {
name: 'OriginalForm',
data() {
return {
isEdit: false,
isFormValid: false,
previewImage: null,
newOriginalLink: '',
form: {
id: null,
image: null,
imageUrl: null,
title: '',
contentType: '',
category: '',
isAdult: false,
description: '',
originalLink: '', // 원천 원작 링크(파라미터명 유지)
originalWork: '',
writer: '',
studio: '',
originalLinks: [], // 추가 원작 링크들
tags: []
},
originalInitial: null,
imageRules: [v => (this.isEdit ? true : (!!v || '이미지를 선택하세요'))],
contentTypeRules: [v => (this.isEdit ? true : (!!(v && v.toString().trim()) || '콘텐츠 타입은 필수입니다'))],
categoryRules: [v => (this.isEdit ? true : (!!(v && v.toString().trim()) || '카테고리는 필수입니다'))],
originalLinkRules: [v => (this.isEdit ? true : (!!(v && v.toString().trim()) || '원천 원작 링크는 필수입니다'))],
descriptionRules: [v => (this.isEdit ? true : (!!(v && v.toString().trim()) || '작품 소개는 필수입니다'))]
}
},
computed: {
imageChanged() {
return !!this.form.image;
},
hasNonImageChanges() {
if (!this.isEdit || !this.originalInitial) return false;
const fields = ['title', 'contentType', 'category', 'isAdult', 'description', 'originalLink', 'originalWork', 'writer', 'studio'];
const basicChanged = fields.some(f => this.form[f] !== this.originalInitial[f]);
const arraysChanged = !this.arraysEqual(this.form.originalLinks, this.originalInitial.originalLinks)
|| !this.arraysEqual(this.form.tags, this.originalInitial.tags);
return basicChanged || arraysChanged;
},
hasEditChanges() {
return this.imageChanged || this.hasNonImageChanges;
},
canSubmit() {
if (this.isEdit) return this.hasEditChanges && !!(this.form.title && this.form.title.toString().trim());
const required = [this.form.image, this.form.title, this.form.contentType, this.form.category, this.form.originalLink, this.form.description];
return required.every(v => !!(v && (v.toString ? v.toString().trim() : v)));
}
},
watch: {
'form.image': {
handler(newImage) {
if (newImage) {
const reader = new FileReader();
reader.onload = (e) => { this.previewImage = e.target.result }
reader.readAsDataURL(newImage)
} else {
this.previewImage = null
}
}
}
},
created() {
if (this.$route.query.id) {
this.isEdit = true;
this.load(this.$route.query.id);
}
},
methods: {
notifyError(message) { this.$dialog.notify.error(message) },
notifySuccess(message) { this.$dialog.notify.success(message) },
goBack() { this.$router.push('/original-work') },
arraysEqual(a, b) {
const arrA = Array.isArray(a) ? a : [];
const arrB = Array.isArray(b) ? b : [];
if (arrA.length !== arrB.length) return false;
for (let i = 0; i < arrA.length; i++) {
if (arrA[i] !== arrB[i]) return false;
}
return true;
},
addOriginalLink() {
if (!this.newOriginalLink || !this.newOriginalLink.trim()) return;
const val = this.newOriginalLink.trim();
if (!this.form.originalLinks) this.form.originalLinks = [];
if (!this.form.originalLinks.includes(val)) {
this.form.originalLinks.push(val);
}
this.newOriginalLink = '';
},
removeOriginalLink(index) {
if (!this.form.originalLinks) return;
this.form.originalLinks.splice(index, 1);
},
onTagSpace() {
// CharacterForm의 태그 방식과 유사: 마지막 항목을 공백 기준으로 확정
if (!Array.isArray(this.form.tags)) this.form.tags = [];
const last = this.form.tags[this.form.tags.length - 1];
if (typeof last === 'string' && last.trim()) {
let processed = last.trim().replace(/\s+/g, '');
if (processed.length > 50) processed = processed.substring(0, 50);
this.form.tags.splice(this.form.tags.length - 1, 1, processed);
this.$nextTick(() => this.form.tags.push(''));
}
},
removeTag(item) {
if (!Array.isArray(this.form.tags)) return;
const idx = this.form.tags.indexOf(item);
if (idx >= 0) this.form.tags.splice(idx, 1);
},
async load(id) {
try {
const res = await getOriginal(id);
if (res.status === 200 && res.data.success === true) {
const d = res.data.data;
this.form = {
id: d.id,
image: null,
imageUrl: d.imageUrl,
title: d.title || '',
contentType: d.contentType || '',
category: d.category || '',
isAdult: !!d.isAdult,
description: d.description || '',
originalLink: d.originalLink || '',
originalWork: d.originalWork || '',
writer: d.writer || '',
studio: d.studio || '',
originalLinks: Array.isArray(d.originalLinks) ? d.originalLinks.slice() : [],
tags: Array.isArray(d.tags) ? d.tags.slice() : []
}
this.originalInitial = {
id: d.id,
imageUrl: d.imageUrl,
title: d.title || '',
contentType: d.contentType || '',
category: d.category || '',
isAdult: !!d.isAdult,
description: d.description || '',
originalLink: d.originalLink || '',
originalWork: d.originalWork || '',
writer: d.writer || '',
studio: d.studio || '',
originalLinks: Array.isArray(d.originalLinks) ? d.originalLinks.slice() : [],
tags: Array.isArray(d.tags) ? d.tags.slice() : []
}
} else {
this.notifyError('상세 조회 실패');
}
} catch (e) {
this.notifyError('상세 조회 실패');
}
},
async onSubmit() {
try {
const isValid = this.$refs.form ? this.$refs.form.validate() : true;
if (!isValid) {
this.notifyError(this.isEdit ? '입력을 확인해주세요.' : '필수 항목을 모두 입력해주세요.');
return;
}
if (this.isEdit) {
const fields = ['title', 'contentType', 'category', 'isAdult', 'description', 'originalLink', 'originalWork', 'writer', 'studio'];
const patch = { id: this.form.id };
if (this.originalInitial) {
fields.forEach(f => {
if (this.form[f] !== this.originalInitial[f]) {
patch[f] = this.form[f];
}
});
if (!this.arraysEqual(this.form.originalLinks, this.originalInitial.originalLinks)) {
patch.originalLinks = this.form.originalLinks;
}
if (!this.arraysEqual(this.form.tags, this.originalInitial.tags)) {
patch.tags = this.form.tags;
}
}
const image = this.form.image || null;
if (Object.keys(patch).length === 1 && !image) {
this.notifyError('변경된 내용이 없습니다.');
return;
}
const res = await updateOriginal(patch, image);
if (res.status === 200 && res.data.success === true) {
this.notifySuccess('수정되었습니다.');
this.$router.push('/original-work');
} else {
this.notifyError('수정 실패');
}
} else {
const res = await createOriginal(this.form);
if (res.status === 200 && res.data.success === true) {
this.notifySuccess('등록되었습니다.');
this.$router.push('/original-work');
} else {
this.notifyError('등록 실패');
}
}
} catch (e) {
this.notifyError(this.isEdit ? '수정 실패' : '등록 실패');
}
}
}
}
</script>
<style scoped>
.required-asterisk >>> .v-label::after { content: ' *'; color: #ff5252; }
</style>

View File

@@ -0,0 +1,205 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>원작 리스트</v-toolbar-title>
<v-spacer />
<v-btn
color="primary"
dark
@click="goToCreate"
>
원작 등록
</v-btn>
</v-toolbar>
<v-container>
<v-row v-if="isLoading && originals.length === 0">
<v-col class="text-center">
<v-progress-circular
indeterminate
color="primary"
size="64"
/>
</v-col>
</v-row>
<v-row>
<v-col
v-for="item in originals"
:key="item.id"
cols="12"
sm="6"
md="4"
lg="3"
>
<v-card
class="mx-auto"
max-width="344"
style="cursor:pointer;"
@click="openDetail(item)"
>
<v-img
:src="item.imageUrl"
height="200"
contain
/>
<v-card-title class="text-no-wrap">
{{ item.title }}
</v-card-title>
<v-card-actions>
<v-spacer />
<v-btn
small
color="primary"
@click.stop="editOriginal(item)"
>
수정
</v-btn>
<v-btn
small
color="error"
@click.stop="confirmDelete(item)"
>
삭제
</v-btn>
<v-spacer />
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-row v-if="!isLoading && originals.length === 0">
<v-col class="text-center">
데이터가 없습니다.
</v-col>
</v-row>
<v-row v-if="isLoading && originals.length > 0">
<v-col class="text-center">
<v-progress-circular
indeterminate
color="primary"
/>
</v-col>
</v-row>
</v-container>
<v-dialog
v-model="deleteDialog"
max-width="400"
>
<v-card>
<v-card-title class="headline">
삭제 확인
</v-card-title>
<v-card-text>정말 삭제하시겠습니까?</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
text
@click="deleteDialog = false"
>
취소
</v-btn>
<v-btn
color="error"
text
@click="deleteItem"
>
삭제
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import { getOriginalList, deleteOriginal } from '@/api/original'
export default {
name: 'OriginalList',
data() {
return {
isLoading: false,
originals: [],
page: 1,
hasMore: true,
deleteDialog: false,
selected: null
}
},
mounted() {
this.loadMore();
window.addEventListener('scroll', this.handleScroll);
},
beforeDestroy() {
window.removeEventListener('scroll', this.handleScroll);
},
methods: {
notifyError(message) { this.$dialog.notify.error(message) },
notifySuccess(message) { this.$dialog.notify.success(message) },
async loadMore() {
if (this.isLoading || !this.hasMore) return;
this.isLoading = true;
try {
const res = await getOriginalList(this.page);
if (res.status === 200 && res.data.success === true) {
const content = res.data.data?.content || [];
this.originals = this.originals.concat(content);
this.hasMore = content.length > 0;
this.page++;
} else {
this.notifyError('원작 목록 조회 실패');
}
} catch (e) {
this.notifyError('원작 목록 조회 실패');
} finally {
this.isLoading = false;
}
},
handleScroll() {
const scrollPosition = window.innerHeight + window.scrollY;
const documentHeight = document.documentElement.offsetHeight;
if (scrollPosition >= documentHeight - 200 && !this.isLoading && this.hasMore) {
this.loadMore();
}
},
goToCreate() {
this.$router.push('/original-work/form');
},
editOriginal(item) {
this.$router.push({ path: '/original-work/form', query: { id: item.id } });
},
openDetail(item) {
this.$router.push({ path: '/original-work/detail', query: { id: item.id } });
},
confirmDelete(item) {
this.selected = item;
this.deleteDialog = true;
},
async deleteItem() {
if (!this.selected) return;
try {
const res = await deleteOriginal(this.selected.id);
if (res.status === 200 && res.data.success === true) {
this.notifySuccess('삭제되었습니다.');
this.originals = this.originals.filter(x => x.id !== this.selected.id);
} else {
this.notifyError('삭제 실패');
}
} catch (e) {
this.notifyError('삭제 실패');
} finally {
this.deleteDialog = false;
this.selected = null;
}
}
}
}
</script>
<style scoped>
.text-no-wrap { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>

View File

@@ -10,11 +10,25 @@
<v-container>
<v-row>
<v-col cols="10" />
<v-col cols="9">
<v-radio-group
v-model="selected_tab_id"
row
@change="getCurations"
>
<v-radio
v-for="tab in tabs"
:key="tab.tabId"
:label="tab.title"
:value="tab.tabId"
/>
</v-radio-group>
</v-col>
<v-spacer />
<v-col>
<v-btn
block
color="#9970ff"
color="#3bb9f1"
dark
depressed
@click="showWriteDialog"
@@ -45,12 +59,24 @@
v-for="(item, index) in props.items"
:key="index"
>
<td>
<td
@click="handleItemClick(item)"
>
{{ item.title }}
</td>
<td>
<td
@click="handleItemClick(item)"
>
{{ item.description }}
</td>
<td>
<h3 v-if="item.isSeries">
O
</h3>
<h3 v-else>
X
</h3>
</td>
<td>
<h3 v-if="item.isAdult">
O
@@ -103,6 +129,26 @@
<v-card-title v-else>
콘텐츠 큐레이션 등록
</v-card-title>
<v-card-text v-if="is_modify === false">
<v-row align="center">
<v-col cols="4">
메인
</v-col>
<v-col cols="8">
<v-radio-group
v-model="curation.tab_id"
row
>
<v-radio
v-for="tab in tabs"
:key="tab.tabId"
:label="tab.title"
:value="tab.tabId"
/>
</v-radio-group>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
@@ -131,6 +177,19 @@
</v-col>
</v-row>
</v-card-text>
<v-card-text v-if="is_modify === false">
<v-row>
<v-col cols="4">
시리즈 큐레이션
</v-col>
<v-col cols="8">
<input
v-model="curation.is_series"
type="checkbox"
>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row>
<v-col cols="4">
@@ -220,8 +279,10 @@ export default {
show_delete_confirm_dialog: false,
show_write_dialog: false,
selected_curation: {},
curation: {is_adult: false},
curation: {is_adult: false, is_series: false},
curations: [],
tabs: [],
selected_tab_id: 1,
headers: [
{
text: '제목',
@@ -235,6 +296,12 @@ export default {
sortable: false,
value: 'description',
},
{
text: '시리즈 큐레이션',
align: 'center',
sortable: false,
value: 'isSeries',
},
{
text: '19금',
align: 'center',
@@ -252,7 +319,7 @@ export default {
},
async created() {
await this.getCurations()
await this.getAudioContentMainTabList()
},
methods: {
@@ -273,22 +340,48 @@ export default {
this.selected_curation = item
this.curation.id = item.id
this.curation.tab_id = item.tabId
this.curation.title = item.title
this.curation.description = item.description
this.curation.is_series = item.isSeries
this.curation.is_adult = item.isAdult
this.show_write_dialog = true
},
cancel() {
this.curation = {is_adult: false}
this.curation = {is_adult: false, is_series: false}
this.selected_curation = {}
this.is_modify = false
this.show_write_dialog = false
},
handleItemClick(item) {
this.$router.push(
{
name: 'ContentCurationDetail',
params: {
curation_id: item.id,
title: item.title,
description: item.description,
is_series: item.isSeries,
is_adult: item.isAdult
}
}
)
},
validate() {
if (
this.curation.tab_id === null ||
this.curation.tab_id === undefined ||
this.curation.tab_id <= 0
) {
this.notifyError("메인 탭을 선택하세요")
return false
}
if (
this.curation.title === null ||
this.curation.title === undefined ||
@@ -320,6 +413,27 @@ export default {
this.show_delete_confirm_dialog = false
},
async getAudioContentMainTabList() {
this.is_loading = true
try {
const res = await api.getAudioContentMainTabList()
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
this.tabs = data.filter(item => item.title !== '홈')
this.selected_tab_id = this.tabs[0].tabId
await this.getCurations()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async submit() {
if (!this.validate()) return;
if (this.is_loading) return;
@@ -328,8 +442,10 @@ export default {
try {
const request = {
tabId: this.curation.tab_id,
title: this.curation.title,
description: this.curation.description,
isSeries: this.curation.is_series,
isAdult: this.curation.is_adult
}
@@ -357,6 +473,10 @@ export default {
try {
let request = {id: this.curation.id}
if (this.selected_curation.tab_id !== this.curation.tab_id) {
request.tabId = this.curation.tab_id
}
if (this.selected_curation.title !== this.curation.title && this.curation.title.trim().length > 0) {
request.title = this.curation.title
}
@@ -368,6 +488,10 @@ export default {
request.description = this.curation.description
}
if (this.selected_curation.isSeries !== this.curation.is_series) {
request.isSeries = this.curation.is_series
}
if (this.selected_curation.isAdult !== this.curation.is_adult) {
request.isAdult = this.curation.is_adult
}
@@ -439,7 +563,7 @@ export default {
this.is_loading = true
try {
const res = await api.getCurations()
const res = await api.getCurations(this.selected_tab_id)
if (res.status === 200 && res.data.success === true) {
this.curations = res.data.data
} else {

View File

@@ -0,0 +1,630 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>{{ curation_title }}</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-container>
<v-row>
<v-col
cols="4"
align="right"
>
19 :
</v-col>
<v-col
align="left"
>
<div v-if="is_adult">
O
</div>
<div v-else>
X
</div>
</v-col>
<v-spacer />
<v-col>
<v-btn
v-if="is_series"
block
color="#3bb9f1"
dark
depressed
@click="showAddSeries"
>
시리즈 등록
</v-btn>
<v-btn
v-else
block
color="#3bb9f1"
dark
depressed
@click="showAddContent"
>
콘텐츠 등록
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col
cols="4"
align="right"
>
내용 :
</v-col>
<v-col
cols="8"
align="left"
>
<vue-show-more-text
:style="{ padding: '0' }"
:text="curation_description"
:lines="2"
/>
</v-col>
</v-row>
<v-row>
<v-col>
<v-simple-table class="elevation-10">
<template>
<thead>
<tr>
<th class="text-center">
썸네일
</th>
<th class="text-center">
제목
</th>
<th class="text-center">
내용
</th>
<th class="text-center">
크리에이터
</th>
<th class="text-center">
19
</th>
<th class="text-center">
관리
</th>
</tr>
</thead>
<draggable
v-model="items"
tag="tbody"
@end="onDropCallback(items)"
>
<tr
v-for="item in items"
:key="item.id"
>
<td align="center">
<v-img
max-width="70"
max-height="70"
:src="item.coverImageUrl"
class="rounded-circle"
/>
</td>
<td>
<vue-show-more-text
:text="item.title"
:lines="3"
/>
</td>
<td style="max-width: 200px !important; word-break:break-all; height: auto;">
<vue-show-more-text
:text="item.desc"
:lines="3"
/>
</td>
<td>{{ item.creatorNickname }}</td>
<td>
<div v-if="item.isAdult">
O
</div>
<div v-else>
X
</div>
</td>
<td>
<v-btn
:disabled="is_loading"
@click="deleteConfirm(item)"
>
삭제
</v-btn>
</td>
</tr>
</draggable>
</template>
</v-simple-table>
</v-col>
</v-row>
</v-container>
<v-dialog
v-model="show_add_content_dialog"
max-width="1000px"
persistent
>
<v-card>
<v-card-title>
콘텐츠 추가
</v-card-title>
<v-card-text>
<v-text-field
v-model="search_word"
label="콘텐츠 제목"
@keyup.enter="searchContentItem"
>
<v-btn
slot="append"
color="#3bb9f1"
dark
@click="searchContentItem"
>
검색
</v-btn>
</v-text-field>
</v-card-text>
<v-card-text v-if="search_item_list.length > 0 || add_item_list.length > 0">
<v-row>
<v-col>
검색결과
<v-simple-table>
<template v-slot:default>
<thead>
<tr>
<th class="text-center">
제목
</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="item in search_item_list"
:key="item.id"
>
<td>{{ item.title }}</td>
<td>
<v-btn
color="#3bb9f1"
@click="addItem(item)"
>
추가
</v-btn>
</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-col>
<v-col v-if="add_item_list.length > 0">
추가할 콘텐츠
<v-simple-table>
<template>
<thead>
<tr>
<th class="text-center">
제목
</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="item in add_item_list"
:key="item.id"
>
<td>{{ item.title }}</td>
<td>
<v-btn
color="#3bb9f1"
@click="removeItem(item)"
>
제거
</v-btn>
</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-col>
</v-row>
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="cancel"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
@click="addItemInCuration"
>
추가
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog
v-model="show_add_series_dialog"
max-width="1000px"
persistent
>
<v-card>
<v-card-title>
시리즈 추가
</v-card-title>
<v-card-text>
<v-text-field
v-model="search_word"
label="시리즈 제목"
@keyup.enter="searchSeriesItem"
>
<v-btn
slot="append"
color="#3bb9f1"
dark
@click="searchSeriesItem"
>
검색
</v-btn>
</v-text-field>
</v-card-text>
<v-card-text v-if="search_item_list.length > 0 || add_item_list.length > 0">
<v-row>
<v-col>
검색결과
<v-simple-table>
<template v-slot:default>
<thead>
<tr>
<th class="text-center">
제목
</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="item in search_item_list"
:key="item.id"
>
<td>{{ item.title }}</td>
<td>
<v-btn
color="#3bb9f1"
@click="addItem(item)"
>
추가
</v-btn>
</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-col>
<v-col v-if="add_item_list.length > 0">
추가할 시리즈
<v-simple-table>
<template>
<thead>
<tr>
<th class="text-center">
제목
</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="item in add_item_list"
:key="item.id"
>
<td>{{ item.title }}</td>
<td>
<v-btn
color="#3bb9f1"
@click="removeItem(item)"
>
제거
</v-btn>
</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-col>
</v-row>
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="cancel"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
@click="addItemInCuration"
>
추가
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog
v-model="show_delete_confirm_dialog"
max-width="400px"
persistent
>
<v-card>
<v-card-text />
<v-card-text v-if="selected_item !== null">
{{ selected_item.title }} 삭제하시겠습니까?
</v-card-text>
<v-card-text v-else>
삭제하시겠습니까?
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="cancel"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
@click="removeItemInCuration"
>
확인
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import Draggable from 'vuedraggable';
import * as api from "@/api/audio_content"
import VueShowMoreText from 'vue-show-more-text'
export default {
name: 'ContentCurationDetail',
components: {VueShowMoreText, Draggable},
data() {
return {
is_loading: false,
curation_id: 0,
curation_title: '',
curation_description: '',
is_series: false,
is_adult: false,
items: [],
show_add_series_dialog: false,
show_add_content_dialog: false,
show_delete_confirm_dialog: false,
search_word: '',
selected_item: null,
add_item_list: [],
search_item_list: [],
}
},
async created() {
this.curation_id = this.$route.params.curation_id
this.curation_title = this.$route.params.title
this.curation_description = this.$route.params.description
this.is_series = this.$route.params.is_series
this.is_adult = this.$route.params.is_adult
await this.getCurationItems()
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
cancel() {
this.search_word = ''
this.add_item_list = []
this.search_item_list = []
this.selected_item = null
this.show_add_series_dialog = false
this.show_add_content_dialog = false
this.show_delete_confirm_dialog = false
},
deleteConfirm(item) {
this.selected_item = item
this.show_delete_confirm_dialog = true
},
showAddContent() {
this.show_add_content_dialog = true
},
showAddSeries() {
this.show_add_series_dialog = true
},
addItem(item) {
this.search_item_list = this.search_item_list.filter((t) => {
return t.id !== item.id
});
this.add_item_list.push(item)
},
removeItem(item) {
this.add_item_list = this.add_item_list.filter((t) => {
return t.id !== item.id
});
this.search_item_list.push(item)
},
async onDropCallback(items) {
const ids = items.map((item) => {
return item.id
})
try {
this.is_loading = true
const res = await api.updateItemInCurationOrders(this.curation_id, ids)
if (res.status === 200 && res.data.success === true) {
this.notifySuccess(res.data.message)
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async searchContentItem() {
if (this.search_word.length < 2) {
this.notifyError('검색어를 2글자 이상 입력하세요.')
return
}
this.is_loading = true
try {
const res = await api.searchContentItem(this.curation_id, this.search_word)
if (res.data.success === true) {
this.search_item_list = res.data.data
if (res.data.data.length <= 0) {
this.notifyError('검색결과가 없습니다.')
}
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async searchSeriesItem() {
if (this.search_word.length < 2) {
this.notifyError('검색어를 2글자 이상 입력하세요.')
return
}
this.is_loading = true
try {
const res = await api.searchSeriesItem(this.curation_id, this.search_word)
if (res.data.success === true) {
this.search_item_list = res.data.data
if (res.data.data.length <= 0) {
this.notifyError('검색결과가 없습니다.')
}
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async addItemInCuration() {
this.is_loading = true
const itemIdList = this.add_item_list.map((item) => {
return item.id
})
try {
const res = await api.addItemToCuration(this.curation_id, itemIdList)
if (res.status === 200 && res.data.success === true) {
this.cancel()
await this.getCurationItems()
} else {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async removeItemInCuration() {
this.is_loading = true
try {
const res = await api.removeItemInCuration(this.curation_id, this.selected_item.id)
if (res.status === 200 && res.data.success === true) {
this.cancel()
await this.getCurationItems()
} else {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async getCurationItems() {
this.is_loading = true
try {
const res = await api.getCurationItems(this.curation_id)
if (res.status === 200 && res.data.success === true) {
this.items = res.data.data
console.log(this.items)
} else {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
},
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,429 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>태그 큐레이션</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-container>
<v-row>
<v-spacer />
<v-col cols="3">
<v-btn
block
color="#3bb9f1"
dark
depressed
@click="showWriteDialog"
>
태그 등록
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col>
<v-data-table
:headers="headers"
:items="curations"
:loading="is_loading"
item-key="id"
class="elevation-1"
hide-default-footer
disable-pagination
>
<template v-slot:body="props">
<draggable
v-model="props.items"
tag="tbody"
@end="onDropCallback(props.items)"
>
<tr
v-for="(item, index) in props.items"
:key="index"
>
<td
@click="handleItemClick(item)"
>
{{ item.tag }}
</td>
<td>
<h3 v-if="item.isAdult">
O
</h3>
<h3 v-else>
X
</h3>
</td>
<td>
<v-row>
<v-col />
<v-col>
<v-btn
:disabled="is_loading"
@click="showModifyDialog(item)"
>
수정
</v-btn>
</v-col>
<v-col>
<v-btn
:disabled="is_loading"
@click="deleteConfirm(item)"
>
삭제
</v-btn>
</v-col>
<v-col />
</v-row>
</td>
</tr>
</draggable>
</template>
</v-data-table>
</v-col>
</v-row>
</v-container>
<v-row>
<v-dialog
v-model="show_write_dialog"
max-width="1000px"
persistent
>
<v-card>
<v-card-title v-if="is_modify === true">
태그 큐레이션 수정
</v-card-title>
<v-card-title v-else>
태그 큐레이션 등록
</v-card-title>
<v-card-text>
<v-row align="center">
<v-col cols="4">
태그
</v-col>
<v-col cols="8">
<v-text-field
v-model="curation.tag"
label="태그"
required
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row>
<v-col cols="4">
19
</v-col>
<v-col cols="8">
<input
v-model="curation.is_adult"
type="checkbox"
>
</v-col>
</v-row>
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="cancel"
>
취소
</v-btn>
<v-btn
v-if="is_modify === true"
color="blue darken-1"
text
@click="modify"
>
수정
</v-btn>
<v-btn
v-else
color="blue darken-1"
text
@click="submit"
>
등록
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-row>
<v-dialog
v-model="show_delete_confirm_dialog"
max-width="400px"
persistent
>
<v-card>
<v-card-text />
<v-card-text>
"{{ selected_curation.tag }}" 삭제하시겠습니까?
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="deleteCancel"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
@click="deleteCuration"
>
확인
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import Draggable from 'vuedraggable';
import * as api from "@/api/audio_content"
export default {
name: "ContentHashTagCuration",
components: {Draggable},
data() {
return {
is_loading: false,
is_modify: false,
show_delete_confirm_dialog: false,
show_write_dialog: false,
selected_curation: {},
curation: {is_adult: false},
curations: [],
headers: [
{
text: '태그',
align: 'center',
sortable: false,
value: 'tag',
},
{
text: '19금',
align: 'center',
sortable: false,
value: 'isAdult',
},
{
text: '관리',
align: 'center',
sortable: false,
value: 'management'
},
],
}
},
async created() {
await this.getHashTagCurations();
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
showWriteDialog() {
this.show_write_dialog = true
},
showModifyDialog(item) {
this.is_modify = true
this.selected_curation = item
this.curation.id = item.id
this.curation.tag = item.tag
this.curation.is_adult = item.isAdult
this.show_write_dialog = true
},
cancel() {
this.curation = {is_adult: false}
this.selected_curation = {}
this.is_modify = false
this.show_write_dialog = false
},
handleItemClick(item) {
this.$router.push(
{
name: 'ContentHashTagCurationDetail',
params: {
curation_id: item.id,
tag: item.tag,
is_adult: item.isAdult
}
}
)
},
validate() {
if (
this.curation.tag === null ||
this.curation.tag === undefined ||
this.curation.tag.trim().length <= 0
) {
this.notifyError("태그를 입력하세요")
return false
}
return true
},
deleteConfirm(curation) {
this.selected_curation = curation
this.show_delete_confirm_dialog = true
},
deleteCancel() {
this.selected_curation = {}
this.show_delete_confirm_dialog = false
},
async getHashTagCurations() {
this.is_loading = true
try {
const res = await api.getHashTagCurations()
if (res.status === 200 && res.data.success === true) {
this.curations = res.data.data
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async submit() {
if (!this.validate()) return;
if (this.is_loading) return;
this.isLoading = true
try {
const request = {
tag: this.curation.tag,
isAdult: this.curation.is_adult
}
const res = await api.saveHashTagCuration(request)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess('등록되었습니다.')
this.curations = []
await this.getHashTagCurations()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async modify() {
if (!this.validate()) return;
if (this.is_loading) return;
this.isLoading = true
try {
let request = {id: this.curation.id}
if (this.selected_curation.tag !== this.curation.tag && this.curation.tag.trim().length > 0) {
request.tag = this.curation.tag
}
if (this.selected_curation.isAdult !== this.curation.is_adult) {
request.isAdult = this.curation.is_adult
}
const res = await api.modifyHashTagCuration(request)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess('수정되었습니다.')
this.curations = []
await this.getHashTagCurations()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async deleteCuration() {
if (this.is_loading) return;
this.is_loading = true
try {
let request = {id: this.selected_curation.id, isActive: false}
const res = await api.modifyHashTagCuration(request)
if (res.status === 200 && res.data.success === true) {
this.show_delete_confirm_dialog = false
this.cancel()
this.notifySuccess('삭제되었습니다.')
this.curations = []
await this.getHashTagCurations()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async onDropCallback(items) {
this.curations = items
const ids = items.map((item) => {
return item.id
})
try {
this.is_loading = true
const res = await api.updateHashTagCurationOrders(ids)
if (res.status === 200 && res.data.success === true) {
this.notifySuccess(res.data.message)
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
}
}
</script>

View File

@@ -0,0 +1,450 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>{{ tag }}</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-container>
<v-row>
<v-col
cols="4"
align="right"
>
19 :
</v-col>
<v-col
align="left"
>
<div v-if="is_adult">
O
</div>
<div v-else>
X
</div>
</v-col>
<v-spacer />
<v-col>
<v-btn
block
color="#3bb9f1"
dark
depressed
@click="showAddContent"
>
콘텐츠 등록
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col>
<v-simple-table class="elevation-10">
<template>
<thead>
<tr>
<th class="text-center">
썸네일
</th>
<th class="text-center">
제목
</th>
<th class="text-center">
내용
</th>
<th class="text-center">
크리에이터
</th>
<th class="text-center">
19
</th>
<th class="text-center">
관리
</th>
</tr>
</thead>
<draggable
v-model="items"
tag="tbody"
@end="onDropCallback(items)"
>
<tr
v-for="item in items"
:key="item.id"
>
<td align="center">
<v-img
max-width="70"
max-height="70"
:src="item.coverImageUrl"
class="rounded-circle"
/>
</td>
<td>
<vue-show-more-text
:text="item.title"
:lines="3"
/>
</td>
<td style="max-width: 200px !important; word-break:break-all; height: auto;">
<vue-show-more-text
:text="item.desc"
:lines="3"
/>
</td>
<td>{{ item.creatorNickname }}</td>
<td>
<div v-if="item.isAdult">
O
</div>
<div v-else>
X
</div>
</td>
<td>
<v-btn
:disabled="is_loading"
@click="deleteConfirm(item)"
>
삭제
</v-btn>
</td>
</tr>
</draggable>
</template>
</v-simple-table>
</v-col>
</v-row>
</v-container>
<v-dialog
v-model="show_add_content_dialog"
max-width="1000px"
persistent
>
<v-card>
<v-card-title>
콘텐츠 추가
</v-card-title>
<v-card-text>
<v-text-field
v-model="search_word"
label="콘텐츠 제목"
@keyup.enter="searchContentItem"
>
<v-btn
slot="append"
color="#3bb9f1"
dark
@click="searchContentItem"
>
검색
</v-btn>
</v-text-field>
</v-card-text>
<v-card-text v-if="search_item_list.length > 0 || add_item_list.length > 0">
<v-row>
<v-col>
검색결과
<v-simple-table>
<template v-slot:default>
<thead>
<tr>
<th class="text-center">
제목
</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="item in search_item_list"
:key="item.id"
>
<td>{{ item.title }}</td>
<td>
<v-btn
color="#3bb9f1"
@click="addItem(item)"
>
추가
</v-btn>
</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-col>
<v-col v-if="add_item_list.length > 0">
추가할 콘텐츠
<v-simple-table>
<template>
<thead>
<tr>
<th class="text-center">
제목
</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="item in add_item_list"
:key="item.id"
>
<td>{{ item.title }}</td>
<td>
<v-btn
color="#3bb9f1"
@click="removeItem(item)"
>
제거
</v-btn>
</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-col>
</v-row>
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="cancel"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
@click="addItemInHashTagCuration"
>
추가
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog
v-model="show_delete_confirm_dialog"
max-width="400px"
persistent
>
<v-card>
<v-card-text />
<v-card-text v-if="selected_item !== null">
{{ selected_item.title }} 삭제하시겠습니까?
</v-card-text>
<v-card-text v-else>
삭제하시겠습니까?
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="cancel"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
@click="removeItemInHashTagCuration"
>
확인
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import Draggable from 'vuedraggable';
import * as api from "@/api/audio_content"
import VueShowMoreText from 'vue-show-more-text'
export default {
name: "ContentHashTagCurationDetail",
components: {VueShowMoreText, Draggable},
data() {
return {
is_loading: false,
curation_id: 0,
tag: '',
is_adult: false,
items: [],
show_add_content_dialog: false,
show_delete_confirm_dialog: false,
search_word: '',
selected_item: null,
add_item_list: [],
search_item_list: [],
}
},
async created() {
this.curation_id = this.$route.params.curation_id
this.tag = this.$route.params.tag
this.is_adult = this.$route.params.is_adult
await this.getHashTagCurationItems()
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
cancel() {
this.search_word = ''
this.add_item_list = []
this.search_item_list = []
this.selected_item = null
this.show_add_content_dialog = false
this.show_delete_confirm_dialog = false
},
deleteConfirm(item) {
this.selected_item = item
this.show_delete_confirm_dialog = true
},
showAddContent() {
this.show_add_content_dialog = true
},
addItem(item) {
this.search_item_list = this.search_item_list.filter((t) => {
return t.id !== item.id
});
this.add_item_list.push(item)
},
removeItem(item) {
this.add_item_list = this.add_item_list.filter((t) => {
return t.id !== item.id
});
this.search_item_list.push(item)
},
async onDropCallback(items) {
const ids = items.map((item) => {
return item.id
})
try {
this.is_loading = true
const res = await api.updateItemInHashTagCurationOrders(this.curation_id, ids)
if (res.status === 200 && res.data.success === true) {
this.notifySuccess(res.data.message)
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async searchContentItem() {
if (this.search_word.length < 2) {
this.notifyError('검색어를 2글자 이상 입력하세요.')
return
}
this.is_loading = true
try {
const res = await api.searchHashTagContentItem(this.curation_id, this.search_word)
if (res.data.success === true) {
this.search_item_list = res.data.data
if (res.data.data.length <= 0) {
this.notifyError('검색결과가 없습니다.')
}
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async addItemInHashTagCuration() {
this.is_loading = true
const itemIdList = this.add_item_list.map((item) => {
return item.id
})
try {
const res = await api.addItemToHashTagCuration(this.curation_id, itemIdList)
if (res.status === 200 && res.data.success === true) {
this.cancel()
await this.getHashTagCurationItems()
} else {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async removeItemInHashTagCuration() {
this.is_loading = true
try {
const res = await api.removeItemInHashTagCuration(this.curation_id, this.selected_item.id)
if (res.status === 200 && res.data.success === true) {
this.cancel()
await this.getHashTagCurationItems()
} else {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async getHashTagCurationItems() {
this.is_loading = true
try {
const res = await api.getHashTagCurationItems(this.curation_id)
if (res.status === 200 && res.data.success === true) {
this.items = res.data.data
console.log(this.items)
} else {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
},
}
</script>

View File

@@ -63,9 +63,6 @@
<th class="text-center">
내용
</th>
<th class="text-center">
큐레이션
</th>
<th class="text-center">
크리에이터
</th>
@@ -134,7 +131,6 @@
:lines="3"
/>
</td>
<td>{{ item.curationTitle || '없음' }}</td>
<td>{{ item.creatorNickname }}</td>
<td>{{ item.theme }}</td>
<td style="max-width: 100px !important; word-break:break-all; height: auto;">
@@ -307,22 +303,6 @@
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row>
<v-col cols="4">
큐레이션
</v-col>
<v-col cols="8">
<v-select
v-model="audio_content.curation_id"
:items="curations"
item-text="title"
item-value="value"
label="큐레이션 선택"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row>
<v-col cols="4">
@@ -415,7 +395,6 @@ export default {
search_word: '',
audio_content: {},
audio_contents: [],
curations: [],
themeList: [],
selected_audio_content: {},
utm_source: '',
@@ -426,7 +405,6 @@ export default {
async created() {
await this.getAudioContentThemeList();
await this.getCurations()
await this.getAudioContent()
},
@@ -455,7 +433,6 @@ export default {
this.audio_content.id = item.audioContentId
this.audio_content.title = item.title
this.audio_content.detail = item.detail
this.audio_content.curation_id = item.curationId
this.audio_content.theme_id = item.themeId
this.audio_content.is_adult = item.isAdult
this.audio_content.is_comment_available = item.isCommentAvailable
@@ -513,10 +490,6 @@ export default {
request.detail = this.audio_content.detail
}
if (this.selected_audio_content.curationId !== this.audio_content.curation_id) {
request.curationId = this.audio_content.curation_id
}
if (this.selected_audio_content.themeId !== this.audio_content.theme_id) {
request.themeId = this.audio_content.theme_id
}
@@ -598,26 +571,6 @@ export default {
}
},
async getCurations() {
this.is_loading = true
try {
const res = await api.getCurations()
if (res.status === 200 && res.data.success === true) {
this.curations = res.data.data.map((curation) => {
return {title: curation.title, value: curation.id}
})
this.curations.unshift({title: '없음', value: 0})
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async getAudioContent() {
this.is_loading = true
try {

View File

@@ -16,11 +16,25 @@
<template v-slot:activator="{ on, attrs }">
<v-container>
<v-row>
<v-col cols="10" />
<v-col cols="9">
<v-radio-group
v-model="selected_tab_id"
row
@change="getBanners"
>
<v-radio
v-for="tab in tabs"
:key="tab.tabId"
:label="tab.title"
:value="tab.tabId"
/>
</v-radio-group>
</v-col>
<v-spacer />
<v-col>
<v-btn
block
color="#9970ff"
color="#3BB9F1"
dark
depressed
v-bind="attrs"
@@ -75,6 +89,26 @@
<v-card-title v-else>
배너 등록
</v-card-title>
<v-card-text>
<v-row align="center">
<v-col cols="4">
메인
</v-col>
<v-col cols="8">
<v-radio-group
v-model="banner.tab_id"
row
>
<v-radio
v-for="tab in tabs"
:key="tab.tabId"
:label="tab.title"
:value="tab.tabId"
/>
</v-radio-group>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
@@ -293,13 +327,15 @@ export default {
show_write_dialog: false,
show_delete_confirm_dialog: false,
selected_banner: {},
banner: {type: 'CREATOR'},
banner: {type: 'CREATOR', tab_id: 1},
banners: [],
events: [],
creators: [],
series: [],
search_query_creator: '',
search_query_series: '',
tabs: [],
selected_tab_id: 1
}
},
@@ -319,7 +355,7 @@ export default {
async created() {
await this.getEvents()
await this.getBanners()
await this.getAudioContentMainTabList()
},
mounted() {
@@ -343,7 +379,7 @@ export default {
this.is_selecting = false
this.show_write_dialog = false
this.show_delete_confirm_dialog = false
this.banner = {type: 'CREATOR'}
this.banner = {type: 'CREATOR', tab_id: 1}
this.selected_banner = {}
this.search_query_creator = ''
this.search_query_series = ''
@@ -357,6 +393,27 @@ export default {
this.$dialog.notify.success(message)
},
async getAudioContentMainTabList() {
this.is_loading = true
try {
const res = await api.getAudioContentMainTabList()
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
this.tabs = data
this.selected_tab_id = data[0].tabId
await this.getBanners()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
showModifyBannerDialog(banner) {
this.is_modify = true
this.selected_banner = banner
@@ -374,6 +431,7 @@ export default {
this.banner.series_title = banner.seriesTitle
this.banner.link = banner.link
this.banner.is_adult = banner.isAdult
this.banner.tab_id = banner.tabId
setTimeout(() => {
this.is_selecting = false; // 선택 상태 해제
@@ -452,6 +510,10 @@ export default {
request.seriesId = this.banner.series_id
}
if (this.banner.tab_id !== 1) {
request.tabId = this.banner.tab_id
}
formData.append("request", JSON.stringify(request))
const res = await api.saveBanner(formData)
@@ -523,6 +585,10 @@ export default {
request.isAdult = this.banner.is_adult
}
if (this.selected_banner.tabId !== this.banner.tab_id) {
request.tabId = this.banner.tab_id
}
formData.append("request", JSON.stringify(request))
const res = await api.modifyBanner(formData)
@@ -680,7 +746,7 @@ export default {
async getBanners() {
this.is_loading = true
try {
const res = await api.getBannerList()
const res = await api.getBannerList(this.selected_tab_id)
if (res.status === 200 && res.data.success === true) {
this.banners = res.data.data
} else {

View File

@@ -53,6 +53,24 @@
<template v-slot:item.communitySettlementRatio="{ item }">
{{ item.communitySettlementRatio }}%
</template>
<template v-slot:item.actions="{ item }">
<v-btn
small
color="primary"
text
@click="openEdit(item)"
>
수정
</v-btn>
<v-btn
small
color="red"
text
@click="confirmDelete(item)"
>
삭제
</v-btn>
</template>
</v-data-table>
</v-col>
</v-row>
@@ -73,13 +91,20 @@
persistent
>
<v-card>
<v-card-title>크리에이터 정산비율</v-card-title>
<v-card-text>
<v-card-title>{{ is_edit ? '크리에이터 정산비율 수정' : '크리에이터 정산비율' }}</v-card-title>
<v-card-text v-show="!is_edit">
<v-text-field
v-model="creator_settlement_ratio.creator_id"
label="크리에이터 번호"
/>
</v-card-text>
<v-card-text v-show="is_edit">
<v-text-field
v-model="creator_settlement_ratio.nickname"
disabled
label="크리에이터 닉네임"
/>
</v-card-text>
<v-card-text>
<v-text-field
v-model="creator_settlement_ratio.subsidy"
@@ -118,7 +143,7 @@
text
@click="validate"
>
등록하기
{{ is_edit ? '수정하기' : '등록하기' }}
</v-btn>
</v-card-actions>
</v-card>
@@ -142,6 +167,8 @@ export default {
items: [],
creator_settlement_ratio: {},
show_write_dialog: false,
is_edit: false,
editing_item_id: null,
headers: [
{
text: '닉네임',
@@ -173,6 +200,12 @@ export default {
sortable: false,
value: 'communitySettlementRatio',
},
{
text: '관리',
align: 'center',
sortable: false,
value: 'actions',
},
],
}
},
@@ -191,11 +224,16 @@ export default {
},
showWriteDialog() {
this.is_edit = false
this.editing_item_id = null
this.creator_settlement_ratio = {}
this.show_write_dialog = true
},
cancel() {
this.creator_settlement_ratio = {}
this.is_edit = false
this.editing_item_id = null
this.show_write_dialog = false
},
@@ -225,7 +263,11 @@ export default {
return
}
this.createCreatorSettlementRatio();
if (this.is_edit) {
this.updateCreatorSettlementRatio();
} else {
this.createCreatorSettlementRatio();
}
},
async createCreatorSettlementRatio() {
@@ -253,6 +295,71 @@ export default {
this.is_loading = false
},
async updateCreatorSettlementRatio() {
if (this.is_loading) return;
this.is_loading = true
try {
// 수정은 생성과 동일한 파라미터를 전송 (memberId 기준)
const payload = { ...this.creator_settlement_ratio }
const res = await api.updateCreatorSettlementRatio(payload)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess(res.data.message || '수정되었습니다.')
this.items = []
await this.getSettlementRatio()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
openEdit(item) {
this.is_edit = true
this.editing_item_id = null
this.creator_settlement_ratio = {
creator_id: item.memberId,
nickname: item.nickname,
subsidy: item.subsidy,
liveSettlementRatio: item.liveSettlementRatio,
contentSettlementRatio: item.contentSettlementRatio,
communitySettlementRatio: item.communitySettlementRatio,
}
this.show_write_dialog = true
},
async confirmDelete(item) {
try {
const ok = await this.$dialog.confirm({ text: '삭제하시겠습니까?', title: '확인', actions: { false: '취소', true: '삭제' } })
if (!ok) return
} catch (e) {
// 일부 구현체는 confirm이 boolean이 아닌 경우가 있음
}
this.deleteCreatorSettlementRatio(item)
},
async deleteCreatorSettlementRatio(item) {
if (this.is_loading) return;
this.is_loading = true
try {
const memberId = item.memberId
const res = await api.deleteCreatorSettlementRatio(memberId)
if (res.status === 200 && res.data.success === true) {
this.notifySuccess(res.data.message || '삭제되었습니다.')
this.items = this.items.filter(x => (x.memberId) !== memberId)
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async getSettlementRatio() {
this.is_loading = true
@@ -279,10 +386,6 @@ export default {
},
async next() {
if (this.search_word.length < 2) {
this.search_word = ''
}
await this.getSettlementRatio()
},
},

View File

@@ -0,0 +1,361 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>광고 통계</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-container>
<v-row>
<v-spacer />
<v-col>
<v-btn
block
color="#3bb9f1"
dark
depressed
@click="getTodayStatistics"
>
오늘
</v-btn>
</v-col>
<v-col>
<v-btn
block
color="#3bb9f1"
dark
depressed
@click="getYesterdayStatistics"
>
어제
</v-btn>
</v-col>
<v-col>
<v-btn
block
color="#3bb9f1"
dark
depressed
@click="getWeekStatistics"
>
7
</v-btn>
</v-col>
<v-spacer />
<v-col>
<datetime
v-model="start_date"
class="datepicker"
format="YYYY-MM-DD"
/>
</v-col>
<v-col cols="1">
~
</v-col>
<v-col>
<datetime
v-model="end_date"
class="datepicker"
format="YYYY-MM-DD"
/>
</v-col>
<v-col cols="2">
<v-btn
block
color="#3bb9f1"
dark
depressed
@click="getStatistics"
>
조회
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col>
<v-data-table
:headers="headers"
:items="items"
:loading="is_loading"
:items-per-page="-1"
class="elevation-1"
hide-default-footer
>
<template slot="body.prepend">
<tr class="summary">
<td colspan="4">
총합계
</td>
<td>{{ sumField('launchCount').toLocaleString() }}</td>
<td>{{ sumField('signUpCount').toLocaleString() }}</td>
<td>{{ sumField('firstPaymentCount').toLocaleString() }}</td>
<td>{{ sumField('firstPaymentTotalAmount').toLocaleString() }}</td>
<td>{{ sumField('repeatPaymentCount').toLocaleString() }}</td>
<td>{{ sumField('repeatPaymentTotalAmount').toLocaleString() }}</td>
<td>{{ sumField('allPaymentCount').toLocaleString() }}</td>
<td>{{ sumField('allPaymentTotalAmount').toLocaleString() }}</td>
<td>{{ sumField('loginCount').toLocaleString() }}</td>
</tr>
</template>
<template v-slot:item.date="{ item }">
{{ item.date }}
</template>
<template v-slot:item.mediaGroup="{ item }">
{{ item.mediaGroup }}
</template>
<template v-slot:item.pid="{ item }">
{{ item.pid }}
</template>
<template v-slot:item.pidName="{ item }">
{{ item.pidName }}
</template>
<template v-slot:item.launchCount="{ item }">
{{ item.launchCount.toLocaleString() }}
</template>
<template v-slot:item.signUpCount="{ item }">
{{ item.signUpCount.toLocaleString() }}
</template>
<template v-slot:item.firstPaymentCount="{ item }">
{{ item.firstPaymentCount.toLocaleString() }}
</template>
<template v-slot:item.firstPaymentTotalAmount="{ item }">
{{ item.firstPaymentTotalAmount.toLocaleString() }}
</template>
<template v-slot:item.repeatPaymentCount="{ item }">
{{ item.repeatPaymentCount.toLocaleString() }}
</template>
<template v-slot:item.repeatPaymentTotalAmount="{ item }">
{{ item.repeatPaymentTotalAmount.toLocaleString() }}
</template>
<template v-slot:item.allPaymentCount="{ item }">
{{ item.allPaymentCount.toLocaleString() }}
</template>
<template v-slot:item.allPaymentTotalAmount="{ item }">
{{ item.allPaymentTotalAmount.toLocaleString() }}
</template>
<template v-slot:item.loginCount="{ item }">
{{ item.loginCount.toLocaleString() }}
</template>
</v-data-table>
</v-col>
</v-row>
<v-row class="text-center">
<v-col>
<v-pagination
v-model="page"
:length="total_page"
circle
@input="next"
/>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script>
import * as api from "@/api/marketing";
import datetime from "vuejs-datetimepicker";
export default {
name: 'MarketingAdStatisticsView',
components: {datetime},
data() {
return {
is_loading: false,
start_date: null,
end_date: null,
page: 1,
total_page: 0,
headers: [
{
text: '일자',
align: 'center',
sortable: false,
value: 'date',
},
{
text: '매체',
align: 'center',
sortable: false,
value: 'mediaGroup',
},
{
text: 'pid',
align: 'center',
sortable: false,
value: 'pid',
},
{
text: 'pid명',
align: 'center',
sortable: false,
value: 'pidName',
},
{
text: '앱 실행',
align: 'center',
sortable: false,
value: 'launchCount',
},
{
text: '가입수',
align: 'center',
sortable: false,
value: 'signUpCount',
},
{
text: '첫결제건수',
align: 'center',
sortable: false,
value: 'firstPaymentCount',
},
{
text: '첫결제금액',
align: 'center',
sortable: false,
value: 'firstPaymentTotalAmount',
},
{
text: '재결제건수',
align: 'center',
sortable: false,
value: 'repeatPaymentCount',
},
{
text: '재결제금액',
align: 'center',
sortable: false,
value: 'repeatPaymentTotalAmount',
},
{
text: '총 결제건수',
align: 'center',
sortable: false,
value: 'allPaymentCount',
},
{
text: '총 결제금액',
align: 'center',
sortable: false,
value: 'allPaymentTotalAmount',
},
{
text: '로그인 수',
align: 'center',
sortable: false,
value: 'loginCount',
}
],
items: [],
}
},
async created() {
await this.getTodayStatistics();
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
sumField(key) {
return this.items.reduce((a, b) => a + (b[key] || 0), 0)
},
formatDate(date) {
return date.toISOString().split('T')[0];
},
async next() {
await this.getStatistics()
},
async getStatistics() {
this.is_loading = true
try {
const res = await api.getStatistics(this.start_date, this.end_date, this.page);
if (res.status === 200 && res.data.success === true) {
let data = res.data.data
this.items = data.items
const totalPage = Math.ceil(data.totalCount / 20)
if (totalPage <= 0)
this.total_page = 1
else
this.total_page = totalPage
} else {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
},
async getTodayStatistics() {
const today = new Date();
this.start_date = this.formatDate(today);
this.end_date = this.formatDate(today);
await this.getStatistics()
},
async getYesterdayStatistics() {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
this.start_date = this.formatDate(yesterday);
this.end_date = this.formatDate(yesterday);
await this.getStatistics()
},
async getWeekStatistics() {
const week = new Date();
week.setDate(week.getDate() - 8);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
this.start_date = this.formatDate(week);
this.end_date = this.formatDate(yesterday);
await this.getStatistics()
}
},
}
</script>
<style scoped>
.summary {
background-color: #c4dbf1;
}
</style>

View File

@@ -0,0 +1,596 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>매체 파트너 코드</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-container>
<v-row>
<v-col cols="8" />
<v-col>
<v-btn
block
color="#3bb9f1"
dark
depressed
@click="showWriteDialog"
>
매체 파트너 코드 등록
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col>
<v-data-table
:headers="headers"
:items="items"
:loading="is_loading"
:items-per-page="-1"
item-key="id"
class="elevation-1"
hide-default-footer
>
<template v-slot:item.id="{ item }">
{{ item.id }}
</template>
<template v-slot:item.mediaGroup="{ item }">
{{ item.mediaGroup }}
</template>
<template v-slot:item.pid="{ item }">
{{ item.pid }}
</template>
<template v-slot:item.pidName="{ item }">
{{ item.pidName }}
</template>
<template v-slot:item.type="{ item }">
<span v-if="item.type === 'SERIES'">시리즈(오리지널 콘텐츠)</span>
<span v-else-if="item.type === 'CONTENT'">개별 콘텐츠</span>
<span v-else-if="item.type === 'LIVE'">라이브</span>
<span v-else-if="item.type === 'CHANNEL'">채널</span>
<span v-else>메인</span>
</template>
<template v-slot:item.createdAt="{ item }">
{{ item.createdAt }}
</template>
<template v-slot:item.utmSource="{ item }">
{{ item.utmSource }}
</template>
<template v-slot:item.utmMedium="{ item }">
{{ item.utmMedium }}
</template>
<template v-slot:item.link="{ item }">
<v-btn @click="copyLink(item.link)">
링크복사
</v-btn>
</template>
<template v-slot:item.isActive="{ item }">
<div v-if="item.isActive">
사용
</div>
<div v-else>
미사용
</div>
</template>
<template v-slot:item.management="{ item }">
<v-btn
:disabled="is_loading"
@click="showModifyDialog(item)"
>
수정
</v-btn>
</template>
</v-data-table>
</v-col>
</v-row>
<v-row class="text-center">
<v-col>
<v-pagination
v-model="page"
:length="total_page"
circle
@input="next"
/>
</v-col>
</v-row>
<v-row>
<v-dialog
v-model="show_write_dialog"
max-width="1000px"
persistent
>
<v-card>
<v-card-title>매체 파트너 코드 등록</v-card-title>
<v-card-text>
<v-row align="center">
<v-col cols="4">
매체 그룹
</v-col>
<v-col cols="8">
<v-text-field
v-model="media_group"
label="매체 그룹"
required
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
PID
</v-col>
<v-col cols="8">
<v-text-field
v-model="pid"
label="PID"
required
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
PID
</v-col>
<v-col cols="8">
<v-text-field
v-model="pid_name"
label="PID 명"
required
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
구분
</v-col>
<v-col cols="8">
<v-radio-group
v-model="type"
row
>
<v-radio
v-for="item in type_list"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</v-radio-group>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
utm_source
</v-col>
<v-col cols="8">
<v-text-field
v-model="utm_source"
label="UTM_SOURCE"
required
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
utm_medium
</v-col>
<v-col cols="8">
<v-text-field
v-model="utm_medium"
label="UTM_MEDIUM"
required
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text v-show="selected_media_partner_code !== null">
<v-row align="center">
<v-col cols="4">
사용여부
</v-col>
<v-col cols="8">
<v-radio-group
v-model="is_active"
row
>
<v-radio
key="1"
label="사용"
:value="true"
/>
<v-radio
key="2"
label="미사용"
:value="false"
/>
</v-radio-group>
</v-col>
</v-row>
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="cancel"
>
취소
</v-btn>
<v-btn
v-if="selected_media_partner_code !== null"
color="blue darken-1"
text
@click="modify"
>
수정
</v-btn>
<v-btn
v-else
color="blue darken-1"
text
@click="validate"
>
등록
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-row>
</v-container>
</div>
</template>
<script>
import * as api from "@/api/marketing";
export default {
name: "MarketingMediaPartnerCodeView",
data() {
return {
is_loading: false,
page: 1,
total_page: 0,
show_write_dialog: false,
selected_media_partner_code: null,
media_group: null,
pid: null,
pid_name: null,
type: 'MAIN',
is_active: true,
utm_source: null,
utm_medium: null,
type_list: [
{
label: '메인',
value: 'MAIN'
},
{
label: '시리즈(오리지널 콘텐츠 포함)',
value: 'SERIES'
},
{
label: '개별 콘텐츠',
value: 'CONTENT'
},
{
label: '라이브',
value: 'LIVE'
},
{
label: '채널',
value: 'CHANNEL'
},
],
items: [],
headers: [
{
text: '번호',
align: 'center',
sortable: false,
value: 'id',
},
{
text: '매체그룹',
align: 'center',
sortable: false,
value: 'mediaGroup',
},
{
text: 'Pid',
align: 'center',
sortable: false,
value: 'pid',
},
{
text: 'Pid명',
align: 'center',
sortable: false,
value: 'pidName',
},
{
text: '구분',
align: 'center',
sortable: false,
value: 'type',
},
{
text: '링크',
align: 'center',
sortable: false,
value: 'link',
},
{
text: '등록시간',
align: 'center',
sortable: false,
value: 'createdAt',
},
{
text: '기록추적(사용여부)',
align: 'center',
sortable: false,
value: 'isActive',
},
{
text: '관리',
align: 'center',
sortable: false,
value: 'management',
}
],
}
},
async created() {
await this.getMediaPartnerCodeList()
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
showWriteDialog() {
this.show_write_dialog = true
},
showModifyDialog(item) {
this.selected_media_partner_code = item;
this.media_group = item.mediaGroup;
this.pid = item.pid
this.pid_name = item.pidName
this.type = item.type
this.utm_source = item.utmSource
this.utm_medium = item.utmMedium
this.is_active = item.isActive;
this.show_write_dialog = true
},
cancel() {
this.selected_media_partner_code = null
this.media_group = null
this.pid = null
this.pid_name = null
this.type = 'MAIN'
this.is_active = true
this.utm_source = null
this.utm_medium = null
this.show_write_dialog = false
},
async copyLink(link) {
this.is_loading = true
try {
await navigator.clipboard.writeText(link)
this.notifySuccess("링크가 복사되었습니다.")
} catch (e) {
this.notifyError("링크를 복사하지 못했습니다.")
} finally {
this.is_loading = false
}
},
validate() {
if (this.media_group === null) {
this.notifyError('매체 그룹을 입력하세요')
return
}
if (this.pid === null) {
this.notifyError('PID를 입력하세요')
return
}
if (this.pid_name === null) {
this.notifyError('PID명을 입력하세요')
return
}
if (
this.type !== 'MAIN' &&
this.type !== 'SERIES' &&
this.type !== 'CONTENT' &&
this.type !== 'LIVE' &&
this.type !== 'CHANNEL'
) {
this.notifyError('잘못된 광고타입입니다.')
return
}
if (this.utm_source === null) {
this.notifyError('utm_source를 입력하세요')
return
}
if (this.utm_medium === null) {
this.notifyError('utm_medium을 입력하세요')
return
}
this.submit()
},
async getMediaPartnerCodeList() {
this.is_loading = true
try {
const res = await api.getMediaPartnerList(this.page);
if (res.status === 200 && res.data.success === true) {
let data = res.data.data
this.items = data.items
const totalPage = Math.ceil(data.totalCount / 20)
if (totalPage <= 0)
this.total_page = 1
else
this.total_page = totalPage
} else {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
},
async submit() {
if (this.is_loading) return;
this.is_loading = true
try {
const request = {
mediaGroup: this.media_group,
pid: this.pid,
pidName: this.pid_name,
type: this.type,
utmSource: this.utm_source,
utmMedium: this.utm_medium
};
const res = await api.createMediaPartner(request)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess(res.data.message || '등록되었습니다.')
this.items = [];
this.page = 1;
this.total_page = 0;
await this.getMediaPartnerCodeList()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
this.is_loading = false
},
async modify() {
if (this.is_loading) return;
this.is_loading = true
try {
const request = {
id: this.selected_media_partner_code.id
};
if (this.media_group !== this.selected_media_partner_code.mediaGroup) {
request.mediaGroup = this.media_group
}
if (this.pid !== this.selected_media_partner_code.pid) {
request.pid = this.pid;
}
if (this.pid_name !== this.selected_media_partner_code.pidName) {
request.pidName = this.pid_name;
}
if (this.type !== this.selected_media_partner_code.type) {
request.type = this.type;
}
if (this.utm_source !== this.selected_media_partner_code.utmSource) {
request.utmSource = this.utm_source;
}
if (this.utm_medium !== this.selected_media_partner_code.utmMedium) {
request.utmMedium = this.utm_medium;
}
if (this.is_active !== this.selected_media_partner_code.isActive) {
request.isActive = this.is_active
}
const res = await api.updateMediaPartner(request)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess(res.data.message || '수정되었습니다.')
this.items = [];
this.page = 1;
this.total_page = 0;
await this.getMediaPartnerCodeList()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
this.is_loading = false
},
async next() {
await this.getMediaPartnerCodeList()
},
},
}
</script>
<style scoped>
</style>

View File

@@ -49,6 +49,9 @@
<th class="text-center">
회원타입
</th>
<th class="text-center">
로그인 타입
</th>
<th class="text-center">
OS
</th>
@@ -92,6 +95,7 @@
</div>
</td>
<td>{{ item.userType }}</td>
<td>{{ item.loginType }}</td>
<td>
<div v-if="item.container === 'aos'">
Android
@@ -189,6 +193,13 @@
</v-row>
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-btn
color="blue darken-1"
text
@click="confirmResetPassword"
>
비밀번호 재설정
</v-btn>
<v-spacer />
<v-btn
color="blue darken-1"
@@ -208,6 +219,39 @@
</v-card>
</v-dialog>
</v-row>
<v-row>
<v-dialog
v-model="show_confirm_reset_password_dialog"
max-width="400px"
persistent
>
<v-card>
<v-card-title>
{{ nickname }}님의 비밀번호를 재설정 하시겠습니까?
</v-card-title>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="resetPassword"
>
비밀번호 재설정
</v-btn>
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="cancel"
>
취소
</v-btn>
<v-spacer />
</v-card-actions>
</v-card>
</v-dialog>
</v-row>
</div>
</template>
@@ -228,7 +272,8 @@ export default {
email: null,
nickname: null,
user_type: null,
show_popup_dialog: false
show_popup_dialog: false,
show_confirm_reset_password_dialog: false,
}
},
@@ -336,6 +381,7 @@ export default {
this.nickname = null
this.user_type = null
this.show_popup_dialog = false
this.show_confirm_reset_password_dialog = false
},
async modify() {
@@ -366,6 +412,32 @@ export default {
this.is_loading = false
this.cancel()
},
confirmResetPassword() {
this.show_popup_dialog = false
this.show_confirm_reset_password_dialog = true
},
async resetPassword() {
this.is_loading = true
try {
const res = await api.resetPassword(this.member.id)
if (res.status === 200 && res.data.success === true) {
this.notifySuccess(res.data.message)
this.cancel()
this.page = 1
await this.getMemberList()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
}
}
}

View File

@@ -0,0 +1,283 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>일별 전체 회원 </v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-container>
<v-row>
<v-spacer />
<v-col cols="2">
<datetime
v-model="start_date"
class="datepicker"
format="YYYY-MM-DD"
/>
</v-col>
<v-col cols="1">
~
</v-col>
<v-col cols="2">
<datetime
v-model="end_date"
class="datepicker"
format="YYYY-MM-DD"
/>
</v-col>
<v-col cols="1" />
<v-col cols="2">
<v-btn
block
color="#3bb9f1"
dark
depressed
@click="getStatistics"
>
조회
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col>
<v-data-table
:headers="headers"
:items="items"
:loading="is_loading"
:items-per-page="-1"
class="elevation-1"
hide-default-footer
>
<template slot="body.prepend">
<tr class="summary">
<td>
합계
</td>
<td>
{{ total_sign_up_count }}
</td>
<td>
{{ total_sign_up_email_count }}
</td>
<td>
{{ total_sign_up_kakao_count }}
</td>
<td>
{{ total_sign_up_google_count }}
</td>
<td>
{{ total_auth_count }}
</td>
<td>
{{ total_sign_out_count }}
</td>
<td>
{{ total_payment_member_count }}
</td>
</tr>
</template>
<template v-slot:item.date="{ item }">
{{ item.date }}
</template>
<template v-slot:item.signUpCount="{ item }">
{{ item.signUpCount.toLocaleString() }}
</template>
<template v-slot:item.signUpEmailCount="{ item }">
{{ item.signUpEmailCount.toLocaleString() }}
</template>
<template v-slot:item.signUpKakaoCount="{ item }">
{{ item.signUpKakaoCount.toLocaleString() }}
</template>
<template v-slot:item.signUpGoogleCount="{ item }">
{{ item.signUpGoogleCount.toLocaleString() }}
</template>
<template v-slot:item.authCount="{ item }">
{{ item.authCount.toLocaleString() }}
</template>
<template v-slot:item.signOutCount="{ item }">
{{ item.signOutCount.toLocaleString() }}
</template>
<template v-slot:item.paymentMemberCount="{ item }">
{{ item.paymentMemberCount.toLocaleString() }}
</template>
</v-data-table>
</v-col>
</v-row>
<v-row class="text-center">
<v-col>
<v-pagination
v-model="page"
:length="total_page"
circle
@input="getStatistics"
/>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script>
import * as api from "@/api/member_statistics"
import datetime from 'vuejs-datetimepicker';
export default {
name: "MemberStatisticsView",
components: {datetime},
data() {
return {
is_loading: false,
start_date: null,
end_date: null,
total_auth_count: 0,
total_sign_up_count: 0,
total_sign_up_email_count: 0,
total_sign_up_kakao_count: 0,
total_sign_up_google_count: 0,
total_sign_out_count: 0,
total_payment_member_count: 0,
page: 1,
total_page: 0,
items: [],
headers: [
{
text: '날짜',
align: 'center',
sortable: false,
value: 'date',
},
{
text: '회원가입 수',
align: 'center',
sortable: false,
value: 'signUpCount',
},
{
text: '이메일 가입 수',
align: 'center',
sortable: false,
value: 'signUpEmailCount',
},
{
text: '카카오 가입 수',
align: 'center',
sortable: false,
value: 'signUpKakaoCount',
},
{
text: '구글 가입 수',
align: 'center',
sortable: false,
value: 'signUpGoogleCount',
},
{
text: '본인인증 수',
align: 'center',
sortable: false,
value: 'authCount',
},
{
text: '회원탈퇴 수',
align: 'center',
sortable: false,
value: 'signOutCount',
},
{
text: '결제자 수',
align: 'center',
sortable: false,
value: 'paymentMemberCount',
},
]
}
},
async created() {
const date = new Date();
const firstDate = new Date(date.getFullYear(), date.getMonth(), 1);
const lastDate = new Date(date.getFullYear(), date.getMonth() + 1, 0);
let firstDateMonth = (firstDate.getMonth() + 1).toString()
if (firstDateMonth.length < 2) {
firstDateMonth = '0' + firstDateMonth
}
let lastDateMonth = (lastDate.getMonth() + 1).toString()
if (lastDateMonth.length < 2) {
lastDateMonth = '0' + lastDateMonth
}
this.start_date = firstDate.getFullYear() + '-' + firstDateMonth + '-0' + firstDate.getDate()
this.end_date = lastDate.getFullYear() + '-' + lastDateMonth + '-' + lastDate.getDate()
await this.getStatistics()
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
async getStatistics() {
this.is_loading = true
try {
const res = await api.getStatistics(this.start_date, this.end_date, this.page);
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
this.total_auth_count = data.totalAuthCount
this.total_sign_up_count = data.totalSignUpCount
this.total_sign_up_email_count = data.totalSignUpEmailCount
this.total_sign_up_kakao_count = data.totalSignUpKakaoCount
this.total_sign_up_google_count = data.totalSignUpGoogleCount
this.total_sign_out_count = data.totalSignOutCount
this.total_payment_member_count = data.totalPaymentMemberCount
this.items = data.items
const totalPage = Math.ceil(data.totalCount / 30)
if (totalPage <= 0)
this.total_page = 1
else
this.total_page = totalPage
} else {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
},
}
}
</script>
<style scoped>
.summary {
background-color: #c4dbf1;
}
</style>

View File

@@ -14,7 +14,7 @@
<v-col>
<v-btn
block
color="#9970ff"
color="#3bb9f1"
dark
depressed
@click="showWriteDialog"
@@ -113,13 +113,13 @@
<datetime
v-model="start_date"
class="datepicker"
format="YYYY-MM-DD"
format="YYYY-MM-DD H:i"
/>
<div> ~ </div>
<datetime
v-model="end_date"
class="datepicker"
format="YYYY-MM-DD"
format="YYYY-MM-DD H:i"
/>
</v-col>
</v-row>

View File

@@ -75,13 +75,13 @@
<datetime
v-model="event.startDate"
class="datepicker"
format="YYYY-MM-DD"
format="YYYY-MM-DD H:i"
/>
<div> ~ </div>
<div> ~</div>
<datetime
v-model="event.endDate"
class="datepicker"
format="YYYY-MM-DD"
format="YYYY-MM-DD H:i"
/>
</v-col>
</v-row>
@@ -281,16 +281,17 @@ import datetime from 'vuejs-datetimepicker';
export default {
name: "EventView",
components: { datetime },
components: {datetime},
data() {
return {
is_loading: false,
is_modify: false,
events: [],
event: { isAdult: '' },
event: {isAdult: ''},
show_write_dialog: false,
show_delete_confirm_dialog: false,
selected_event: {},
}
},
@@ -361,6 +362,7 @@ export default {
clickEvent(item) {
this.is_modify = true
this.selected_event = item
this.event.id = item.id
this.event.thumbnailImageUrl = item.thumbnailImageUrl
this.event.detailImageUrl = item.detailImageUrl
@@ -376,7 +378,8 @@ export default {
cancel() {
this.is_modify = false
this.event = { isAdult: '' }
this.event = {isAdult: ''}
this.selected_event = {}
this.show_write_dialog = false
},
@@ -440,7 +443,7 @@ export default {
this.notifySuccess('등록되었습니다.')
this.page = 1
await this.getEvents()
this.event = { isAdult: '' }
this.event = {isAdult: ''}
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
@@ -457,7 +460,11 @@ export default {
const formData = new FormData()
formData.append("id", this.event.id)
if (this.event.title != null && this.event.title.trim().length > 0) {
if (
this.event.title != null &&
this.event.title.trim().length > 0 &&
this.selected_event.title !== this.event.title
) {
formData.append("title", this.event.title)
}
@@ -477,7 +484,7 @@ export default {
formData.append("isPopup", this.event.isPopup)
}
if (this.event.link != null && this.event.link.trim().length > 0) {
if (this.selected_event.link !== this.event.link) {
formData.append("link", this.event.link)
}
@@ -485,11 +492,11 @@ export default {
formData.append("isAdult", JSON.parse(this.event.isAdult))
}
if (this.event.startDate != null) {
if (this.event.startDate != null && this.event.startDate !== this.selected_event.startDate) {
formData.append("startDate", this.event.startDate)
}
if (this.event.endDate != null) {
if (this.event.endDate != null && this.event.endDate !== this.selected_event.endDate) {
formData.append("endDate", this.event.endDate)
}
@@ -499,7 +506,7 @@ export default {
this.notifySuccess('수정되었습니다.')
this.page = 1
await this.getEvents()
this.event = { isAdult: '' }
this.event = {isAdult: ''}
this.is_modify = false
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')

View File

@@ -0,0 +1,585 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>포인트 정책</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-container>
<v-row>
<v-col cols="10" />
<v-col>
<v-btn
block
color="#3bb9f1"
dark
depressed
@click="showWriteDialog"
>
포인트 정책 등록
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col>
<v-data-table
:headers="headers"
:items="point_policy_list"
:loading="is_loading"
:items-per-page="-1"
item-key="id"
class="elevation-1"
hide-default-footer
>
<template v-slot:item.title="{ item }">
{{ item.title }}
</template>
<template v-slot:item.policyType="{ item }">
{{ policy_type_map[item.policyType] }}
</template>
<template v-slot:item.actionType="{ item }">
{{ action_type_map[item.actionType] }}
</template>
<template v-slot:item.threshold="{ item }">
{{ item.threshold }}
</template>
<template v-slot:item.availableCount="{ item }">
{{ item.availableCount }}
</template>
<template v-slot:item.period="{ item }">
{{ item.startDate }} ~ {{ item.endDate }}
</template>
<template v-slot:item.point="{ item }">
{{ item.pointAmount }}
</template>
<template v-slot:item.isActive="{ item }">
<div v-if="item.isActive">
O
</div>
<div v-else>
X
</div>
</template>
<template v-slot:item.management="{ item }">
<v-btn
:disabled="is_loading"
@click="showModifyDialog(item)"
>
수정
</v-btn>
</template>
</v-data-table>
</v-col>
</v-row>
<v-row class="text-center">
<v-col>
<v-pagination
v-model="page"
:length="total_page"
circle
@input="next"
/>
</v-col>
</v-row>
</v-container>
<v-row>
<v-dialog
v-model="show_write_dialog"
max-width="1000px"
persistent
>
<v-card>
<v-card-title>포인트 정책 등록</v-card-title>
<v-card-text>
<v-text-field
v-model="point_policy.title"
label="제목"
required
/>
</v-card-text>
<v-card-text v-if="selected_point_policy === null">
<v-radio-group
v-model="point_policy.policy_type"
label="지급 유형 선택"
>
<v-radio
v-for="item in policy_type_list"
:key="item.value"
:label="item.name"
:value="item.value"
/>
</v-radio-group>
</v-card-text>
<v-card-text v-if="selected_point_policy === null">
<v-radio-group
v-model="point_policy.action_type"
label="액션 선택"
>
<v-radio
v-for="item in action_type_list"
:key="item.value"
:label="item.name"
:value="item.value"
/>
</v-radio-group>
</v-card-text>
<v-card-text v-if="selected_point_policy === null">
<v-text-field
v-model="point_policy.threshold"
label="참여해야 하는 횟수"
required
/>
</v-card-text>
<v-card-text v-if="selected_point_policy === null">
<v-text-field
v-model="point_policy.point"
label="포인트"
required
/>
</v-card-text>
<v-card-text v-if="selected_point_policy === null">
<v-text-field
v-model="point_policy.available_count"
label="참여 가능 횟수"
required
/>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
기간
</v-col>
<v-col
cols="8"
class="datepicker-wrapper"
>
<datetime
v-model="point_policy.start_date"
class="datepicker"
format="YYYY-MM-DD H:i"
/>
<div> ~</div>
<datetime
v-model="point_policy.end_date"
class="datepicker"
format="YYYY-MM-DD H:i"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text v-show="selected_point_policy !== null">
<v-row align="center">
<v-col cols="4">
활성화
</v-col>
<v-col cols="8">
<input
v-model="point_policy.is_active"
type="checkbox"
>
</v-col>
</v-row>
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="cancel"
>
취소
</v-btn>
<v-btn
v-if="selected_point_policy !== null"
color="blue darken-1"
text
@click="modify"
>
수정
</v-btn>
<v-btn
v-else
color="blue darken-1"
text
@click="validate"
>
등록
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-row>
</div>
</template>
<script>
import * as api from '@/api/point_policy'
import datetime from "vuejs-datetimepicker";
export default {
name: "PointPolicyView",
components: {datetime},
data() {
return {
is_loading: false,
show_write_dialog: false,
action_type_list: [
{
name: '본인인증',
value: 'USER_AUTHENTICATION'
},
{
name: '콘텐츠 댓글',
value: 'CONTENT_COMMENT'
},
{
name: '구매한 콘텐츠 댓글',
value: 'ORDER_CONTENT_COMMENT'
},
{
name: '라이브 연속 청취 30분',
value: 'LIVE_CONTINUOUS_LISTEN_30'
},
],
action_type_map: {
'USER_AUTHENTICATION': '본인인증',
'CONTENT_COMMENT': '콘텐츠 댓글',
'ORDER_CONTENT_COMMENT': '구매한 콘텐츠 댓글',
'LIVE_CONTINUOUS_LISTEN_30': '라이브 연속 청취 30분',
},
policy_type_list: [
{
name: '매일',
value: 'DAILY'
},
{
name: '전체',
value: 'TOTAL'
},
],
policy_type_map: {
'DAILY': '매일',
'TOTAL': '전체',
},
point_policy: {
title: '',
policy_type: '',
action_type: '',
threshold: 0,
available_count: 0,
point: 0,
start_date: '',
end_date: ''
},
selected_point_policy: null,
point_policy_list: [],
page: 1,
total_page: 0,
headers: [
{
text: '제목',
align: 'center',
sortable: false,
value: 'title',
},
{
text: '지급유형',
align: 'center',
sortable: false,
value: 'policyType',
},
{
text: '액션',
align: 'center',
sortable: false,
value: 'actionType',
},
{
text: '참여해야 하는 횟수',
align: 'center',
sortable: false,
value: 'threshold',
},
{
text: '참여 가능 횟수',
align: 'center',
sortable: false,
value: 'availableCount',
},
{
text: '기간',
align: 'center',
sortable: false,
value: 'period',
},
{
text: '포인트',
align: 'center',
sortable: false,
value: 'pointAmount',
},
{
text: '활성화',
align: 'center',
sortable: false,
value: 'isActive',
},
{
text: '관리',
align: 'center',
sortable: false,
value: 'management'
}
],
}
},
async created() {
await this.getPointPolicyList()
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
showWriteDialog() {
this.show_write_dialog = true
},
showModifyDialog(item) {
this.selected_point_policy = item;
this.point_policy = {
title: item.title,
policy_type: item.policyType,
action_type: item.actionType,
threshold: item.threshold,
available_count: item.availableCount,
point: item.pointAmount,
start_date: item.startDate,
end_date: item.endDate,
is_active: item.isActive
};
this.show_write_dialog = true
},
validate() {
if (this.point_policy.title.trim() === '') {
this.notifyError('제목을 입력하세요.')
return
}
if (this.point_policy.policy_type.trim() === '') {
this.notifyError('지급유형을 선택하세요')
return
}
if (this.point_policy.action_type.trim() === '') {
this.notifyError('액션을 선택하세요')
return
}
if (isNaN(this.point_policy.threshold)) {
this.notifyError('참여 해야하는 횟수는 숫자만 입력 가능합니다.')
return
}
if (this.point_policy.threshold <= 0) {
this.notifyError('참여 해야하는 횟수는 1이상 입력 가능합니다.')
return
}
if (isNaN(this.point_policy.point)) {
this.notifyError('지급 포인트는 숫자만 입력 가능합니다.')
return
}
if (isNaN(this.point_policy.available_count)) {
this.notifyError('참여 가능 횟수는 숫자만 입력 가능합니다.')
return
}
if (this.point_policy.available_count <= 0) {
this.notifyError('참여 가능 횟수는 1이상 입력 가능합니다.')
return
}
if (this.point_policy.start_date.trim() === '') {
this.notifyError('정책 시작 날짜를 입력하세요')
return
}
this.submit()
},
cancel() {
this.point_policy = {
title: '',
policy_type: '',
action_type: '',
threshold: 0,
available_count: 0,
point: 0,
start_date: '',
end_date: ''
}
this.show_write_dialog = false
this.selected_point_policy = null;
},
async submit() {
if (this.is_loading) return;
this.is_loading = true
try {
const request = {
'title': this.point_policy.title,
'policyType': this.point_policy.policy_type,
'actionType': this.point_policy.action_type,
'threshold': this.point_policy.threshold,
'availableCount': this.point_policy.available_count,
'pointAmount': this.point_policy.point,
'startDate': this.point_policy.start_date,
'endDate': this.point_policy.end_date
}
const res = await api.createPointPolicyList(request)
this.is_loading = false
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess(res.data.message || '등록되었습니다.')
this.page = 1
this.point_policy_list = []
await this.getPointPolicyList()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
},
async modify() {
if (this.is_loading) return;
this.is_loading = true
try {
const request = {}
if (this.point_policy.title !== this.selected_point_policy.title) {
request.title = this.point_policy.title
}
if (this.point_policy.start_date !== this.selected_point_policy.startDate) {
request.startDate = this.point_policy.start_date
}
if (this.point_policy.end_date !== this.selected_point_policy.endDate) {
request.endDate = this.point_policy.end_date
}
if (this.point_policy.is_active !== this.selected_point_policy.isActive) {
request.isActive = this.point_policy.is_active
}
const res = await api.updatePointPolicyList(this.selected_point_policy.id, request)
this.is_loading = false
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess(res.data.message || '수정되었습니다.')
this.point_policy_list = []
await this.getPointPolicyList()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
},
async getPointPolicyList() {
if (this.is_loading) return;
this.is_loading = true
try {
const res = await api.getPointPolicyList(this.page);
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const total_page = Math.ceil(data.totalCount / 20)
this.point_policy_list = data.items
if (total_page <= 0)
this.total_page = 1
else
this.total_page = total_page
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
},
async next() {
await this.getPointPolicyList()
},
}
}
</script>
<style scoped>
.datepicker {
text-align: center;
}
.datepicker-wrapper {
display: flex;
flex-direction: row;
}
.datepicker-wrapper > div {
margin: 20px;
}
.v-card__text {
margin-top: 20px;
}
.v-card__actions {
margin-top: 100px;
}
.v-card__actions > .v-btn {
font-size: 20px;
}
</style>

View File

@@ -0,0 +1,481 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>새로운 시리즈</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-dialog
v-model="show_write_dialog"
max-width="1000px"
persistent
>
<template v-slot:activator="{ on, attrs }">
<v-container>
<v-row>
<v-col cols="9" />
<v-spacer />
<v-col>
<v-btn
block
color="#3BB9F1"
dark
depressed
v-bind="attrs"
v-on="on"
>
새로운 시리즈 등록
</v-btn>
</v-col>
</v-row>
<draggable
v-model="recommend_series_list"
class="row"
@end="onDropCallback(recommend_series_list)"
>
<v-col
v-for="(item, i) in recommend_series_list"
:key="i"
cols="3"
>
<v-card>
<v-card-title>
<v-spacer />
<v-img :src="item.imageUrl" />
<v-spacer />
</v-card-title>
<v-card-actions>
<v-spacer />
<v-btn
text
@click="showModifyRecommendSeriesDialog(item)"
>
수정
</v-btn>
<v-btn
text
@click="deleteConfirm(item)"
>
삭제
</v-btn>
<v-spacer />
</v-card-actions>
</v-card>
</v-col>
</draggable>
</v-container>
</template>
<v-card>
<v-card-title v-if="is_modify === true">
새로운 시리즈 수정
</v-card-title>
<v-card-title v-else>
새로운 시리즈 등록
</v-card-title>
<v-card-text>
<v-row align="center">
<v-col cols="4">
시리즈
</v-col>
<v-col cols="8">
<v-combobox
v-model="recommend_series.series_title"
:items="series"
:loading="is_loading"
:search-input.sync="search_query_series"
label="시리즈를 검색하세요"
item-text="name"
item-value="value"
no-data-text="No results found"
hide-selected
clearable
@change="onSelectSeries"
@update:search-input="onSearchSeriesUpdate"
/>
</v-col>
</v-row>
</v-card-text>
<div class="image-select">
<label for="image">
새로운 시리즈 이미지 등록
</label>
<v-file-input
id="image"
v-model="recommend_series.image"
@change="imageAdd"
/>
</div>
<img
v-if="recommend_series.image_url"
:src="recommend_series.image_url"
alt=""
class="image-preview"
>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="cancel"
>
취소
</v-btn>
<v-btn
v-if="is_modify === true"
color="blue darken-1"
text
@click="modify"
>
수정
</v-btn>
<v-btn
v-else
color="blue darken-1"
text
@click="submit"
>
등록
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog
v-model="show_delete_confirm_dialog"
max-width="400px"
persistent
>
<v-card>
<v-card-text />
<v-card-text>
삭제하시겠습니까?
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="cancel"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
@click="deleteRecommendSeries"
>
확인
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import Draggable from "vuedraggable";
import debounce from "lodash/debounce";
import * as api from "@/api/audio_content_series_recommend"
import * as seriesApi from "@/api/audio_content_series";
export default {
name: "ContentSeriesNew",
components: {Draggable},
data() {
return {
is_selecting: false,
is_loading: false,
is_modify: false,
show_write_dialog: false,
show_delete_confirm_dialog: false,
selected_recommend_series: {},
recommend_series: {},
recommend_series_list: [],
series: [],
search_query_series: '',
}
},
watch: {
search_query_series() {
if (!this.is_selecting) {
this.debouncedSearchSeries();
}
}
},
async created() {
await this.getRecommendSeriesList();
},
mounted() {
this.debouncedSearchSeries = debounce(this.searchSeries, 500);
},
methods: {
imageAdd(payload) {
const file = payload;
if (file) {
this.recommend_series.image_url = URL.createObjectURL(file)
URL.revokeObjectURL(file)
} else {
this.recommend_series.image_url = null
}
},
cancel() {
this.is_modify = false
this.is_selecting = false
this.show_write_dialog = false
this.show_delete_confirm_dialog = false
this.recommend_series = {}
this.selected_recommend_series = {}
this.search_query_series = ''
this.series = []
},
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
showModifyRecommendSeriesDialog(recommendSeries) {
this.is_modify = true
this.selected_recommend_series = recommendSeries
this.is_selecting = true; // 선택 중 플래그 활성화
this.recommend_series.id = recommendSeries.id
this.recommend_series.image_url = recommendSeries.imageUrl
this.recommend_series.series_id = recommendSeries.seriesId
this.recommend_series.series_title = recommendSeries.seriesTitle
setTimeout(() => {
this.is_selecting = false; // 선택 상태 해제
}, 1000);
this.show_write_dialog = true
},
deleteConfirm(recommendSeries) {
this.selected_recommend_series = recommendSeries
this.show_delete_confirm_dialog = true
},
onSelectSeries(value) {
this.recommend_series.series_id = value.value
this.is_selecting = true; // 선택 중 플래그 활성화
setTimeout(() => {
this.is_selecting = false; // 선택 상태 해제
}, 0);
},
onSearchSeriesUpdate(value) {
if (!this.is_selecting) {
this.search_query_series = value
}
},
validate() {
if (this.recommend_series.series_id === null || this.recommend_series.series_id === undefined) {
this.notifyError("시리즈를 선택하세요")
return false;
}
return true;
},
async getRecommendSeriesList() {
this.is_loading = true
try {
const res = await api.getRecommendSeriesList(false)
if (res.status === 200 && res.data.success === true) {
this.recommend_series_list = res.data.data
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
} finally {
this.is_loading = false
}
},
async submit() {
if (!this.validate()) return;
if (this.is_loading === true) return;
this.is_loading = true
try {
const formData = new FormData()
formData.append("image", this.recommend_series.image)
let request = {
seriesId: this.recommend_series.series_id,
isFree: false
}
formData.append("request", JSON.stringify(request))
const res = await api.saveRecommendSeries(formData)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess('등록되었습니다.')
this.recommend_series_list = []
await this.getRecommendSeriesList()
} else {
this.notifyError(res.data.message);
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async modify() {
if (this.is_loading) return;
this.is_loading = true
try {
const formData = new FormData()
let request = {id: this.recommend_series.id}
if (this.recommend_series.image !== null) {
formData.append("image", this.recommend_series.image)
}
if (
this.selected_recommend_series.series_id !== this.recommend_series.series_id &&
this.recommend_series.series_id !== null &&
this.recommend_series.series_id !== undefined
) {
request.seriesId = this.recommend_series.series_id
}
formData.append("request", JSON.stringify(request))
const res = await api.modifyRecommendSeries(formData)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess('수정되었습니다.')
this.recommend_series_list = []
await this.getRecommendSeriesList()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async deleteRecommendSeries() {
if (this.is_loading) return;
this.is_loading = true
try {
const formData = new FormData()
formData.append("request", JSON.stringify({id: this.selected_recommend_series.id, isActive: false}))
const res = await api.modifyRecommendSeries(formData)
if (res.status === 200 && res.data.success === true) {
this.show_delete_confirm_dialog = false
this.cancel()
this.notifySuccess('삭제되었습니다.')
this.recommend_series_list = []
await this.getRecommendSeriesList()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async onDropCallback(items) {
const ids = items.map((item) => {
return item.id
})
const res = await api.updateRecommendSeriesOrders(ids)
if (res.status === 200 && res.data.success === true) {
this.notifySuccess(res.data.message || '수정되었습니다.')
}
},
async searchSeries() {
if (this.search_query_series === null || this.search_query_series.length < 2) {
this.series = [];
return;
}
this.is_loading = true;
try {
const res = await seriesApi.searchSeriesList(this.search_query_series);
if (res.status === 200 && res.data.success === true) {
this.series = res.data.data.map((item) => {
return {name: item.title, value: item.id}
})
} else {
this.series = []
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
} finally {
this.is_loading = false
}
},
}
}
</script>
<style scoped>
.image-select label {
display: inline-block;
padding: 10px 20px;
background-color: #232d4a;
color: #fff;
vertical-align: middle;
font-size: 15px;
cursor: pointer;
border-radius: 5px;
}
.v-file-input {
position: absolute;
width: 0;
height: 0;
padding: 0;
overflow: hidden;
border: 0;
}
.image-preview {
max-width: 100%;
width: 250px;
object-fit: cover;
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,481 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>무료 추천 시리즈</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-dialog
v-model="show_write_dialog"
max-width="1000px"
persistent
>
<template v-slot:activator="{ on, attrs }">
<v-container>
<v-row>
<v-col cols="9" />
<v-spacer />
<v-col>
<v-btn
block
color="#3BB9F1"
dark
depressed
v-bind="attrs"
v-on="on"
>
무료 추천 시리즈 등록
</v-btn>
</v-col>
</v-row>
<draggable
v-model="recommend_series_list"
class="row"
@end="onDropCallback(recommend_series_list)"
>
<v-col
v-for="(item, i) in recommend_series_list"
:key="i"
cols="3"
>
<v-card>
<v-card-title>
<v-spacer />
<v-img :src="item.imageUrl" />
<v-spacer />
</v-card-title>
<v-card-actions>
<v-spacer />
<v-btn
text
@click="showModifyRecommendSeriesDialog(item)"
>
수정
</v-btn>
<v-btn
text
@click="deleteConfirm(item)"
>
삭제
</v-btn>
<v-spacer />
</v-card-actions>
</v-card>
</v-col>
</draggable>
</v-container>
</template>
<v-card>
<v-card-title v-if="is_modify === true">
추천 시리즈 수정
</v-card-title>
<v-card-title v-else>
추천 시리즈 등록
</v-card-title>
<v-card-text>
<v-row align="center">
<v-col cols="4">
시리즈
</v-col>
<v-col cols="8">
<v-combobox
v-model="recommend_series.series_title"
:items="series"
:loading="is_loading"
:search-input.sync="search_query_series"
label="시리즈를 검색하세요"
item-text="name"
item-value="value"
no-data-text="No results found"
hide-selected
clearable
@change="onSelectSeries"
@update:search-input="onSearchSeriesUpdate"
/>
</v-col>
</v-row>
</v-card-text>
<div class="image-select">
<label for="image">
추천 시리즈 이미지 등록
</label>
<v-file-input
id="image"
v-model="recommend_series.image"
@change="imageAdd"
/>
</div>
<img
v-if="recommend_series.image_url"
:src="recommend_series.image_url"
alt=""
class="image-preview"
>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="cancel"
>
취소
</v-btn>
<v-btn
v-if="is_modify === true"
color="blue darken-1"
text
@click="modify"
>
수정
</v-btn>
<v-btn
v-else
color="blue darken-1"
text
@click="submit"
>
등록
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog
v-model="show_delete_confirm_dialog"
max-width="400px"
persistent
>
<v-card>
<v-card-text />
<v-card-text>
삭제하시겠습니까?
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="cancel"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
@click="deleteRecommendSeries"
>
확인
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import Draggable from "vuedraggable";
import debounce from "lodash/debounce";
import * as api from "@/api/audio_content_series_recommend"
import * as seriesApi from "@/api/audio_content_series";
export default {
name: "ContentSeriesRecommendFree",
components: {Draggable},
data() {
return {
is_selecting: false,
is_loading: false,
is_modify: false,
show_write_dialog: false,
show_delete_confirm_dialog: false,
selected_recommend_series: {},
recommend_series: {},
recommend_series_list: [],
series: [],
search_query_series: '',
}
},
watch: {
search_query_series() {
if (!this.is_selecting) {
this.debouncedSearchSeries();
}
}
},
async created() {
await this.getRecommendSeriesList();
},
mounted() {
this.debouncedSearchSeries = debounce(this.searchSeries, 500);
},
methods: {
imageAdd(payload) {
const file = payload;
if (file) {
this.recommend_series.image_url = URL.createObjectURL(file)
URL.revokeObjectURL(file)
} else {
this.recommend_series.image_url = null
}
},
cancel() {
this.is_modify = false
this.is_selecting = false
this.show_write_dialog = false
this.show_delete_confirm_dialog = false
this.recommend_series = {}
this.selected_recommend_series = {}
this.search_query_series = ''
this.series = []
},
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
showModifyRecommendSeriesDialog(recommendSeries) {
this.is_modify = true
this.selected_recommend_series = recommendSeries
this.is_selecting = true; // 선택 중 플래그 활성화
this.recommend_series.id = recommendSeries.id
this.recommend_series.image_url = recommendSeries.imageUrl
this.recommend_series.series_id = recommendSeries.seriesId
this.recommend_series.series_title = recommendSeries.seriesTitle
setTimeout(() => {
this.is_selecting = false; // 선택 상태 해제
}, 1000);
this.show_write_dialog = true
},
deleteConfirm(recommendSeries) {
this.selected_recommend_series = recommendSeries
this.show_delete_confirm_dialog = true
},
onSelectSeries(value) {
this.recommend_series.series_id = value.value
this.is_selecting = true; // 선택 중 플래그 활성화
setTimeout(() => {
this.is_selecting = false; // 선택 상태 해제
}, 0);
},
onSearchSeriesUpdate(value) {
if (!this.is_selecting) {
this.search_query_series = value
}
},
validate() {
if (this.recommend_series.series_id === null || this.recommend_series.series_id === undefined) {
this.notifyError("시리즈를 선택하세요")
return false;
}
return true;
},
async getRecommendSeriesList() {
this.is_loading = true
try {
const res = await api.getRecommendSeriesList(true)
if (res.status === 200 && res.data.success === true) {
this.recommend_series_list = res.data.data
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
} finally {
this.is_loading = false
}
},
async submit() {
if (!this.validate()) return;
if (this.is_loading === true) return;
this.is_loading = true
try {
const formData = new FormData()
formData.append("image", this.recommend_series.image)
let request = {
seriesId: this.recommend_series.series_id,
isFree: true
}
formData.append("request", JSON.stringify(request))
const res = await api.saveRecommendSeries(formData)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess('등록되었습니다.')
this.recommend_series_list = []
await this.getRecommendSeriesList()
} else {
this.notifyError(res.data.message);
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async modify() {
if (this.is_loading) return;
this.is_loading = true
try {
const formData = new FormData()
let request = {id: this.recommend_series.id}
if (this.recommend_series.image !== null) {
formData.append("image", this.recommend_series.image)
}
if (
this.selected_recommend_series.series_id !== this.recommend_series.series_id &&
this.recommend_series.series_id !== null &&
this.recommend_series.series_id !== undefined
) {
request.seriesId = this.recommend_series.series_id
}
formData.append("request", JSON.stringify(request))
const res = await api.modifyRecommendSeries(formData)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess('수정되었습니다.')
this.recommend_series_list = []
await this.getRecommendSeriesList()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async deleteRecommendSeries() {
if (this.is_loading) return;
this.is_loading = true
try {
const formData = new FormData()
formData.append("request", JSON.stringify({id: this.selected_recommend_series.id, isActive: false}))
const res = await api.modifyRecommendSeries(formData)
if (res.status === 200 && res.data.success === true) {
this.show_delete_confirm_dialog = false
this.cancel()
this.notifySuccess('삭제되었습니다.')
this.recommend_series_list = []
await this.getRecommendSeriesList()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async onDropCallback(items) {
const ids = items.map((item) => {
return item.id
})
const res = await api.updateRecommendSeriesOrders(ids)
if (res.status === 200 && res.data.success === true) {
this.notifySuccess(res.data.message || '수정되었습니다.')
}
},
async searchSeries() {
if (this.search_query_series === null || this.search_query_series.length < 2) {
this.series = [];
return;
}
this.is_loading = true;
try {
const res = await seriesApi.searchSeriesList(this.search_query_series);
if (res.status === 200 && res.data.success === true) {
this.series = res.data.data.map((item) => {
return {name: item.title, value: item.id}
})
} else {
this.series = []
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
} finally {
this.is_loading = false
}
},
}
}
</script>
<style scoped>
.image-select label {
display: inline-block;
padding: 10px 20px;
background-color: #232d4a;
color: #fff;
vertical-align: middle;
font-size: 15px;
cursor: pointer;
border-radius: 5px;
}
.v-file-input {
position: absolute;
width: 0;
height: 0;
padding: 0;
overflow: hidden;
border: 0;
}
.image-preview {
max-width: 100%;
width: 250px;
object-fit: cover;
margin-top: 10px;
}
</style>