Compare commits

..

240 Commits

Author SHA1 Message Date
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
Yu Sung
152fe817e8 콘텐츠 배너 등록/수정
- 크리에이터/시리즈 검색시 2글자 부터 검색이 되도록 수정
2025-01-18 01:44:10 +09:00
e70426af68 Merge pull request 'test' (#53) from test into main
Reviewed-on: #53
2025-01-17 06:00:59 +00:00
Yu Sung
8f3a3ec8cc 콘텐츠 배너 등록/수정 - 시리즈 연결 추가 2025-01-17 14:28:08 +09:00
Yu Sung
f558c9260e 콘텐츠 배너 등록/수정
- 크리에이터 배너 등록시 크리에이터를 검색해서 등록할 수 있도록 수정
2025-01-16 14:54:15 +09:00
Yu Sung
c2a4a64417 이벤트 배너 API - Admin API URL로 변경 2025-01-16 02:34:48 +09:00
81b33e1322 Merge pull request '오디션 지원 취소기능 적용' (#52) from test into main
Reviewed-on: #52
2025-01-08 06:34:54 +00:00
Yu Sung
6edd6c1558 오디션 지원 취소기능 적용 2025-01-08 15:25:31 +09:00
588fcfbe90 Merge pull request '오디션 지원자 연락처 표시' (#51) from test into main
Reviewed-on: #51
2025-01-07 20:10:23 +00:00
Yu Sung
13958733a7 오디션 지원자 연락처 표시 2025-01-08 04:36:35 +09:00
ff2c126382 Merge pull request '오디션 메뉴 추가' (#50) from test into main
Reviewed-on: #50
2025-01-07 17:20:32 +00:00
Yu Sung
67a17a44aa 마감날짜 제거 2025-01-05 15:28:48 +09:00
Yu Sung
a0a80bf626 오디션 배역 상세 페이지 추가 2024-12-28 05:31:22 +09:00
Yu Sung
4b9259c525 오디션 배역 등록/수정 - 배역 정보 추가 2024-12-28 03:53:48 +09:00
Yu Sung
d5d365d0ad 오디션 상세페이지 - 배역 리스트/등록/수정/삭제 추가 2024-12-28 02:34:28 +09:00
Yu Sung
521345b9c8 오디션 상세 페이지 추가 2024-12-27 23:43:42 +09:00
Yu Sung
fedecfed99 오디션 관리 - 모집상태 추가 2024-12-27 02:45:22 +09:00
Yu Sung
0537b6df7d 오디션 관리 - 페이지 추가 2024-12-27 01:40:31 +09:00
702daca29f Merge pull request '소다라이브 -> 보이스온' (#49) from test into main
Reviewed-on: #49
2024-11-21 12:59:05 +00:00
Yu Sung
db44737b89 소다라이브 -> 보이스온 2024-11-21 21:55:51 +09:00
8e9008a3c1 Merge pull request '이벤트 기간 추가' (#48) from test into main
Reviewed-on: #48
2024-10-31 03:17:44 +00:00
Yu Sung
fa8b1c81f6 이벤트 기간 추가 2024-10-31 00:20:26 +09:00
5c0c00aad4 Merge pull request '크리에이터 리스트 - 프로필 이미지 다운로드 버튼 추가' (#47) from test into main
Reviewed-on: #47
2024-10-24 03:07:00 +00:00
Yu Sung
b71dff04fb 크리에이터 리스트 - 프로필 이미지 다운로드 버튼 추가 2024-10-24 11:58:56 +09:00
e0949c6d73 Merge pull request 'test' (#46) from test into main
Reviewed-on: #46
2024-10-16 04:18:55 +00:00
Yu Sung
29884883eb 불필요한 log 제거 2024-10-16 13:17:41 +09:00
Yu Sung
f460b76ff7 콘텐츠 리스트 - 오픈 / 오픈 예정 추가 2024-10-16 12:34:04 +09:00
0449bac8d5 Merge pull request '전체 개수 추가' (#45) from test into main
Reviewed-on: #45
2024-10-15 04:18:01 +00:00
Yu Sung
704b8803f5 전체 개수 추가 2024-10-15 13:16:06 +09:00
d412c15c9d Merge pull request '시리즈 리스트 - 작품 개수 추가' (#44) from test into main
Reviewed-on: #44
2024-10-14 15:43:15 +00:00
Yu Sung
b9c35cb22b 시리즈 리스트 - 작품 개수 추가 2024-10-15 00:38:19 +09:00
ed16a6ddad Merge pull request '시리즈 리스트 페이지 추가' (#43) from test into main
Reviewed-on: #43
2024-10-14 10:14:19 +00:00
Yu Sung
5ba0d379e1 시리즈 리스트 페이지 추가 2024-10-14 19:06:52 +09:00
f06e2d41e0 Merge pull request '전체 크리에이터 수 추가' (#42) from test into main
Reviewed-on: #42
2024-09-26 11:38:53 +00:00
Yu Sung
77559bb4a9 전체 크리에이터 수 추가 2024-09-26 20:31:04 +09:00
7505269db3 Merge pull request '크리에이터별 정산 - 페이징 추가' (#41) from test into main
Reviewed-on: #41
2024-08-01 05:16:04 +00:00
Yu Sung
e73a534583 크리에이터별 정산 - 페이징 추가 2024-08-01 14:13:44 +09:00
15eeb6943d Merge pull request '크리에이터 기준 라이브, 콘텐츠, 커뮤니티 합계 정산 페이지 추가' (#40) from test into main
Reviewed-on: #40
2024-07-08 14:41:28 +00:00
Yu Sung
ee0daccace 크리에이터 기준 라이브, 콘텐츠, 커뮤니티 합계 정산 페이지 추가 2024-07-08 23:39:32 +09:00
7e7ed46cea Merge pull request '크리에이터 기준 라이브, 콘텐츠, 커뮤니티 합계 정산 페이지 추가' (#39) from test into main
Reviewed-on: #39
2024-07-08 14:37:08 +00:00
Yu Sung
c5c1a886c0 크리에이터 기준 라이브, 콘텐츠, 커뮤니티 합계 정산 페이지 추가 2024-07-08 23:35:18 +09:00
fd01786649 Merge pull request 'test' (#38) from test into main
Reviewed-on: #38
2024-07-08 14:22:01 +00:00
Yu Sung
dba6bff90c 크리에이터 기준 콘텐츠, 커뮤니티 합계 정산 페이지 추가 2024-07-08 23:14:38 +09:00
Yu Sung
d4a0dfb223 크리에이터 기준 라이브 합계 정산 페이지 추가 2024-07-08 15:53:58 +09:00
c48c1c2f09 Merge pull request '크리에이터 정산비율 등록페이지 추가' (#37) from test into main
Reviewed-on: #37
2024-06-11 08:11:57 +00:00
Yu Sung
e8dc2fff95 크리에이터 정산비율 등록페이지 추가 2024-06-11 13:33:30 +09:00
9bcf3a3cdb Merge pull request '커뮤니티 정산' (#36) from test into main
Reviewed-on: #36
2024-06-06 14:59:58 +00:00
Yu Sung
b1014cf1e8 커뮤니티 정산 - 엑셀다운로드 추가 2024-06-06 23:52:11 +09:00
Yu Sung
9fa87d6cd0 커뮤니티 정산 - 날짜 이동 후 조회 추가 2024-06-06 23:40:13 +09:00
4c5b987d98 Merge pull request 'test' (#35) from test into main
Reviewed-on: #35
2024-06-03 22:24:23 +00:00
Yu Sung
1ed31395e9 시그니처 캔 리스트 - 정렬 추가 2024-05-30 13:31:37 +09:00
Yu Sung
e7063b432d 시그니처 캔 리스트 - 정렬 추가 2024-05-30 13:08:45 +09:00
Yu Sung
00616f917b 커뮤니티 정산 - 게시물 1개 가격 표시 2024-05-30 12:58:32 +09:00
f168403048 Merge pull request '라이브 리스트 - 현재참여인원 추가' (#34) from test into main
Reviewed-on: #34
2024-05-28 18:16:18 +00:00
Yu Sung
e2efeab20d 라이브 리스트 - 현재참여인원 추가 2024-05-29 03:08:44 +09:00
82ee1584e7 Merge pull request '커뮤니티 정산 페이지 추가' (#33) from test into main
Reviewed-on: #33
2024-05-28 16:13:16 +00:00
Yu Sung
512ae21a48 커뮤니티 정산 페이지 추가 2024-05-29 01:02:17 +09:00
65cb918389 Merge pull request '시그니처 관리 - 재생 시간 등록/수정 기능 추가' (#32) from test into main
Reviewed-on: #32
2024-05-02 07:17:34 +00:00
Yu Sung
36b0442878 시그니처 관리 - 재생 시간 등록/수정 기능 추가 2024-05-02 15:58:49 +09:00
784baf9a2f Merge pull request '시리즈 장르 - 등록/삭제 페이지 추가' (#31) from test into main
Reviewed-on: #31
2024-04-26 19:06:32 +00:00
Yu Sung
8642b95ec7 시리즈 장르 - 등록/삭제 페이지 추가 2024-04-18 14:26:49 +09:00
7a85ac41cc Merge pull request '관리자 - 캔 충전현황' (#30) from test into main
Reviewed-on: #30
2024-04-01 11:34:15 +00:00
Yu Sung
00e5c5e7f9 관리자 - 캔 충전현황
- 응답값에 화폐 locale 추가
2024-04-01 20:31:48 +09:00
9d4c9437cf Merge pull request '관리자 - 캔 충전현황' (#29) from test into main
Reviewed-on: #29
2024-04-01 11:27:14 +00:00
Yu Sung
9842019cdd 관리자 - 캔 충전현황
- 응답값에 화폐 locale 추가
2024-04-01 20:17:56 +09:00
68845aeae1 Merge pull request '콘텐츠 리스트 한정판 표시' (#28) from test into main
Reviewed-on: #28
2024-03-29 05:00:01 +00:00
Yu Sung
ddf0ddb8f6 콘텐츠 리스트 한정판 표시
- 남은 개수를 표시하던 방식에서 팔린 개수 표시로 변경 (판매개수/전체개수)
- 매진되면 Sold Out으로 표시
2024-03-29 11:51:54 +09:00
bbdca29337 Merge pull request '콘텐츠 리스트' (#27) from test into main
Reviewed-on: #27
2024-03-28 06:43:25 +00:00
Yu Sung
ac5e184a47 콘텐츠 리스트
- 한정판 여부 표시
2024-03-27 19:49:59 +09:00
c14c041daa Merge pull request 'test' (#26) from test into main
Reviewed-on: #26
2024-03-19 07:50:16 +00:00
Yu Sung
5a79ef93d2 콘텐츠
- 내용, 태그를 3줄로 표시
- show more를 터치하면 전체내용 표시
2024-03-19 16:46:59 +09:00
Yu Sung
7cd8673564 회원리스트
- 프로필 이미지 다운로드 추가
2024-03-19 16:37:07 +09:00
Yu Sung
ae2b70deb2 회원리스트
- 프로필 이미지 다운로드 추가
2024-03-19 16:29:45 +09:00
a515a144eb Merge pull request 'test' (#25) from test into main
Reviewed-on: #25
2024-03-13 11:42:23 +00:00
Yu Sung
4051f11a9e 시그니처 관리
- 수정사항이 없습니다 -> 변경사항이 없습니다.
2024-03-13 19:23:38 +09:00
Yu Sung
9d85b737d8 시그니처 관리
- 캔 수정, 19금 설정 추가
2024-03-13 19:19:01 +09:00
54a6773905 Merge pull request 'test' (#24) from test into main
Reviewed-on: #24
2024-03-12 07:54:10 +00:00
Yu Sung
995e4e6e7d 시그니처 관리
- 수정/삭제 추가
2024-03-12 16:49:52 +09:00
Yu Sung
62514b6145 시그니처 관리
- 컬럼 크기 변경
2024-03-11 18:22:30 +09:00
d97087b4e9 Merge pull request '시그니처 캔 등록 페이지 추가' (#23) from test into main
Reviewed-on: #23
2024-03-08 13:59:51 +00:00
Yu Sung
8572f8e49f 시그니처 캔 등록 페이지 추가 2024-03-08 03:13:21 +09:00
ddb2449053 Merge pull request '파비콘 변경' (#22) from test into main
Reviewed-on: #22
2024-02-17 14:56:25 +00:00
Yu Sung
514a2b5770 파비콘 변경 2024-02-17 23:50:07 +09:00
8aca07cdf7 Merge pull request '콘텐츠 수정' (#21) from test into main
Reviewed-on: #21
2024-02-08 18:25:50 +00:00
Yu Sung
e85ab62483 콘텐츠 수정
- 테마 수정 기능 추가
2024-02-09 03:15:43 +09:00
0ba845d95a Merge pull request '콘텐츠 리스트 - 오픈예정일 추가' (#20) from test into main
Reviewed-on: #20
2024-01-11 09:27:25 +00:00
Yu Sung
02cfe40836 콘텐츠 리스트 - 오픈예정일 추가 2024-01-11 18:19:54 +09:00
64b1fd5395 Merge pull request '수정 기능 추가' (#19) from test into main
Reviewed-on: #19
2024-01-03 15:25:28 +00:00
Yu Sung
335716f860 수정 기능 추가 2024-01-04 00:02:07 +09:00
639bea70fa Merge pull request '쿠폰 관리 페이지 추가' (#18) from test into main
Reviewed-on: #18
2024-01-03 10:33:23 +00:00
Yu Sung
aa3c3211aa 엑셀다운로드 기능 추가 2024-01-02 06:39:02 +09:00
Yu Sung
2ea2421f7c 쿠폰 번호 리스트 - 4자리 마다 하이픈 추가 2024-01-02 05:26:24 +09:00
Yu Sung
76909fc232 쿠폰 관리 페이지 추가 2024-01-02 04:59:17 +09:00
6a89ba059b Merge pull request '푸시 발송 대상 지정 UI 추가' (#17) from test into main
Reviewed-on: #17
2023-11-24 07:01:10 +00:00
Yu Sung
e75b54679f 푸시 발송 대상 지정 UI 추가 2023-11-24 14:29:27 +09:00
ff83041585 Merge pull request '연령제한 표시 추가' (#16) from test into main
Reviewed-on: #16
2023-11-21 16:25:21 +00:00
Yu Sung
28f21b1e03 연령제한 표시 추가 2023-11-22 01:16:46 +09:00
e660be0bf4 Merge pull request '일자별 콘텐츠 후원 페이지 - 유/무료 구분 추가' (#15) from test into main
Reviewed-on: #15
2023-11-14 13:36:49 +00:00
Yu Sung
3b4edd63e8 일자별 콘텐츠 후원 페이지 - 유/무료 구분 추가 2023-11-14 22:35:17 +09:00
62cdd57069 Merge pull request '일자별 콘텐츠 후원 페이지 - 유/무료 구분 추가' (#14) from test into main
Reviewed-on: #14
2023-11-14 13:34:37 +00:00
Yu Sung
c174724458 일자별 콘텐츠 후원 페이지 - 유/무료 구분 추가 2023-11-14 22:32:57 +09:00
f8346ed5ef Merge pull request '일자별 콘텐츠 후원 페이지 - 유/무료 구분 추가' (#13) from test into main
Reviewed-on: #13
2023-11-14 13:19:50 +00:00
Yu Sung
e65354789a 일자별 콘텐츠 후원 페이지 - 유/무료 구분 추가 2023-11-14 22:07:02 +09:00
9656b9a9d1 Merge pull request '일자별 콘텐츠 후원 페이지 추가' (#12) from test into main
Reviewed-on: #12
2023-11-14 08:57:15 +00:00
Yu Sung
e1ce77b811 일자별 콘텐츠 후원 페이지 - 합계 셀 병합 개수 수정 2023-11-14 17:45:48 +09:00
Yu Sung
6cab1e8894 일자별 콘텐츠 후원 페이지 추가 2023-11-14 17:39:43 +09:00
97a58266bb Merge pull request 'orderType 추가, 판매수 -> 누적 판매수 로 변경' (#11) from test into main
Reviewed-on: #11
2023-11-13 14:52:38 +00:00
Yu Sung
5df44fac76 orderType 추가, 판매수 -> 누적 판매수 로 변경 2023-11-13 23:46:37 +09:00
8fc0cfa345 Merge pull request '콘텐츠별 누적 현황 페이지 - 총 콘텐츠 개수 표시' (#10) from test into main
Reviewed-on: #10
2023-11-13 14:08:26 +00:00
Yu Sung
710f6f5a49 콘텐츠별 누적 현황 페이지 - 총 콘텐츠 개수 표시 2023-11-13 23:05:56 +09:00
22f9c2287d Merge pull request '콘텐츠별 누적 현황 페이지 추가' (#9) from test into main
Reviewed-on: #9
2023-11-13 13:46:19 +00:00
Yu Sung
df893ab795 콘텐츠별 누적 현황 페이지 추가 2023-11-13 22:39:46 +09:00
9284f7d5c3 Merge pull request '콘텐츠 정산 - 엑셀 다운로드 추가' (#8) from test into main
Reviewed-on: #8
2023-11-13 08:46:43 +00:00
Yu Sung
5be8eabb5b 콘텐츠 정산 - 엑셀 다운로드 추가 2023-11-13 14:41:25 +09:00
e6f27a4529 Merge pull request '콘텐츠 정산 - 헤더 순서 변경' (#7) from test into main
Reviewed-on: #7
2023-11-10 13:56:50 +00:00
Yu Sung
400717991f 콘텐츠 정산 - 헤더 순서 변경 2023-11-10 22:51:27 +09:00
6a33d1c024 Merge pull request '콘텐츠 정산 페이지 추가' (#6) from test into main
Reviewed-on: #6
2023-11-10 10:51:32 +00:00
Yu Sung
6076412b45 콘텐츠 정산 페이지 추가 2023-11-10 19:44:26 +09:00
3b83789c15 Merge pull request 'test' (#5) from test into main
Reviewed-on: #5
2023-10-06 15:09:02 +00:00
Yu Sung
60e5551109 콘텐츠 커버이미지 다운로드 버튼 추가 2023-10-07 00:05:52 +09:00
Yu Sung
93294066bb 크리에이터 라이브 정산 - coin -> can 2023-10-04 00:41:50 +09:00
55f0ab9af3 Merge pull request '크리에이터 라이브 정산 - 인원 추가, 코인 -> 캔' (#4) from test into main
Reviewed-on: #4
2023-10-03 12:15:25 +00:00
Yu Sung
6f1e57bc6a 크리에이터 라이브 정산 - 인원 추가, 코인 -> 캔 2023-10-03 21:09:12 +09:00
9b168a6112 Merge pull request '크리에이터 라이브 정산 페이지 추가' (#3) from test into main
Reviewed-on: #3
2023-10-03 09:25:39 +00:00
Yu Sung
9464bf55e0 크리에이터 라이브 정산 페이지 추가 2023-10-03 18:19:15 +09:00
c47937933e Merge pull request 'test' (#2) from test into main
Reviewed-on: #2
2023-08-25 07:50:32 +00:00
Yu Sung
a22fbca9fd 라이브 관심사, 크리에이터 관심사 - 연령제한 추가 2023-08-25 16:37:03 +09:00
Yu Sung
b8b45554e9 콘텐츠 공유 - 파이어베이스 링크, 도메인, 프로젝트명 변경 2023-08-25 16:36:27 +09:00
4744fe7d9a Merge pull request '채널공유 - 파이어베이스 링크, 도메인, 프로젝트명 변경' (#1) from test into main
Reviewed-on: #1
2023-08-22 03:39:44 +00:00
Yu Sung
4e7a2cb233 채널공유 - 파이어베이스 링크, 도메인, 프로젝트명 변경 2023-08-22 11:39:33 +09:00
Yu Sung
581127b4e0 푸시 발송 - account -> member 로 변경 2023-08-08 17:02:39 +09:00
Yu Sung
423316cab4 요즘친구 -> 크리에이터 로 변경 2023-08-07 15:43:07 +09:00
Yu Sung
c788a9e7dd 관리자 - 추천 라이브 크리에이터 배너 API 적용 2023-08-07 03:11:47 +09:00
Yu Sung
b6fcbaa11a 콘텐츠 메인 상단 배너 - 크리에이터 불러오는 API 수정 2023-08-07 01:19:53 +09:00
Yu Sung
80f819744e 라이브 관심사 - API URL 수정 2023-08-06 22:42:31 +09:00
Yu Sung
fb08221aa3 캔, 충전현황 페이지 API 연동 2023-08-06 14:27:31 +09:00
Yu Sung
f66bfd8524 회원 태그 - CRUD API 추가 2023-08-06 13:52:03 +09:00
Yu Sung
1d4bcd601d 회원리스트 페이지 2023-08-06 11:12:35 +09:00
88 changed files with 18759 additions and 1043 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

@@ -1,4 +1,4 @@
# yozm-admin
# soda-live-admin
## Project setup
```

86
package-lock.json generated
View File

@@ -1,18 +1,23 @@
{
"name": "yozm-admin",
"name": "soda-live-admin",
"version": "0.1.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "yozm-admin",
"name": "soda-live-admin",
"version": "0.1.0",
"dependencies": {
"core-js": "^3.6.5",
"file-saver": "^2.0.5",
"lodash": "^4.17.21",
"vue": "^2.6.11",
"vue-excel-xlsx": "^1.2.2",
"vue-router": "^3.2.0",
"vue-show-more-text": "^2.0.2",
"vue2-datepicker": "^3.11.1",
"vue2-editor": "^2.10.3",
"vue2-timepicker": "^1.1.6",
"vuedraggable": "^2.24.3",
"vuejs-datetimepicker": "^1.1.13",
"vuetify": "2.6.10",
@@ -5237,6 +5242,11 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz",
"integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw=="
},
"node_modules/date-format-parse": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/date-format-parse/-/date-format-parse-0.2.7.tgz",
"integrity": "sha512-/+lyMUKoRogMuTeOVii6lUwjbVlesN9YRYLzZT/g3TEZ3uD9QnpjResujeEqUW+OSNbT7T1+SYdyEkTcRv+KDQ=="
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@@ -6809,6 +6819,11 @@
"webpack": "^4.0.0"
}
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -9011,8 +9026,7 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
@@ -14596,6 +14610,15 @@
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.3.tgz",
"integrity": "sha512-FUlILrW3DGitS2h+Xaw8aRNvGTwtuaxrRkNSHWTizOfLUie7wuYwezeZ50iflRn8YPV5kxmU2LQuu3nM/b3Zsg=="
},
"node_modules/vue-show-more-text": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/vue-show-more-text/-/vue-show-more-text-2.0.2.tgz",
"integrity": "sha512-x/WuikWAx8Hm4gpZx6KHtJYiXDordGdSoXrd34lTiJeAnlT8Y7Yc0FfGBNdUv6mXncuET3LiRwwNz+X5gI+oiw==",
"dependencies": {
"core-js": "^3.6.5",
"vue": "^2.6.11"
}
},
"node_modules/vue-style-loader": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.3.tgz",
@@ -14628,6 +14651,17 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true
},
"node_modules/vue2-datepicker": {
"version": "3.11.1",
"resolved": "https://registry.npmjs.org/vue2-datepicker/-/vue2-datepicker-3.11.1.tgz",
"integrity": "sha512-6PU/+pnp2mgZAfnSXmbdwj9516XsEvTiw61Q5SNrvvdy8W/FCxk1GAe9UZn/m9YfS5A47yK6XkcjMHbp7aFApA==",
"dependencies": {
"date-format-parse": "^0.2.7"
},
"peerDependencies": {
"vue": "^2.5.0"
}
},
"node_modules/vue2-editor": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/vue2-editor/-/vue2-editor-2.10.3.tgz",
@@ -14636,6 +14670,14 @@
"quill": "^1.3.6"
}
},
"node_modules/vue2-timepicker": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/vue2-timepicker/-/vue2-timepicker-1.1.6.tgz",
"integrity": "sha512-DhO2UH4CTer/lMWxg+jqxn/+h6g2vZrsM6vCe9u7/Ie+Pej9yA+8mQA3C3VPApZ+LauKc43WxCspOXb6SGBOTw==",
"peerDependencies": {
"vue": "^2.6.5"
}
},
"node_modules/vuedraggable": {
"version": "2.24.3",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.24.3.tgz",
@@ -19930,6 +19972,11 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz",
"integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw=="
},
"date-format-parse": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/date-format-parse/-/date-format-parse-0.2.7.tgz",
"integrity": "sha512-/+lyMUKoRogMuTeOVii6lUwjbVlesN9YRYLzZT/g3TEZ3uD9QnpjResujeEqUW+OSNbT7T1+SYdyEkTcRv+KDQ=="
},
"de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@@ -21169,6 +21216,11 @@
"schema-utils": "^2.5.0"
}
},
"file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
},
"file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -22856,8 +22908,7 @@
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"lodash.debounce": {
"version": "4.0.8",
@@ -27432,6 +27483,15 @@
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.3.tgz",
"integrity": "sha512-FUlILrW3DGitS2h+Xaw8aRNvGTwtuaxrRkNSHWTizOfLUie7wuYwezeZ50iflRn8YPV5kxmU2LQuu3nM/b3Zsg=="
},
"vue-show-more-text": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/vue-show-more-text/-/vue-show-more-text-2.0.2.tgz",
"integrity": "sha512-x/WuikWAx8Hm4gpZx6KHtJYiXDordGdSoXrd34lTiJeAnlT8Y7Yc0FfGBNdUv6mXncuET3LiRwwNz+X5gI+oiw==",
"requires": {
"core-js": "^3.6.5",
"vue": "^2.6.11"
}
},
"vue-style-loader": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.3.tgz",
@@ -27466,6 +27526,14 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true
},
"vue2-datepicker": {
"version": "3.11.1",
"resolved": "https://registry.npmjs.org/vue2-datepicker/-/vue2-datepicker-3.11.1.tgz",
"integrity": "sha512-6PU/+pnp2mgZAfnSXmbdwj9516XsEvTiw61Q5SNrvvdy8W/FCxk1GAe9UZn/m9YfS5A47yK6XkcjMHbp7aFApA==",
"requires": {
"date-format-parse": "^0.2.7"
}
},
"vue2-editor": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/vue2-editor/-/vue2-editor-2.10.3.tgz",
@@ -27474,6 +27542,12 @@
"quill": "^1.3.6"
}
},
"vue2-timepicker": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/vue2-timepicker/-/vue2-timepicker-1.1.6.tgz",
"integrity": "sha512-DhO2UH4CTer/lMWxg+jqxn/+h6g2vZrsM6vCe9u7/Ie+Pej9yA+8mQA3C3VPApZ+LauKc43WxCspOXb6SGBOTw==",
"requires": {}
},
"vuedraggable": {
"version": "2.24.3",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.24.3.tgz",

View File

@@ -1,5 +1,5 @@
{
"name": "yozm-admin",
"name": "soda-live-admin",
"version": "0.1.0",
"private": true,
"scripts": {
@@ -11,10 +11,15 @@
},
"dependencies": {
"core-js": "^3.6.5",
"file-saver": "^2.0.5",
"lodash": "^4.17.21",
"vue": "^2.6.11",
"vue-excel-xlsx": "^1.2.2",
"vue-router": "^3.2.0",
"vue-show-more-text": "^2.0.2",
"vue2-datepicker": "^3.11.1",
"vue2-editor": "^2.10.3",
"vue2-timepicker": "^1.1.6",
"vuedraggable": "^2.24.3",
"vuejs-datetimepicker": "^1.1.13",
"vuetify": "2.6.10",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -5,7 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
<title>보이스온 관리자</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css">
</head>

View File

@@ -7,7 +7,7 @@
dark
>
<v-spacer />
<v-toolbar-title>요즘 관리자</v-toolbar-title>
<v-toolbar-title>보이스온 관리자</v-toolbar-title>
<v-spacer />
</v-app-bar>

View File

@@ -1,13 +1,13 @@
import Vue from 'vue';
async function getAudioContentList(page) {
async function getAudioContentList(status, page) {
return Vue.axios.get(
"/admin/audio-content/list?page=" + (page - 1) +
"/admin/audio-content/list?status=" + status + "&page=" + (page - 1) +
"&size=10"
)
}
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) {
@@ -59,6 +59,92 @@ async function updateCurationOrders(ids) {
return Vue.axios.put('/admin/audio-content/curation/orders', {ids: ids})
}
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,
@@ -70,5 +156,22 @@ export {
getCurations,
saveCuration,
modifyCuration,
updateCurationOrders
updateCurationOrders,
getAudioContentThemeList,
getAudioContentMainTabList,
getCurationItems,
searchSeriesItem,
searchContentItem,
addItemToCuration,
removeItemInCuration,
updateItemInCurationOrders,
getHashTagCurations,
saveHashTagCuration,
modifyHashTagCuration,
updateHashTagCurationOrders,
getHashTagCurationItems,
addItemToHashTagCuration,
removeItemInHashTagCuration,
searchHashTagContentItem,
updateItemInHashTagCurationOrders
}

View File

@@ -0,0 +1,34 @@
import Vue from 'vue';
async function getAudioContentSeriesList(page) {
return Vue.axios.get("/admin/audio-content/series?page=" + (page - 1) + "&size=10");
}
async function getAudioContentSeriesGenreList() {
return Vue.axios.get('/admin/audio-content/series/genre');
}
async function createAudioContentSeriesGenre(genre, is_adult) {
return Vue.axios.post('/admin/audio-content/series/genre', {genre: genre, isAdult: is_adult})
}
async function updateAudioContentSeriesGenre(request) {
return Vue.axios.put('/admin/audio-content/series/genre', request)
}
async function updateAudioContentSeriesGenreOrders(ids) {
return Vue.axios.put('/admin/audio-content/series/genre/orders', {ids: ids})
}
async function searchSeriesList(searchWord) {
return Vue.axios.get("/admin/audio-content/series/search?search_word=" + searchWord)
}
export {
getAudioContentSeriesList,
getAudioContentSeriesGenreList,
createAudioContentSeriesGenre,
updateAudioContentSeriesGenre,
updateAudioContentSeriesGenreOrders,
searchSeriesList
}

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
}

59
src/api/audition.js Normal file
View File

@@ -0,0 +1,59 @@
import Vue from 'vue';
async function getAuditionList(page) {
return Vue.axios.get("/admin/audition?page=" + (page - 1) + "&size=20");
}
async function createAudition(formData) {
return Vue.axios.post("/admin/audition", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
async function updateAudition(formData) {
return Vue.axios.put("/admin/audition", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
async function getAuditionDetail(id) {
return Vue.axios.get("/admin/audition/" + id);
}
async function createAuditionRole(formData) {
return Vue.axios.post("/admin/audition/role", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
async function updateAuditionRole(formData) {
return Vue.axios.put("/admin/audition/role", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
async function getAuditionRoleDetail(id) {
return Vue.axios.get("/admin/audition/role/" + id);
}
async function getAuditionApplicantList(id, page) {
return Vue.axios.get("/admin/audition/role/" + id + "/applicant?page=" + (page - 1) + "&size=20");
}
async function deleteAuditionApplicant(id) {
return Vue.axios.delete("/admin/audition/applicant/" + id);
}
export {
getAuditionList, createAudition, updateAudition, getAuditionDetail,
createAuditionRole, updateAuditionRole, getAuditionRoleDetail, getAuditionApplicantList,
deleteAuditionApplicant
}

View File

@@ -1,7 +1,71 @@
import Vue from 'vue';
async function getCalculateCreator(startDate, endDate) {
return Vue.axios.get('/admin/calculate/creator?startDateStr=' + startDate + '&endDateStr=' + endDate);
async function getCalculateLive(startDate, endDate) {
return Vue.axios.get('/admin/calculate/live?startDateStr=' + startDate + '&endDateStr=' + endDate);
}
export { getCalculateCreator }
async function getCalculateContent(startDate, endDate) {
return Vue.axios.get('/admin/calculate/content-list?startDateStr=' + startDate + '&endDateStr=' + endDate);
}
async function getCumulativeSalesByContent(page, size) {
return Vue.axios.get('/admin/calculate/cumulative-sales-by-content?page=' + (page - 1) + "&size=" + size);
}
async function getCalculateContentDonation(startDate, endDate) {
return Vue.axios.get('/admin/calculate/content-donation-list?startDateStr=' + startDate + '&endDateStr=' + endDate);
}
async function getCalculateCommunityPost(startDate, endDate, page, size) {
return Vue.axios.get(
'/admin/calculate/community-post?startDateStr=' +
startDate + '&endDateStr=' + endDate + "&page=" + (page - 1) + "&size=" + size
);
}
async function getSettlementRatio(page) {
return Vue.axios.get('/admin/calculate/ratio?page=' + (page - 1) + "&size=20'");
}
async function createCreatorSettlementRatio(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", request)
}
async function getCalculateLiveByCreator(startDate, endDate, page, size) {
return Vue.axios.get('/admin/calculate/live-by-creator?startDateStr=' +
startDate + '&endDateStr=' + endDate + '&page=' + (page - 1) + '&size=' + size
)
}
async function getCalculateContentByCreator(startDate, endDate, page, size) {
return Vue.axios.get('/admin/calculate/content-by-creator?startDateStr=' +
startDate + '&endDateStr=' + endDate + '&page=' + (page - 1) + '&size=' + size
)
}
async function getCalculateCommunityByCreator(startDate, endDate, page, size) {
return Vue.axios.get('/admin/calculate/community-by-creator?startDateStr=' +
startDate + '&endDateStr=' + endDate + '&page=' + (page - 1) + '&size=' + size
)
}
export {
getCalculateLive,
getCalculateContent,
getCumulativeSalesByContent,
getCalculateContentDonation,
getCalculateCommunityPost,
getSettlementRatio,
createCreatorSettlementRatio,
getCalculateLiveByCreator,
getCalculateContentByCreator,
getCalculateCommunityByCreator
}

52
src/api/can.js Normal file
View File

@@ -0,0 +1,52 @@
import Vue from 'vue';
async function deleteCan(id) {
return Vue.axios.delete('/admin/can/' + id);
}
async function getCans() {
return Vue.axios.get('/can');
}
async function insertCan(can, rewardCan, price) {
const request = {can: can, rewardCan: rewardCan, price: price}
return Vue.axios.post('/admin/can', request);
}
async function paymentCan(can, method, member_id) {
const request = {memberId: member_id, method: method, can: can}
return Vue.axios.post('/admin/can/charge', request)
}
async function getCouponList(page) {
return Vue.axios.get('/can/coupon?page=' + (page - 1) + "&size=20");
}
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);
}
async function getCouponNumberList(couponId, page) {
return Vue.axios.get('/can/coupon/number-list?couponId=' + couponId + '&page=' + (page - 1) + "&size=20");
}
async function downloadCouponNumberList(couponId) {
return Vue.axios.get('/can/coupon/number-list/download?couponId=' + couponId, { responseType: 'blob' });
}
async function modifyCoupon(request) {
return Vue.axios.put('/can/coupon', request);
}
export {
getCans,
insertCan,
deleteCan,
paymentCan,
modifyCoupon,
getCouponList,
generateCoupon,
getCouponNumberList,
downloadCouponNumberList
}

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
}

View File

@@ -1,27 +0,0 @@
import Vue from 'vue';
async function deleteCoin(id) {
return Vue.axios.delete('/coin/' + id);
}
async function modifyCoin(id, coin, rewardCoin, price) {
const request = {id: id, coin: coin, rewardCoin: rewardCoin, price: price}
return Vue.axios.put('/coin', request);
}
async function getCoins() {
return Vue.axios.get('/coin');
}
async function insertCoin(coin, rewardCoin, price) {
const request = {coin: coin, rewardCoin: rewardCoin, price: price}
return Vue.axios.post('/coin', request);
}
async function paymentCoin(coin, method, account_id) {
const request = {accountId: account_id, method: method, coin: coin}
return Vue.axios.post('/admin/coin/charge', request)
}
export {getCoins, insertCoin, modifyCoin, deleteCoin, paymentCoin}

View File

@@ -1,7 +1,7 @@
import Vue from 'vue';
async function enrollment(formData) {
return Vue.axios.post('/account/counselor/tag', formData, {
return Vue.axios.post('/admin/member/tag', formData, {
headers: {
"Content-Type": "multipart/form-data",
},
@@ -9,15 +9,15 @@ async function enrollment(formData) {
}
async function getTags() {
return Vue.axios.get('/account/counselor/tag');
return Vue.axios.get('/member/tag');
}
async function deleteTag(tagId) {
return Vue.axios.delete('/account/counselor/tag/' + tagId)
return Vue.axios.delete('/admin/member/tag/' + tagId)
}
async function modifyTag(tagId, formData) {
return Vue.axios.put('/account/counselor/tag/' + tagId, formData, {
return Vue.axios.put('/admin/member/tag/' + tagId, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
@@ -25,7 +25,7 @@ async function modifyTag(tagId, formData) {
}
async function updateTagOrders(ids) {
return Vue.axios.put('/account/counselor/tag/orders', { ids: ids })
return Vue.axios.put('/admin/member/tag/orders', { ids: ids })
}
export { enrollment, getTags, deleteTag, modifyTag, updateTagOrders }

View File

@@ -1,7 +1,7 @@
import Vue from 'vue';
async function save(formData) {
return Vue.axios.post('/event', formData, {
return Vue.axios.post('/admin/event/banner', formData, {
headers: {
"Content-Type": "multipart/form-data",
},
@@ -9,7 +9,7 @@ async function save(formData) {
}
async function modify(formData) {
return Vue.axios.put('/event', formData, {
return Vue.axios.put('/admin/event/banner', formData, {
headers: {
"Content-Type": "multipart/form-data",
},
@@ -17,11 +17,11 @@ async function modify(formData) {
}
async function deleteEvent(id) {
return Vue.axios.delete("/event/" + id)
return Vue.axios.delete("/admin/event/banner/" + id)
}
async function getEvents() {
return Vue.axios.get("/event")
return Vue.axios.get("/admin/event/banner")
}
export {save, modify, deleteEvent, getEvents}

View File

@@ -5,14 +5,14 @@ const url = "https://firebasedynamiclinks.googleapis.com/v1/shortLinks?key=AIzaS
async function shareCreatorChannel(channelInfo, utmSource, utmMedium, utmCampaign) {
const data = {
"dynamicLinkInfo": {
"domainUriPrefix": "https://yozm.page.link",
"link": "https://yozm.day/?channel_id=" + channelInfo.id,
"domainUriPrefix": "https://sodalive.page.link",
"link": "https://sodalive.net/?channel_id=" + channelInfo.id,
"androidInfo": {
"androidPackageName": "kr.co.vividnext.sodalive",
},
"iosInfo": {
"iosBundleId": "kr.co.vividnext.yozm",
"iosAppStoreId": "1630284226"
"iosBundleId": "kr.co.vividnext.sodalive",
"iosAppStoreId": "6461721697"
},
"analyticsInfo": {
"googlePlayAnalytics": {
@@ -22,8 +22,8 @@ async function shareCreatorChannel(channelInfo, utmSource, utmMedium, utmCampaig
},
},
"socialMetaTagInfo": {
"socialTitle": "요즘라이브",
"socialDescription": "요즘라이브 " + channelInfo.nickname + "님의 채널입니다.",
"socialTitle": "보이스온",
"socialDescription": "보이스온 " + channelInfo.nickname + "님의 채널입니다.",
"socialImageLink": channelInfo.profileUrl
}
}
@@ -39,14 +39,14 @@ async function shareCreatorChannel(channelInfo, utmSource, utmMedium, utmCampaig
async function shareAudioContent(audioContent, utmSource, utmMedium, utmCampaign) {
const data = {
"dynamicLinkInfo": {
"domainUriPrefix": "https://yozm.page.link",
"link": "https://yozm.day/?audio_content_id=" + audioContent.audioContentId,
"domainUriPrefix": "https://sodalive.page.link",
"link": "https://sodalive.net/?audio_content_id=" + audioContent.audioContentId,
"androidInfo": {
"androidPackageName": "kr.co.vividnext.yozm",
"androidPackageName": "kr.co.vividnext.sodalive",
},
"iosInfo": {
"iosBundleId": "kr.co.vividnext.yozm",
"iosAppStoreId": "1630284226"
"iosBundleId": "kr.co.vividnext.sodalive",
"iosAppStoreId": "6461721697"
},
"analyticsInfo": {
"googlePlayAnalytics": {
@@ -57,7 +57,7 @@ async function shareAudioContent(audioContent, utmSource, utmMedium, utmCampaign
},
"socialMetaTagInfo": {
"socialTitle": audioContent.title + " - " + audioContent.creatorNickname,
"socialDescription": "지금 요즘라이브에서 이 콘텐츠 감상하기",
"socialDescription": "지금 보이스온에서 이 콘텐츠 감상하기",
"socialImageLink": audioContent.coverImageUrl
}
}

View File

@@ -1,7 +1,7 @@
import Vue from 'vue';
async function getLive() {
return Vue.axios.get('/admin/live')
async function getLive(page, size) {
return Vue.axios.get('/admin/live?page=' + (page - 1) + '&size=' + size)
}
export { getLive }

View File

@@ -1,7 +1,7 @@
import Vue from 'vue';
async function enrollment(formData) {
return Vue.axios.post('/suda/tag', formData, {
return Vue.axios.post('/live/tag', formData, {
headers: {
"Content-Type": "multipart/form-data",
},
@@ -9,15 +9,15 @@ async function enrollment(formData) {
}
async function getTags() {
return Vue.axios.get('/suda/tag');
return Vue.axios.get('/live/tag');
}
async function deleteTag(tagId) {
return Vue.axios.delete('/suda/tag/' + tagId)
return Vue.axios.delete('/live/tag/' + tagId)
}
async function modifyTag(tagId, formData) {
return Vue.axios.put('/suda/tag/' + tagId, formData, {
return Vue.axios.put('/live/tag/' + tagId, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
@@ -25,7 +25,7 @@ async function modifyTag(tagId, formData) {
}
async function updateTagOrders(ids) {
return Vue.axios.put('/suda/tag/orders', { ids: ids })
return Vue.axios.put('/live/tag/orders', { ids: ids })
}
export { enrollment, getTags, deleteTag, modifyTag, updateTagOrders }

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

@@ -8,46 +8,57 @@ async function login(email, password) {
});
}
async function getAccountList(page) {
async function getMemberList(page) {
return Vue.axios.get(
"/admin/account/list?page=" + (page - 1) +
"/admin/member/list?page=" + (page - 1) +
"&size=20"
)
}
async function searchAccount(searchWord, page) {
async function searchMember(searchWord, page) {
return Vue.axios.get(
"/admin/account/search?search_word=" + searchWord +
"/admin/member/search?search_word=" + searchWord +
"&page=" + (page - 1) +
"&size=20"
)
}
async function getCreatorAccountList(page) {
async function getCreatorList(page) {
return Vue.axios.get(
"/admin/account/creator/list?page=" + (page - 1) +
"/admin/member/creator/list?page=" + (page - 1) +
"&size=20"
)
}
async function searchCreatorAccount(searchWord, page) {
async function searchCreator(searchWord, page) {
return Vue.axios.get(
"/admin/account/creator/search?search_word=" + searchWord +
"/admin/member/creator/search?search_word=" + searchWord +
"&page=" + (page - 1) +
"&size=20"
)
}
async function updateAccount(id, user_type) {
async function updateMember(id, user_type) {
const request = {id, userType: user_type}
return Vue.axios.put("/admin/account", request)
return Vue.axios.put("/admin/member", request)
}
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,
getAccountList,
searchAccount,
getCreatorAccountList,
searchCreatorAccount,
updateAccount
getMemberList,
searchMember,
getCreatorList,
searchCreator,
updateMember,
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

@@ -1,11 +1,17 @@
import Vue from 'vue';
async function sendPush(accountIds, title, message) {
return Vue.axios.post('/push', {
accountIds,
async function sendPush(memberIds, title, message, isAuth) {
const request = {
memberIds,
title,
message
})
}
if (isAuth !== undefined && isAuth !== null && isAuth !== '') {
request.isAuth = isAuth
}
return Vue.axios.post('/push', request)
}
export { sendPush }

View File

@@ -1,27 +1,32 @@
import Vue from 'vue';
async function createRecommendSudaCreator(formData) {
return Vue.axios.post('/admin/suda/recommend-suda-creator', formData, {
async function createRecommendCreatorBanner(formData) {
return Vue.axios.post('/admin/live/recommend-creator', formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
async function updateRecommendSudaCreator(formData) {
return Vue.axios.put('/admin/suda/recommend-suda-creator', formData, {
async function updateRecommendCreatorBanner(formData) {
return Vue.axios.put('/admin/live/recommend-creator', formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
async function updateRecommendSudaCreatorOrders(firstOrders, ids) {
return Vue.axios.put('/admin/suda/recommend-suda-creator/orders', { firstOrders: firstOrders, ids: ids })
async function updateRecommendCreatorBannerOrders(firstOrders, ids) {
return Vue.axios.put('/admin/live/recommend-creator/orders', {firstOrders: firstOrders, ids: ids})
}
async function getRecommendSudaCreator(page) {
return Vue.axios.get("/admin/suda/recommend-suda-creator?page=" + (page - 1) + "&size=20");
async function getRecommendCreatorBanner(page) {
return Vue.axios.get("/admin/live/recommend-creator?page=" + (page - 1) + "&size=20");
}
export { createRecommendSudaCreator, updateRecommendSudaCreator, updateRecommendSudaCreatorOrders, getRecommendSudaCreator };
export {
createRecommendCreatorBanner,
updateRecommendCreatorBanner,
updateRecommendCreatorBannerOrders,
getRecommendCreatorBanner
};

23
src/api/signature.js Normal file
View File

@@ -0,0 +1,23 @@
import Vue from 'vue';
async function getSignatureList(page, sort) {
return Vue.axios.get('/admin/live/signature-can?page=' + (page - 1) + "&size=20" + "&sort-type=" + sort);
}
async function createSignature(formData) {
return Vue.axios.post('/admin/live/signature-can', formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
async function modifySignature(formData) {
return Vue.axios.put('/admin/live/signature-can', formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
export { getSignatureList, createSignature, modifySignature }

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

@@ -28,12 +28,17 @@ const routes = [
{
path: '/member/list',
name: 'MemberList',
component: () => import(/* webpackChunkName: "member" */ '../views/Account/MemberList.vue')
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',
component: () => import(/* webpackChunkName: "counselor" */ '../views/Creator/CreatorTags.vue')
component: () => import(/* webpackChunkName: "counselor" */ '../views/Creator/CreatorTags')
},
{
path: '/creator/list',
@@ -41,9 +46,9 @@ const routes = [
component: () => import(/* webpackChunkName: "counselor" */ '../views/Creator/CreatorList.vue')
},
{
path: '/creator/review',
path: '/creator/settlement-ratio',
name: 'CreatorReview',
component: () => import(/* webpackChunkName: "counselor" */ '../views/Creator/CreatorReview.vue')
component: () => import(/* webpackChunkName: "counselor" */ '../views/Creator/CreatorSettlementRatio.vue')
},
{
path: '/live/tags',
@@ -80,6 +85,41 @@ 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: "series" */ '../views/Series/ContentSeriesList.vue')
},
{
path: '/content/series/genre',
name: 'ContentSeriesGenre',
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',
name: 'EventView',
@@ -95,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',
@@ -108,22 +153,57 @@ const routes = [
{
path: '/can/status',
name: 'CoinStatus',
component: () => import(/* webpackChunkName: "coin" */ '../views/Can/CoinStatus.vue')
component: () => import(/* webpackChunkName: "coin" */ '../views/Can/CanStatus.vue')
},
{
path: '/calculate/creator',
name: 'CalculateCreator',
component: () => import(/* webpackChunkName: "calculate" */ '../views/Calculate/CalculateCreator.vue')
path: '/can/coupon',
name: 'CanCoupon',
component: () => import(/* webpackChunkName: "coin" */ '../views/Can/CanCoupon.vue')
},
{
path: '/calculate/content',
path: '/can/signature',
name: 'CanSignature',
component: () => import(/* webpackChunkName: "coin" */ '../views/Can/CanSignature.vue')
},
{
path: '/calculate/live',
name: 'CalculateLive',
component: () => import(/* webpackChunkName: "calculate" */ '../views/Calculate/CalculateLive.vue')
},
{
path: '/calculate/content-by-date',
name: 'CalculateContent',
component: () => import(/* webpackChunkName: "calculate" */ '../views/Calculate/CalculateContent.vue')
},
{
path: '/calculate/copyright',
name: 'CalculateCopyright',
component: () => import(/* webpackChunkName: "calculate" */ '../views/Calculate/CalculateCopyright.vue')
path: '/calculate/content-accumulation',
name: 'CalculateAccumulation',
component: () => import(/* webpackChunkName: "calculate" */ '../views/Calculate/CalculateAccumulation.vue')
},
{
path: '/calculate/content-donation-by-date',
name: 'CalculateContentDonation',
component: () => import(/* webpackChunkName: "calculate" */ '../views/Calculate/CalculateContentDonation.vue')
},
{
path: '/calculate/community-post',
name: 'CalculateCommunityPost',
component: () => import(/* webpackChunkName: "calculate" */ '../views/Calculate/CalculateCommunityPost.vue')
},
{
path: '/calculate/live-by-creator',
name: 'CalculateLiveByCreator',
component: () => import(/* webpackChunkName: "calculate" */ '../views/Calculate/CalculateLiveByCreator.vue')
},
{
path: '/calculate/content-by-creator',
name: 'CalculateContentByCreator',
component: () => import(/* webpackChunkName: "calculate" */ '../views/Calculate/CalculateContentByCreator.vue')
},
{
path: '/calculate/community-by-creator',
name: 'CalculateCommunityByCreator',
component: () => import(/* webpackChunkName: "calculate" */ '../views/Calculate/CalculateCommunityByCreator.vue')
},
{
path: '/notice',
@@ -150,6 +230,86 @@ const routes = [
name: 'PrivacyView',
component: () => import(/* webpackChunkName: "support" */ '../views/Support/PrivacyView.vue')
},
{
path: '/audition',
name: 'AuditionView',
component: () => import(/* webpackChunkName: "audition" */ '../views/Audition/AuditionView.vue')
},
{
path: '/audition/detail',
name: 'AuditionDetailView',
component: () => import(/* webpackChunkName: "audition" */ '../views/Audition/AuditionDetailView.vue')
},
{
path: '/audition/role/detail',
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

@@ -0,0 +1,606 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>{{ audition_title }}</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-container>
<v-row>
<v-col cols="4">
<v-card>
<v-img
:src="audition_detail.imageUrl"
class="audition-image"
/>
<v-card-text
v-if="
audition_detail.originalWorkUrl !== undefined &&
audition_detail.originalWorkUrl.length > 0
"
>
<a
:href="audition_detail.originalWorkUrl"
class="original-work-link"
>
원작링크
</a>
</v-card-text>
<v-card-text>
<b>오디션 정보</b>
<vue-show-more-text
:text="audition_detail.information"
:lines="3"
/>
</v-card-text>
</v-card>
</v-col>
<v-col cols="8">
<v-row>
<v-col cols="8" />
<v-col cols="4">
<v-btn
block
color="#3bb9f1"
dark
depressed
@click="showWriteDialog"
>
배역 등록
</v-btn>
</v-col>
</v-row>
<v-row v-if="audition_role_list.length > 0">
<v-col
v-for="(item, i) in audition_role_list"
:key="i"
cols="4"
>
<v-card>
<v-card-title>
<v-spacer />
<v-img
:src="item.imageUrl"
class="audition-image"
@click="selectAuditionRole(item)"
/>
<v-spacer />
</v-card-title>
<v-card-text class="audition-title-container">
{{ item.name }}
</v-card-text>
<v-card-text>
상태 : {{ getStatusStr(item.status) }}
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
text
@click="showModifyDialog(item)"
>
수정
</v-btn>
<v-btn
text
@click="deleteConfirm(item)"
>
삭제
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-row v-else>
<v-col>
등록된 배역이 없습니다.
</v-col>
</v-row>
</v-col>
</v-row>
</v-container>
<v-dialog
v-model="show_write_dialog"
max-width="1000px"
persistent
>
<v-card>
<v-card-title>
오디션 배역 등록
</v-card-title>
<div class="image-select">
<label for="image">
배역 이미지 등록
</label>
<v-file-input
id="image"
v-model="audition_role.image"
accept="image/*"
@change="imageAdd"
/>
</div>
<img
v-if="audition_role.image_url"
:src="audition_role.image_url"
alt=""
class="image-preview"
>
<v-card-text>
<v-row align="center">
<v-col cols="4">
배역 이름*
</v-col>
<v-col cols="8">
<v-text-field
v-model="audition_role.name"
label="배역 이름"
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-textarea
v-model="audition_role.information"
label="오디션 배역 정보"
required
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
오디션 대본 URL*
</v-col>
<v-col cols="8">
<v-text-field
v-model="audition_role.audition_script_url"
label="오디션 대본 URL"
required
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text v-if="selected_role !== null">
<v-row>
<v-col cols="4">
모집상태
</v-col>
<v-col
cols="8"
align="left"
>
<v-row>
<v-col>
<input
id="radio_in_progress"
v-model="audition_role.status"
type="radio"
value="IN_PROGRESS"
>
<label for="radio_in_progress"> 모집중</label>
</v-col>
<v-col>
<input
id="radio_complete"
v-model="audition_role.status"
type="radio"
value="COMPLETED"
>
<label for="radio_complete"> 모집완료</label>
</v-col>
</v-row>
</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_role !== 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-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="deleteAuditionRole"
>
확인
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import * as api from '@/api/audition'
import VueShowMoreText from 'vue-show-more-text'
export default {
name: "AuditionDetailView",
components: {VueShowMoreText},
data() {
return {
is_loading: false,
audition_id: 0,
audition_title: '',
audition_detail: {},
audition_role_list: [],
show_write_dialog: false,
show_delete_confirm_dialog: false,
audition_role: {},
selected_role: null,
}
},
async created() {
this.audition_id = this.$route.params.audition_id
this.audition_title = this.$route.params.audition_title
if (this.audition_id !== undefined && this.audition_id > 0) {
await this.getAuditionDetail()
} else {
this.$router.go(-1)
}
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
getStatusStr(status) {
if (status === 'NOT_STARTED') {
return "모집전"
} else if (status === 'COMPLETED') {
return "모집완료"
} else {
return "모집중"
}
},
isValidUrl(string) {
try {
new URL(string); // URL 생성 예외가 발생하면 유효하지 않은 URL
return true;
} catch (_) {
return false;
}
},
async getAuditionDetail() {
this.is_loading = true
try {
const res = await api.getAuditionDetail(this.audition_id)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
this.audition_title = data.title
this.audition_detail.imageUrl = data.imageUrl
this.audition_detail.information = data.information
this.audition_role_list = data.roleList
if (this.isValidUrl(data.originalWorkUrl)) {
this.audition_detail.originalWorkUrl = data.originalWorkUrl
}
} else {
this.notifyError(res.data.message || ' 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError(' 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
imageAdd(payload) {
const file = payload;
if (file) {
this.audition_role.image_url = URL.createObjectURL(file)
URL.revokeObjectURL(file)
} else {
this.audition_role.image_url = null
}
},
showWriteDialog() {
this.show_write_dialog = true
},
showModifyDialog(auditionRole) {
this.audition_role = {
name: auditionRole.name,
image_url: auditionRole.imageUrl,
information: auditionRole.information,
audition_script_url: auditionRole.auditionScriptUrl,
status: auditionRole.status
}
this.selected_role = auditionRole
this.show_write_dialog = true
},
cancel() {
this.audition_role = {}
this.selected_role = null
this.show_write_dialog = false
this.show_delete_confirm_dialog = false
},
deleteConfirm(auditionRole) {
this.selected_role = auditionRole
this.show_delete_confirm_dialog = true
},
validate() {
if (this.audition_role.image === undefined || this.audition_role.image === null) {
this.notifyError('배역 이미지를 선택하세요')
return
}
if (this.audition_role.name.trim().length <= 0) {
this.notifyError('배역 이름을 입력하세요')
return
}
if (
this.audition_role.information === undefined ||
this.audition_role.information === null ||
this.audition_role.information.trim().length <= 10
) {
this.notifyError('오디션 배역 정보를 입력하세요')
return
}
if (
this.audition_role.audition_script_url === undefined ||
this.audition_role.audition_script_url === null ||
this.audition_role.audition_script_url.trim().length <= 10
) {
this.notifyError('오디션 대본 URL을 입력하세요')
return
}
this.submit()
},
async submit() {
if (this.is_loading) return;
this.is_loading = true
try {
const request = {
auditionId: this.audition_id,
name: this.audition_role.name,
information: this.audition_role.information,
auditionScriptUrl: this.audition_role.audition_script_url
}
const formData = new FormData()
formData.append("image", this.audition_role.image);
formData.append("request", JSON.stringify(request))
const res = await api.createAuditionRole(formData);
if (res.status === 200 && res.data.success === true) {
this.cancel();
this.notifySuccess(res.data.message || '등록되었습니다.')
this.is_loading = false
await this.getAuditionDetail()
} else {
this.is_loading = false
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 request = {id: this.selected_role.id}
if (this.audition_role.name !== this.selected_role.name) {
request.name = this.audition_role.name
}
if (this.audition_role.information !== this.selected_role.information) {
request.information = this.audition_role.information
}
if (this.audition_role.audition_script_url !== this.selected_role.audition_script_url) {
request.auditionScriptUrl = this.audition_role.audition_script_url
}
if (this.audition_role.status !== this.selected_role.status) {
request.status = this.audition_role.status
}
const formData = new FormData()
formData.append("request", JSON.stringify(request))
if (this.audition_role.image !== undefined && this.audition_role.image !== null) {
formData.append("image", this.audition_role.image)
}
const res = await api.updateAuditionRole(formData);
if (res.status === 200 && res.data.success === true) {
this.cancel();
this.notifySuccess(res.data.message || '등록되었습니다.')
this.is_loading = false
await this.getAuditionDetail()
} else {
this.is_loading = false
this.notifyError(res.data.message || ' 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError(' 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async deleteAuditionRole() {
if (this.is_loading) return;
this.is_loading = true
try {
const request = {id: this.selected_role.id, isActive: false}
const formData = new FormData()
formData.append("request", JSON.stringify(request))
const res = await api.updateAuditionRole(formData)
if (res.status === 200 && res.data.success === true) {
this.cancel();
this.notifySuccess('오디션 배역이 삭제되었습니다.')
this.is_loading = false
await this.getAuditionDetail()
} else {
this.is_loading = false
this.notifyError(res.data.message || ' 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
} catch (e) {
this.notifyError(' 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
selectAuditionRole(auditionRole) {
this.$router.push(
{
name: 'AuditionRoleDetailView',
params: {
audition_role_id: auditionRole.id,
audition_title: this.audition_title
}
}
)
},
},
}
</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: 300px;
object-fit: cover;
margin-top: 10px;
}
.original-work-link {
text-decoration: none;
}
.audition-image {
aspect-ratio: 1000 / 530;
}
.audition-title-container {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
max-height: 2em;
}
.no-audition_role {
height: 50vh;
margin-top: 100px;
}
</style>

View File

@@ -0,0 +1,321 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>{{ audition_title }} - {{ audition_role_name }}</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-container>
<v-row>
<v-col cols="4">
<v-card>
<v-img
:src="audition_role_detail.imageUrl"
class="audition-image"
/>
<v-card-text
v-if="
audition_role_detail.auditionScriptUrl !== undefined &&
audition_role_detail.auditionScriptUrl.length > 0
"
>
<a
:href="audition_role_detail.auditionScriptUrl"
class="audition-script-link"
>
오디션 대본 링크
</a>
</v-card-text>
<v-card-text>
<b>오디션 배역 정보</b>
<vue-show-more-text
:text="audition_role_detail.information"
:lines="3"
/>
</v-card-text>
</v-card>
</v-col>
<v-col cols="8">
<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">
추천수
</th>
<th class="text-center">
관리
</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in audition_role_applicant_list"
:key="item.applicantId"
>
<td>{{ item.applicantId }}</td>
<td align="center">
<v-img
max-width="70"
max-height="70"
:src="item.profileImageUrl"
class="rounded-circle"
/>
<br>
<a
:href="item.profileImageUrl"
class="v-btn v-btn--outlined"
>
다운로드
</a>
</td>
<td>{{ item.nickname }}<br>{{ formatPhoneNumber(item.phoneNumber) }}</td>
<td>
<vuetify-audio
:file="item.voiceUrl"
:downloadable="true"
:auto-play="false"
/>
</td>
<td>{{ item.voteCount }}</td>
<td>
<v-btn
:disabled="is_loading"
@click="deleteConfirm(item)"
>
삭제
</v-btn>
</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-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_audition_role_applicant.nickname }}"님의 오디션지원을 취소하시겠습니까?
</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="deleteAuditionApplicant"
>
확인
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import * as api from '@/api/audition'
import VueShowMoreText from 'vue-show-more-text'
import VuetifyAudio from "vuetify-audio";
export default {
name: "AuditionDetailView",
components: {VuetifyAudio, VueShowMoreText},
data() {
return {
is_loading: false,
audition_role_id: 0,
audition_title: '',
audition_role_name: '',
audition_role_detail: {},
audition_role_applicant_list: [],
selected_audition_role_applicant: {},
show_delete_confirm_dialog: false,
page: 1,
total_page: 0,
}
},
async created() {
this.audition_role_id = this.$route.params.audition_role_id
this.audition_title = this.$route.params.audition_title
if (this.audition_role_id !== undefined && this.audition_role_id > 0) {
await this.getAuditionRoleDetail()
await this.getAuditionRoleApplicant()
} else {
this.$router.go(-1)
}
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
deleteConfirm(item) {
this.selected_audition_role_applicant = item
this.show_delete_confirm_dialog = true
},
deleteCancel() {
this.selected_audition_role_applicant = {}
this.show_delete_confirm_dialog = false
},
isValidUrl(string) {
try {
new URL(string); // URL 생성 시 예외가 발생하면 유효하지 않은 URL
return true;
} catch (_) {
return false;
}
},
formatPhoneNumber(phoneNumber) {
// 전화번호가 올바른 길이인지 확인
if (phoneNumber.length === 11 && /^\d+$/.test(phoneNumber)) {
// 형식을 변경하여 반환
return `${phoneNumber.slice(0, 3)}-${phoneNumber.slice(3, 7)}-${phoneNumber.slice(7)}`;
} else {
return phoneNumber;
}
},
async getAuditionRoleDetail() {
try {
const res = await api.getAuditionRoleDetail(this.audition_role_id)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
this.audition_role_name = data.name
this.audition_role_detail.imageUrl = data.imageUrl
this.audition_role_detail.information = data.information
if (this.isValidUrl(data.auditionScriptUrl)) {
this.audition_role_detail.auditionScriptUrl = data.auditionScriptUrl
}
} else {
this.is_loading = false
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async getAuditionRoleApplicant() {
this.is_loading = true
try {
const res = await api.getAuditionApplicantList(this.audition_role_id, this.page)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const total_page = Math.ceil(data.totalCount / 20)
this.audition_role_applicant_list = data.items
if (total_page <= 0) {
this.total_page = 1
} else {
this.total_page = total_page
}
} else {
this.is_loading = false
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async next() {
await this.getAuditionRoleApplicant()
},
async deleteAuditionApplicant() {
if (this.is_loading) return;
this.is_loading = true
try {
const res = await api.deleteAuditionApplicant(this.selected_audition_role_applicant.applicantId)
if (res.status === 200 && res.data.success === true) {
this.show_delete_confirm_dialog = false
this.notifySuccess(res.data.message || '오디션 지원이 취소 되었습니다.')
this.audition_role_applicant_list = []
this.page = 1
await this.getAuditionRoleApplicant()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,593 @@
<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-if="audition_list.length > 0">
<v-col
v-for="(item, i) in audition_list"
:key="i"
cols="4"
>
<v-card>
<v-card-title>
<v-spacer />
<v-img
:src="item.imageUrl"
class="cover-image"
@click="selectAudition(item)"
/>
<v-spacer />
</v-card-title>
<v-card-text class="audition-title-container">
{{ item.title }}
</v-card-text>
<v-card-text>
상태 : {{ getStatusStr(item.status) }}
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
text
@click="showModifyDialog(item)"
>
수정
</v-btn>
<v-btn
text
@click="deleteConfirm(item)"
>
삭제
</v-btn>
<v-spacer />
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-row v-else>
<v-col>
등록된 오디션이 없습니다.
</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_write_dialog"
max-width="1000px"
persistent
>
<v-card>
<v-card-title>
오디션 작품 등록
</v-card-title>
<div class="image-select">
<label for="image">
오디션 이미지 등록
</label>
<v-file-input
id="image"
v-model="audition.image"
accept="image/*"
@change="imageAdd"
/>
</div>
<img
v-if="audition.image_url"
:src="audition.image_url"
alt=""
class="image-preview"
>
<v-card-text>
<v-row align="center">
<v-col cols="4">
오디션 제목*
</v-col>
<v-col cols="8">
<v-text-field
v-model="audition.title"
label="오디션 제목"
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-textarea
v-model="audition.information"
label="오디션 정보"
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-text-field
v-model="audition.original_work_url"
label="원작 URL(없으면 입력X)"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row>
<v-col cols="4">
연령제한
</v-col>
<v-col
cols="8"
align="left"
>
<input
v-model="audition.is_adult"
type="checkbox"
>
</v-col>
</v-row>
</v-card-text>
<v-card-text v-if="selected_audition !== null">
<v-row>
<v-col cols="4">
모집상태
</v-col>
<v-col
cols="8"
align="left"
>
<v-row>
<v-col v-if="selected_audition.status === 'NOT_STARTED'">
<input
id="radio_not_started"
v-model="audition.status"
type="radio"
value="NOT_STARTED"
>
<label for="radio_not_started"> 모집전</label>
</v-col>
<v-col>
<input
id="radio_in_progress"
v-model="audition.status"
type="radio"
value="IN_PROGRESS"
>
<label for="radio_in_progress"> 모집중</label>
</v-col>
<v-col>
<input
id="radio_complete"
v-model="audition.status"
type="radio"
value="COMPLETED"
>
<label for="radio_complete"> 모집완료</label>
</v-col>
</v-row>
</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_audition !== 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-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="deleteCancel"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
@click="deleteAudition"
>
확인
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import * as api from '@/api/audition'
export default {
name: "AuditionView",
data() {
return {
is_loading: false,
show_write_dialog: false,
show_delete_confirm_dialog: false,
audition: {
title: '',
information: '',
image_url: '',
is_adult: false,
original_work_url: ''
},
audition_list: [],
selected_audition: null,
page: 1,
total_page: 0,
}
},
async created() {
await this.getAuditionList()
},
methods: {
imageAdd(payload) {
const file = payload;
if (file) {
this.audition.image_url = URL.createObjectURL(file)
URL.revokeObjectURL(file)
} else {
this.audition.image_url = null
}
},
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
getStatusStr(status) {
if (status === 'NOT_STARTED') {
return "모집전"
} else if (status === 'COMPLETED') {
return "모집완료"
} else {
return "모집중"
}
},
showWriteDialog() {
this.show_write_dialog = true
},
showModifyDialog(audition) {
this.audition = {
title: audition.title,
information: audition.information,
image_url: audition.imageUrl,
is_adult: audition.isAdult,
original_work_url: audition.originalWorkUrl,
status: audition.status,
}
this.selected_audition = audition
this.show_write_dialog = true
},
cancel() {
this.audition = {
title: '',
information: '',
image_url: '',
is_adult: false,
original_work_url: ''
}
this.selected_audition = null
this.show_write_dialog = false
this.show_delete_confirm_dialog = false
},
deleteConfirm(audition) {
this.selected_audition = audition
this.show_delete_confirm_dialog = true
},
deleteCancel() {
this.cancel();
},
validate() {
if (this.audition.image === undefined || this.audition.image === null) {
this.notifyError('오디션 이미지를 선택하세요')
return
}
if (this.audition.title.trim().length <= 0) {
this.notifyError('오디션 제목을 입력하세요')
return
}
if (this.audition.information.trim().length <= 10) {
this.notifyError('오디션 정보는 최소 10글자 입니다')
return
}
this.submit()
},
async submit() {
if (this.is_loading) return;
this.is_loading = true
try {
const request = {
title: this.audition.title,
information: this.audition.information,
isAdult: this.audition.is_adult
}
if (this.audition.original_work_url !== '') {
request.originalWorkUrl = this.audition.original_work_url
}
const formData = new FormData()
formData.append("image", this.audition.image);
formData.append("request", JSON.stringify(request))
const res = await api.createAudition(formData);
if (res.status === 200 && res.data.success === true) {
this.cancel();
this.notifySuccess(res.data.message || '등록되었습니다.')
this.is_loading = false
this.page = 1
await this.getAuditionList()
} else {
this.is_loading = false
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
selectAudition(audition) {
this.$router.push(
{
name: 'AuditionDetailView',
params: {
audition_id: audition.id, audition_title: audition.title
}
}
)
},
async next() {
await this.getAuditionList()
},
async getAuditionList() {
if (this.is_loading) return;
this.is_loading = true
try {
const res = await api.getAuditionList(this.page)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const total_page = Math.ceil(data.totalCount / 20)
this.audition_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('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async modify() {
if (this.is_loading) return;
this.is_loading = true
try {
const request = {id: this.selected_audition.id}
if (this.audition.title !== this.selected_audition.title) {
request.title = this.audition.title
}
if (this.audition.information !== this.selected_audition.information) {
request.information = this.audition.information
}
if (this.audition.is_adult !== this.selected_audition.isAdult) {
request.isAdult = this.audition.is_adult
}
if (this.audition.original_work_url !== this.selected_audition.originalWorkUrl) {
request.originalWorkUrl = this.audition.original_work_url
}
if (this.audition.status !== this.selected_audition.status) {
request.status = this.audition.status
}
const formData = new FormData()
formData.append("request", JSON.stringify(request))
if (this.audition.image !== undefined && this.audition.image !== null) {
formData.append("image", this.audition.image)
}
const res = await api.updateAudition(formData);
if (res.status === 200 && res.data.success === true) {
this.cancel();
this.notifySuccess(res.data.message || '수정되었습니다.')
this.is_loading = false
this.page = 1
await this.getAuditionList()
} else {
this.is_loading = false
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async deleteAudition() {
if (this.is_loading) return;
this.is_loading = true
try {
const request = {id: this.selected_audition.id, isActive: false}
const formData = new FormData()
formData.append("request", JSON.stringify(request))
const res = await api.updateAudition(formData)
if (res.status === 200 && res.data.success === true) {
this.cancel();
this.notifySuccess('오디션이 삭제되었습니다.')
this.is_loading = false
this.page = 1
await this.getAuditionList()
} else {
this.is_loading = false
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} 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: 300px;
object-fit: cover;
margin-top: 10px;
}
.audition-title-container {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
max-height: 2em;
}
.cover-image {
aspect-ratio: 1000/530;
}
</style>

View File

@@ -0,0 +1,209 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>콘텐츠별 누적 현황</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-container>
<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 v-slot:item.orderPrice="{ item }">
{{ item.orderPrice.toLocaleString() }}
</template>
<template v-slot:item.totalCan="{ item }">
{{ item.totalCan.toLocaleString() }}
</template>
<template v-slot:item.totalKrw="{ item }">
{{ item.totalKrw.toLocaleString() }}
</template>
<template v-slot:item.paymentFee="{ item }">
{{ item.paymentFee.toLocaleString() }}
</template>
<template v-slot:item.settlementAmount="{ item }">
{{ item.settlementAmount.toLocaleString() }}
</template>
<template v-slot:item.tax="{ item }">
{{ item.tax.toLocaleString() }}
</template>
<template v-slot:item.depositAmount="{ item }">
{{ item.depositAmount.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/calculate";
export default {
name: "CalculateAccumulation",
data() {
return {
is_loading: false,
page: 1,
page_size: 20,
total_page: 0,
items: [],
headers: [
{
text: '크리에이터',
align: 'center',
sortable: false,
value: 'nickname',
},
{
text: '등록일',
align: 'center',
sortable: false,
value: 'registrationDate',
},
{
text: '제목',
sortable: false,
value: 'title',
align: 'center',
width: "300px"
},
{
text: '구분',
align: 'center',
sortable: false,
value: 'orderType',
},
{
text: '판매금액(캔)',
align: 'center',
sortable: false,
value: 'orderPrice',
},
{
text: '누적 판매수',
align: 'center',
sortable: false,
value: 'numberOfPeople',
},
{
text: '합계(캔)',
align: 'center',
sortable: false,
value: 'totalCan',
},
{
text: '원화',
align: 'center',
sortable: false,
value: 'totalKrw',
},
{
text: '수수료\n(6.6%)',
align: 'center',
sortable: false,
value: 'paymentFee',
},
{
text: '정산금액',
align: 'center',
sortable: false,
value: 'settlementAmount',
},
{
text: '원천세\n(3.3%)',
align: 'center',
sortable: false,
value: 'tax',
},
{
text: '입금액',
align: 'center',
sortable: false,
value: 'depositAmount',
},
],
}
},
async created() {
await this.getCumulativeSalesByContent();
},
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)
},
async next() {
await this.getCumulativeSalesByContent()
},
async getCumulativeSalesByContent() {
this.is_loading = true
try {
const res = await api.getCumulativeSalesByContent(this.page, this.page_size)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const totalPage = Math.ceil(data.totalCount / this.page_size)
this.items = data.items
if (totalPage <= 0)
this.total_page = 1
else
this.total_page = totalPage
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,319 @@
<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="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="getCalculateCommunityByCreator"
>
조회
</v-btn>
</v-col>
<v-spacer />
<v-col cols="2">
<vue-excel-xlsx
:data="items"
:columns="columns"
:file-name="'정산'"
:file-type="'xlsx'"
:sheet-name="'정산'"
>
<v-btn
block
color="#3bb9f1"
dark
depressed
>
엑셀 다운로드
</v-btn>
</vue-excel-xlsx>
</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>
<td colspan="2">
합계
</td>
<td>{{ sumField('totalCan').toLocaleString() }} </td>
<td>{{ sumField('totalKrw').toLocaleString() }} </td>
<td>{{ sumField('paymentFee').toLocaleString() }} </td>
<td>{{ sumField('settlementAmount').toLocaleString() }} </td>
<td>{{ sumField('tax').toLocaleString() }} </td>
<td>{{ sumField('depositAmount').toLocaleString() }} </td>
</tr>
</template>
<template v-slot:item.email="{ item }">
{{ item.email }}
</template>
<template v-slot:item.nickname="{ item }">
{{ item.nickname }}
</template>
<template v-slot:item.totalCan="{ item }">
{{ item.totalCan.toLocaleString() }}
</template>
<template v-slot:item.totalKrw="{ item }">
{{ item.totalKrw.toLocaleString() }}
</template>
<template v-slot:item.paymentFee="{ item }">
{{ item.paymentFee.toLocaleString() }}
</template>
<template v-slot:item.settlementAmount="{ item }">
{{ item.settlementAmount.toLocaleString() }}
</template>
<template v-slot:item.tax="{ item }">
{{ item.tax.toLocaleString() }}
</template>
<template v-slot:item.depositAmount="{ item }">
{{ item.depositAmount.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 datetime from "vuejs-datetimepicker";
import * as api from "@/api/calculate";
export default {
name: "CalculateLiveByCreator",
components: {datetime},
data() {
return {
is_loading: false,
start_date: null,
end_date: null,
page: 1,
page_size: 20,
total_page: 0,
items: [],
columns: [
{
label: "이메일",
field: "email",
},
{
label: "크리에이터",
field: "nickname",
},
{
label: "합계(캔)",
field: "totalCan",
},
{
label: "원화",
field: "totalKrw",
},
{
label: "결제수수료(6.6%)",
field: "paymentFee",
},
{
label: "정산금액",
field: "settlementAmount",
},
{
label: "원천세(3.3%)",
field: "tax",
},
{
label: "입금액",
field: "depositAmount",
},
],
headers: [
{
text: '이메일',
align: 'center',
sortable: false,
value: 'email',
},
{
text: '크리에이터',
align: 'center',
sortable: false,
value: 'nickname',
},
{
text: '합계(캔)',
align: 'center',
sortable: false,
value: 'totalCan',
},
{
text: '원화',
align: 'center',
sortable: false,
value: 'totalKrw',
},
{
text: '수수료\n(6.6%)',
align: 'center',
sortable: false,
value: 'paymentFee',
},
{
text: '정산금액',
align: 'center',
sortable: false,
value: 'settlementAmount',
},
{
text: '원천세\n(3.3%)',
align: 'center',
sortable: false,
value: 'tax',
},
{
text: '입금액',
align: 'center',
sortable: false,
value: 'depositAmount',
}
],
}
},
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.getCalculateCommunityByCreator();
},
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)
},
async next() {
await this.getCalculateCommunityByCreator()
},
async getCalculateCommunityByCreator() {
this.is_loading = true
try {
const res = await api.getCalculateCommunityByCreator(this.start_date, this.end_date, this.page, this.page_size)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const totalPage = Math.ceil(data.totalCount / this.page_size)
this.items = data.items
if (totalPage <= 0)
this.total_page = 1
else
this.total_page = totalPage
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
} finally {
this.is_loading = false
}
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,315 @@
<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="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="#9970ff"
dark
depressed
@click="getCalculateCommunityPost"
>
조회
</v-btn>
</v-col>
<v-spacer />
<v-col cols="2">
<vue-excel-xlsx
:data="items"
:columns="columns"
:file-name="'커뮤니티-정산'"
:file-type="'xlsx'"
:sheet-name="'커뮤니티-정산'"
>
<v-btn
block
color="#9970ff"
dark
depressed
>
엑셀 다운로드
</v-btn>
</vue-excel-xlsx>
</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 v-slot:item.title="{ item }">
{{ item.title }}...
</template>
<template v-slot:item.totalCan="{ item }">
{{ item.totalCan.toLocaleString() }}
</template>
<template v-slot:item.totalKrw="{ item }">
{{ item.totalKrw.toLocaleString() }}
</template>
<template v-slot:item.paymentFee="{ item }">
{{ item.paymentFee.toLocaleString() }}
</template>
<template v-slot:item.settlementAmount="{ item }">
{{ item.settlementAmount.toLocaleString() }}
</template>
<template v-slot:item.tax="{ item }">
{{ item.tax.toLocaleString() }}
</template>
<template v-slot:item.depositAmount="{ item }">
{{ item.depositAmount.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 datetime from "vuejs-datetimepicker";
import * as api from "@/api/calculate";
export default {
name: "CalculateCommunityPost",
components: {datetime},
data() {
return {
is_loading: false,
start_date: null,
end_date: null,
page: 1,
page_size: 20,
total_page: 0,
items: [],
columns: [
{
label: '날짜',
field: 'date',
},
{
label: '크리에이터',
field: 'nickname',
},
{
label: '내용(앞 10글자)',
field: 'title'
},
{
label: '판매금액(캔)',
field: 'can',
},
{
label: '구매유저수',
field: 'numberOfPurchase',
},
{
label: '합계(캔)',
field: 'totalCan',
},
{
label: '원화',
field: 'totalKrw',
},
{
label: '수수료\n(6.6%)',
field: 'paymentFee',
},
{
label: '정산금액',
field: 'settlementAmount',
},
{
label: '원천세\n(3.3%)',
field: 'tax',
},
{
label: '입금액',
field: 'depositAmount',
}
],
headers: [
{
text: '날짜',
align: 'center',
sortable: false,
value: 'date',
},
{
text: '크리에이터',
align: 'center',
sortable: false,
value: 'nickname',
},
{
text: '내용(앞 10글자)',
sortable: false,
value: 'title',
align: 'center',
width: "300px"
},
{
text: '판매금액(캔)',
align: 'center',
sortable: false,
value: 'can',
},
{
text: '구매유저수',
align: 'center',
sortable: false,
value: 'numberOfPurchase',
},
{
text: '합계(캔)',
align: 'center',
sortable: false,
value: 'totalCan',
},
{
text: '원화',
align: 'center',
sortable: false,
value: 'totalKrw',
},
{
text: '수수료\n(6.6%)',
align: 'center',
sortable: false,
value: 'paymentFee',
},
{
text: '정산금액',
align: 'center',
sortable: false,
value: 'settlementAmount',
},
{
text: '원천세\n(3.3%)',
align: 'center',
sortable: false,
value: 'tax',
},
{
text: '입금액',
align: 'center',
sortable: false,
value: 'depositAmount',
}
],
}
},
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.getCalculateCommunityPost();
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
async next() {
await this.getCalculateCommunityPost()
},
async getCalculateCommunityPost() {
this.is_loading = true
try {
const res = await api.getCalculateCommunityPost(this.start_date, this.end_date, this.page, this.page_size)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const totalPage = Math.ceil(data.totalCount / this.page_size)
this.items = data.items
if (totalPage <= 0)
this.total_page = 1
else
this.total_page = totalPage
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
}
}
}
</script>

View File

@@ -1,7 +1,339 @@
<template>
<div>콘텐츠 정산</div>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>콘텐츠 정산</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-container>
<v-row>
<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="#9970ff"
dark
depressed
@click="getCalculateContent"
>
조회
</v-btn>
</v-col>
<v-spacer />
<v-col cols="2">
<vue-excel-xlsx
:data="items"
:columns="columns"
:file-name="'정산'"
:file-type="'xlsx'"
:sheet-name="'정산'"
>
<v-btn
block
color="#9970ff"
dark
depressed
>
엑셀 다운로드
</v-btn>
</vue-excel-xlsx>
</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>
<td colspan="5">
합계
</td>
<td>{{ sumField('numberOfPeople').toLocaleString() }}</td>
<td>{{ sumField('totalCan').toLocaleString() }} </td>
<td>{{ sumField('totalKrw').toLocaleString() }} </td>
<td>{{ sumField('paymentFee').toLocaleString() }} </td>
<td>{{ sumField('settlementAmount').toLocaleString() }} </td>
<td>{{ sumField('tax').toLocaleString() }} </td>
<td>{{ sumField('depositAmount').toLocaleString() }} </td>
<td />
</tr>
</template>
<template v-slot:item.orderPrice="{ item }">
{{ item.orderPrice.toLocaleString() }}
</template>
<template v-slot:item.orderType="{ item }">
{{ item.orderType }}
</template>
<template v-slot:item.totalCan="{ item }">
{{ item.totalCan.toLocaleString() }}
</template>
<template v-slot:item.totalKrw="{ item }">
{{ item.totalKrw.toLocaleString() }}
</template>
<template v-slot:item.paymentFee="{ item }">
{{ item.paymentFee.toLocaleString() }}
</template>
<template v-slot:item.settlementAmount="{ item }">
{{ item.settlementAmount.toLocaleString() }}
</template>
<template v-slot:item.tax="{ item }">
{{ item.tax.toLocaleString() }}
</template>
<template v-slot:item.depositAmount="{ item }">
{{ item.depositAmount.toLocaleString() }}
</template>
</v-data-table>
</v-col>
</v-row>
</v-container>
</div>
</template>
<style scoped>
<script>
import * as api from "@/api/calculate";
import datetime from 'vuejs-datetimepicker';
</style>
export default {
name: "CalculateContent",
components: {datetime},
data() {
return {
is_loading: false,
start_date: null,
end_date: null,
items: [],
columns: [
{
label: '판매일',
field: 'saleDate',
},
{
label: '크리에이터',
field: 'nickname',
},
{
label: '제목',
field: 'title',
},
{
label: '구분',
field: 'orderType',
},
{
label: '판매금액(캔)',
field: 'orderPrice',
},
{
label: '판매수',
field: 'numberOfPeople',
},
{
label: '합계(캔)',
field: 'totalCan',
},
{
label: '원화',
field: 'totalKrw',
},
{
label: '수수료\n(6.6%)',
field: 'paymentFee',
},
{
label: '정산금액',
field: 'settlementAmount',
},
{
label: '원천세\n(3.3%)',
field: 'tax',
},
{
label: '입금액',
field: 'depositAmount',
},
{
label: '등록일',
field: 'registrationDate',
},
],
headers: [
{
text: '판매일',
align: 'center',
sortable: false,
value: 'saleDate',
},
{
text: '크리에이터',
align: 'center',
sortable: false,
value: 'nickname',
},
{
text: '제목',
sortable: false,
value: 'title',
align: 'center',
width: "300px"
},
{
text: '구분',
align: 'center',
sortable: false,
value: 'orderType',
},
{
text: '판매금액(캔)',
align: 'center',
sortable: false,
value: 'orderPrice',
},
{
text: '판매수',
align: 'center',
sortable: false,
value: 'numberOfPeople',
},
{
text: '합계(캔)',
align: 'center',
sortable: false,
value: 'totalCan',
},
{
text: '원화',
align: 'center',
sortable: false,
value: 'totalKrw',
},
{
text: '수수료\n(6.6%)',
align: 'center',
sortable: false,
value: 'paymentFee',
},
{
text: '정산금액',
align: 'center',
sortable: false,
value: 'settlementAmount',
},
{
text: '원천세\n(3.3%)',
align: 'center',
sortable: false,
value: 'tax',
},
{
text: '입금액',
align: 'center',
sortable: false,
value: 'depositAmount',
},
{
text: '등록일',
align: 'center',
sortable: false,
value: 'registrationDate',
},
],
}
},
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.getCalculateContent();
},
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)
},
async getCalculateContent() {
this.is_loading = true
try {
const res = await api.getCalculateContent(this.start_date, this.end_date)
if (res.status === 200 && res.data.success === true) {
this.items = res.data.data
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
}
}
}
</script>

View File

@@ -0,0 +1,319 @@
<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="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="getCalculateContentByCreator"
>
조회
</v-btn>
</v-col>
<v-spacer />
<v-col cols="2">
<vue-excel-xlsx
:data="items"
:columns="columns"
:file-name="'정산'"
:file-type="'xlsx'"
:sheet-name="'정산'"
>
<v-btn
block
color="#3bb9f1"
dark
depressed
>
엑셀 다운로드
</v-btn>
</vue-excel-xlsx>
</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>
<td colspan="2">
합계
</td>
<td>{{ sumField('totalCan').toLocaleString() }} </td>
<td>{{ sumField('totalKrw').toLocaleString() }} </td>
<td>{{ sumField('paymentFee').toLocaleString() }} </td>
<td>{{ sumField('settlementAmount').toLocaleString() }} </td>
<td>{{ sumField('tax').toLocaleString() }} </td>
<td>{{ sumField('depositAmount').toLocaleString() }} </td>
</tr>
</template>
<template v-slot:item.email="{ item }">
{{ item.email }}
</template>
<template v-slot:item.nickname="{ item }">
{{ item.nickname }}
</template>
<template v-slot:item.totalCan="{ item }">
{{ item.totalCan.toLocaleString() }}
</template>
<template v-slot:item.totalKrw="{ item }">
{{ item.totalKrw.toLocaleString() }}
</template>
<template v-slot:item.paymentFee="{ item }">
{{ item.paymentFee.toLocaleString() }}
</template>
<template v-slot:item.settlementAmount="{ item }">
{{ item.settlementAmount.toLocaleString() }}
</template>
<template v-slot:item.tax="{ item }">
{{ item.tax.toLocaleString() }}
</template>
<template v-slot:item.depositAmount="{ item }">
{{ item.depositAmount.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 datetime from "vuejs-datetimepicker";
import * as api from "@/api/calculate";
export default {
name: "CalculateLiveByCreator",
components: {datetime},
data() {
return {
is_loading: false,
start_date: null,
end_date: null,
page: 1,
page_size: 20,
total_page: 0,
items: [],
columns: [
{
label: "이메일",
field: "email",
},
{
label: "크리에이터",
field: "nickname",
},
{
label: "합계(캔)",
field: "totalCan",
},
{
label: "원화",
field: "totalKrw",
},
{
label: "결제수수료(6.6%)",
field: "paymentFee",
},
{
label: "정산금액",
field: "settlementAmount",
},
{
label: "원천세(3.3%)",
field: "tax",
},
{
label: "입금액",
field: "depositAmount",
},
],
headers: [
{
text: '이메일',
align: 'center',
sortable: false,
value: 'email',
},
{
text: '크리에이터',
align: 'center',
sortable: false,
value: 'nickname',
},
{
text: '합계(캔)',
align: 'center',
sortable: false,
value: 'totalCan',
},
{
text: '원화',
align: 'center',
sortable: false,
value: 'totalKrw',
},
{
text: '수수료\n(6.6%)',
align: 'center',
sortable: false,
value: 'paymentFee',
},
{
text: '정산금액',
align: 'center',
sortable: false,
value: 'settlementAmount',
},
{
text: '원천세\n(3.3%)',
align: 'center',
sortable: false,
value: 'tax',
},
{
text: '입금액',
align: 'center',
sortable: false,
value: 'depositAmount',
}
],
}
},
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.getCalculateContentByCreator();
},
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)
},
async next() {
await this.getCalculateContentByCreator()
},
async getCalculateContentByCreator() {
this.is_loading = true
try {
const res = await api.getCalculateContentByCreator(this.start_date, this.end_date, this.page, this.page_size)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const totalPage = Math.ceil(data.totalCount / this.page_size)
this.items = data.items
if (totalPage <= 0)
this.total_page = 1
else
this.total_page = totalPage
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
} finally {
this.is_loading = false
}
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,321 @@
<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="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="#9970ff"
dark
depressed
@click="getCalculateContentDonation"
>
조회
</v-btn>
</v-col>
<v-spacer />
<v-col cols="2">
<vue-excel-xlsx
:data="items"
:columns="columns"
:file-name="'정산'"
:file-type="'xlsx'"
:sheet-name="'정산'"
>
<v-btn
block
color="#9970ff"
dark
depressed
>
엑셀 다운로드
</v-btn>
</vue-excel-xlsx>
</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>
<td colspan="4">
합계
</td>
<td>{{ sumField('numberOfDonation').toLocaleString() }}</td>
<td>{{ sumField('totalCan').toLocaleString() }} </td>
<td>{{ sumField('totalKrw').toLocaleString() }} </td>
<td>{{ sumField('paymentFee').toLocaleString() }} </td>
<td>{{ sumField('settlementAmount').toLocaleString() }} </td>
<td>{{ sumField('tax').toLocaleString() }} </td>
<td>{{ sumField('depositAmount').toLocaleString() }} </td>
<td />
</tr>
</template>
<template v-slot:item.totalCan="{ item }">
{{ item.totalCan.toLocaleString() }}
</template>
<template v-slot:item.totalKrw="{ item }">
{{ item.totalKrw.toLocaleString() }}
</template>
<template v-slot:item.paymentFee="{ item }">
{{ item.paymentFee.toLocaleString() }}
</template>
<template v-slot:item.settlementAmount="{ item }">
{{ item.settlementAmount.toLocaleString() }}
</template>
<template v-slot:item.tax="{ item }">
{{ item.tax.toLocaleString() }}
</template>
<template v-slot:item.depositAmount="{ item }">
{{ item.depositAmount.toLocaleString() }}
</template>
</v-data-table>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script>
import * as api from "@/api/calculate";
import datetime from "vuejs-datetimepicker";
export default {
name: "CalculateContentDonation",
components: {datetime},
data() {
return {
is_loading: false,
start_date: null,
end_date: null,
items: [],
columns: [
{
label: '후원날짜',
field: 'donationDate',
},
{
label: '크리에이터',
field: 'nickname',
},
{
label: '콘텐츠 제목',
field: 'title',
},
{
label: '구분',
field: 'paidOrFree',
},
{
label: '후원수',
field: 'numberOfDonation',
},
{
label: '합계(캔)',
field: 'totalCan',
},
{
label: '원화',
field: 'totalKrw',
},
{
label: '수수료\n(6.6%)',
field: 'paymentFee',
},
{
label: '정산금액',
field: 'settlementAmount',
},
{
label: '원천세\n(3.3%)',
field: 'tax',
},
{
label: '입금액',
field: 'depositAmount',
},
{
label: '콘텐츠 등록일',
field: 'registrationDate',
},
],
headers: [
{
text: '후원날짜',
align: 'center',
sortable: false,
value: 'donationDate',
},
{
text: '크리에이터',
align: 'center',
sortable: false,
value: 'nickname',
},
{
text: '콘텐츠 제목',
sortable: false,
value: 'title',
align: 'center',
width: "300px"
},
{
text: '구분',
sortable: false,
value: 'paidOrFree',
align: 'center'
},
{
text: '후원수',
align: 'center',
sortable: false,
value: 'numberOfDonation',
},
{
text: '합계(캔)',
align: 'center',
sortable: false,
value: 'totalCan',
},
{
text: '원화',
align: 'center',
sortable: false,
value: 'totalKrw',
},
{
text: '수수료\n(6.6%)',
align: 'center',
sortable: false,
value: 'paymentFee',
},
{
text: '정산금액',
align: 'center',
sortable: false,
value: 'settlementAmount',
},
{
text: '원천세\n(3.3%)',
align: 'center',
sortable: false,
value: 'tax',
},
{
text: '입금액',
align: 'center',
sortable: false,
value: 'depositAmount',
},
{
text: '콘텐츠 등록일',
align: 'center',
sortable: false,
value: 'registrationDate',
},
],
}
},
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.getCalculateContentDonation();
},
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)
},
async getCalculateContentDonation() {
this.is_loading = true
try {
const res = await api.getCalculateContentDonation(this.start_date, this.end_date)
if (res.status === 200 && res.data.success === true) {
this.items = res.data.data
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
}
}
}
</script>

View File

@@ -1,13 +0,0 @@
<template>
<div>저작권 정산</div>
</template>
<script>
export default {
name: "CalculateCopyright"
}
</script>
<style scoped>
</style>

View File

@@ -2,7 +2,7 @@
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>요즘친구 정산</v-toolbar-title>
<v-toolbar-title>크리에이터 라이브 정산</v-toolbar-title>
<v-spacer />
</v-toolbar>
@@ -38,7 +38,7 @@
color="#9970ff"
dark
depressed
@click="getCalculateCreator"
@click="getCalculateLive"
>
조회
</v-btn>
@@ -93,15 +93,15 @@
</template>
<template v-slot:item.entranceFee="{ item }">
{{ item.entranceFee.toLocaleString() }} 코인
{{ item.entranceFee.toLocaleString() }}
</template>
<template v-slot:item.coinUsageStr="{ item }">
{{ item.coinUsageStr }}
<template v-slot:item.canUsageStr="{ item }">
{{ item.canUsageStr }}
</template>
<template v-slot:item.totalAmount="{ item }">
{{ item.totalAmount.toLocaleString() }} 코인
{{ item.totalAmount.toLocaleString() }}
</template>
<template v-slot:item.totalKrw="{ item }">
@@ -150,7 +150,7 @@ export default {
field: "email",
},
{
label: "닉네임",
label: "크리에이터",
field: "nickname",
},
{
@@ -158,19 +158,23 @@ export default {
field: "date",
},
{
label: "라이브 제목",
label: "제목",
field: "title",
},
{
label: "유료방 입장 금액(코인)",
label: "구분",
field: "canUsageStr",
},
{
label: "입장캔",
field: "entranceFee",
},
{
label: "코인사용구분",
field: "coinUsageStr",
label: "인원",
field: "numberOfPeople",
},
{
label: "합계(코인)",
label: "합계()",
field: "totalAmount",
},
{
@@ -178,7 +182,7 @@ export default {
field: "totalKrw",
},
{
label: "결제수수료(4.4%)",
label: "결제수수료(6.6%)",
field: "paymentFee",
},
{
@@ -202,7 +206,7 @@ export default {
value: 'email',
},
{
text: '닉네임',
text: '크리에이터',
align: 'center',
sortable: false,
value: 'nickname',
@@ -214,24 +218,30 @@ export default {
value: 'date',
},
{
text: '라이브 제목',
text: '제목',
sortable: false,
value: 'title',
},
{
text: '유료방 입장 금액(코인)',
text: '구분',
align: 'center',
sortable: false,
value: 'canUsageStr',
},
{
text: '입장캔',
align: 'center',
sortable: false,
value: 'entranceFee',
},
{
text: '코인사용구분',
text: '인원',
align: 'center',
sortable: false,
value: 'coinUsageStr',
value: 'numberOfPeople',
},
{
text: '합계(코인)',
text: '합계()',
align: 'center',
sortable: false,
value: 'totalAmount',
@@ -243,7 +253,7 @@ export default {
value: 'totalKrw',
},
{
text: '결제수수료(4.4%)',
text: '수수료\n(6.6%)',
align: 'center',
sortable: false,
value: 'paymentFee',
@@ -255,7 +265,7 @@ export default {
value: 'settlementAmount',
},
{
text: '원천세(3.3%)',
text: '원천세\n(3.3%)',
align: 'center',
sortable: false,
value: 'tax',
@@ -289,7 +299,7 @@ export default {
this.start_date = firstDate.getFullYear() + '-' + firstDateMonth + '-0' + firstDate.getDate()
this.end_date = lastDate.getFullYear() + '-' + lastDateMonth + '-' + lastDate.getDate()
await this.getCalculateCreator()
await this.getCalculateLive()
},
methods: {
@@ -301,11 +311,11 @@ export default {
this.$dialog.notify.success(message)
},
async getCalculateCreator() {
async getCalculateLive() {
this.is_loading = true
try {
const res = await api.getCalculateCreator(this.start_date, this.end_date)
const res = await api.getCalculateLive(this.start_date, this.end_date)
if (res.status === 200 && res.data.success === true) {
this.items = res.data.data
} else {

View File

@@ -0,0 +1,319 @@
<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="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="getCalculateLiveByCreator"
>
조회
</v-btn>
</v-col>
<v-spacer />
<v-col cols="2">
<vue-excel-xlsx
:data="items"
:columns="columns"
:file-name="'정산'"
:file-type="'xlsx'"
:sheet-name="'정산'"
>
<v-btn
block
color="#3bb9f1"
dark
depressed
>
엑셀 다운로드
</v-btn>
</vue-excel-xlsx>
</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>
<td colspan="2">
합계
</td>
<td>{{ sumField('totalCan').toLocaleString() }} </td>
<td>{{ sumField('totalKrw').toLocaleString() }} </td>
<td>{{ sumField('paymentFee').toLocaleString() }} </td>
<td>{{ sumField('settlementAmount').toLocaleString() }} </td>
<td>{{ sumField('tax').toLocaleString() }} </td>
<td>{{ sumField('depositAmount').toLocaleString() }} </td>
</tr>
</template>
<template v-slot:item.email="{ item }">
{{ item.email }}
</template>
<template v-slot:item.nickname="{ item }">
{{ item.nickname }}
</template>
<template v-slot:item.totalCan="{ item }">
{{ item.totalCan.toLocaleString() }}
</template>
<template v-slot:item.totalKrw="{ item }">
{{ item.totalKrw.toLocaleString() }}
</template>
<template v-slot:item.paymentFee="{ item }">
{{ item.paymentFee.toLocaleString() }}
</template>
<template v-slot:item.settlementAmount="{ item }">
{{ item.settlementAmount.toLocaleString() }}
</template>
<template v-slot:item.tax="{ item }">
{{ item.tax.toLocaleString() }}
</template>
<template v-slot:item.depositAmount="{ item }">
{{ item.depositAmount.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 datetime from "vuejs-datetimepicker";
import * as api from "@/api/calculate";
export default {
name: "CalculateLiveByCreator",
components: {datetime},
data() {
return {
is_loading: false,
start_date: null,
end_date: null,
page: 1,
page_size: 20,
total_page: 0,
items: [],
columns: [
{
label: "이메일",
field: "email",
},
{
label: "크리에이터",
field: "nickname",
},
{
label: "합계(캔)",
field: "totalCan",
},
{
label: "원화",
field: "totalKrw",
},
{
label: "결제수수료(6.6%)",
field: "paymentFee",
},
{
label: "정산금액",
field: "settlementAmount",
},
{
label: "원천세(3.3%)",
field: "tax",
},
{
label: "입금액",
field: "depositAmount",
},
],
headers: [
{
text: '이메일',
align: 'center',
sortable: false,
value: 'email',
},
{
text: '크리에이터',
align: 'center',
sortable: false,
value: 'nickname',
},
{
text: '합계(캔)',
align: 'center',
sortable: false,
value: 'totalCan',
},
{
text: '원화',
align: 'center',
sortable: false,
value: 'totalKrw',
},
{
text: '수수료\n(6.6%)',
align: 'center',
sortable: false,
value: 'paymentFee',
},
{
text: '정산금액',
align: 'center',
sortable: false,
value: 'settlementAmount',
},
{
text: '원천세\n(3.3%)',
align: 'center',
sortable: false,
value: 'tax',
},
{
text: '입금액',
align: 'center',
sortable: false,
value: 'depositAmount',
}
],
}
},
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.getCalculateLiveByCreator();
},
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)
},
async next() {
await this.getCalculateLiveByCreator()
},
async getCalculateLiveByCreator() {
this.is_loading = true
try {
const res = await api.getCalculateLiveByCreator(this.start_date, this.end_date, this.page, this.page_size)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const totalPage = Math.ceil(data.totalCount / this.page_size)
this.items = data.items
if (totalPage <= 0)
this.total_page = 1
else
this.total_page = totalPage
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
} finally {
this.is_loading = false
}
}
}
}
</script>
<style scoped>
</style>

View File

@@ -2,7 +2,7 @@
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>코인 충전</v-toolbar-title>
<v-toolbar-title> 충전</v-toolbar-title>
<v-spacer />
</v-toolbar>
@@ -23,8 +23,8 @@
/>
<v-text-field
v-model="coin"
label="지급할 코인 수"
v-model="can"
label="지급할 수"
outlined
required
/>
@@ -39,7 +39,7 @@
depressed
@click="confirm"
>
코인 지급
지급
</v-btn>
</v-col>
</v-row>
@@ -50,7 +50,7 @@
persistent
>
<v-card>
<v-card-title>코인 지급 확인</v-card-title>
<v-card-title> 지급 확인</v-card-title>
<v-card-text>
회원번호: {{ account_id }}
</v-card-text>
@@ -58,16 +58,16 @@
기록내용: {{ method }}
</v-card-text>
<v-card-text>
지급할 코인 : {{ coin }}코인
지급할 : {{ 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"
text
@click="submit"
>
코인 지급
지급
</v-btn>
<v-spacer />
<v-btn
@@ -87,18 +87,18 @@
</template>
<script>
import * as api from '@/api/coin'
import * as api from '@/api/can'
export default {
name: "CoinCharge",
name: "CanCharge",
data() {
return {
show_confirm: false,
isLoading: false,
is_loading: false,
account_id: '',
method: '',
coin: ''
can: ''
}
},
@@ -113,18 +113,18 @@ export default {
confirm() {
if (this.account_id.trim() === '' || isNaN(this.account_id)) {
return this.notifyError('코인을 지급할 회원의 회원번호를 입력하세요.')
return this.notifyError('을 지급할 회원의 회원번호를 입력하세요.')
}
if (this.method.trim() === '') {
return this.notifyError('기록할 내용을 입력하세요')
}
if (isNaN(this.coin)) {
return this.notifyError('코인은 숫자만 넣을 수 있습니다.')
if (isNaN(this.can)) {
return this.notifyError('은 숫자만 넣을 수 있습니다.')
}
if (!this.isLoading) {
if (!this.is_loading) {
this.show_confirm = true
}
},
@@ -134,18 +134,18 @@ export default {
},
async submit() {
if (!this.isLoading) {
this.isLoading = true
if (!this.is_loading) {
this.is_loading = true
try {
this.show_confirm = false
const res = await api.paymentCoin(Number(this.coin), this.method, this.account_id)
const res = await api.paymentCan(Number(this.can), this.method, this.account_id)
if (res.status === 200 && res.data.success === true) {
this.notifySuccess('코인이 지급되었습니다.')
this.notifySuccess('이 지급되었습니다.')
this.account_id = ''
this.method = ''
this.coin = ''
this.can = ''
this.is_loading = false
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')

757
src/views/Can/CanCoupon.vue Normal file
View File

@@ -0,0 +1,757 @@
<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="coupon_list"
: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.couponName="{ item }">
<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>
<template v-slot:item.couponCount="{ item }">
{{ item.couponCount.toLocaleString('en-US') }}
</template>
<template v-slot:item.useCouponCount="{ item }">
{{ item.useCouponCount.toLocaleString('en-US') }}
</template>
<template v-slot:item.validity="{ item }">
{{ item.validity }}
</template>
<template v-slot:item.isMultipleUse="{ item }">
<div v-if="item.isMultipleUse">
O
</div>
<div v-else>
X
</div>
</template>
<template v-slot:item.isActive="{ item }">
<div v-if="item.isActive">
O
</div>
<div v-else>
X
</div>
</template>
<template v-slot:item.download="{ item }">
<v-btn
:disabled="is_loading"
@click="downloadCouponNumberList(item)"
>
다운로드
</v-btn>
</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="coupon_name"
label="쿠폰명"
/>
</v-card-text>
<v-card-text>
<v-text-field
v-model="can"
label="쿠폰금액"
/>
</v-card-text>
<v-card-text>
<v-text-field
v-model="coupon_number_count"
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">
유효기간
</v-col>
<v-col
cols="8"
class="datepicker-wrapper"
>
<datetime
v-model="validity"
class="datepicker"
format="YYYY-MM-DD"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
인증 계정당 중복 사용 가능여부
</v-col>
<v-col cols="8">
<input
v-model="is_multiple_use"
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
color="blue darken-1"
text
@click="validate"
>
쿠폰 발행하기
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-row>
<v-row v-if="selected_coupon !== null">
<v-dialog
v-model="show_modify_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">
{{ selected_coupon.couponName }}
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
쿠폰금액
</v-col>
<v-col cols="8">
{{ selected_coupon.can }}
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
발행수량
</v-col>
<v-col cols="8">
{{ selected_coupon.couponCount }}
</v-col>
</v-row>
</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="validity"
class="datepicker"
format="YYYY-MM-DD"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
인증 계정당 중복 사용 가능여부
</v-col>
<v-col cols="8">
<input
v-model="is_multiple_use"
type="checkbox"
>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
활성화
</v-col>
<v-col cols="8">
<input
v-model="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
color="blue darken-1"
text
@click="modify"
>
수정하기
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-row>
<v-row>
<v-dialog
v-model="show_coupon_number_list_dialog"
max-width="1000px"
persistent
>
<v-card>
<v-card-text>
<v-data-table
:headers="detail_headers"
:items="coupon_number_list"
:loading="is_loading"
:items-per-page="-1"
class="elevation-1"
hide-default-footer
>
<template v-slot:item.couponNumberId="{ item }">
{{ item.couponNumberId }}
</template>
<template v-slot:item.couponNumber="{ item }">
{{ formatWithHyphens(item.couponNumber) }}
</template>
<template v-slot:item.isUsed="{ item }">
<div v-if="item.isUsed">
O
</div>
<div v-else>
X
</div>
</template>
</v-data-table>
</v-card-text>
<v-card-text>
<v-row class="text-center">
<v-col>
<v-pagination
v-model="coupon_number_page"
:length="coupon_number_total_page"
circle
@input="couponNumberNext"
/>
</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-card-actions>
</v-card>
</v-dialog>
</v-row>
</div>
</template>
<script>
import * as api from '@/api/can'
import { saveAs } from 'file-saver';
import datetime from "vuejs-datetimepicker";
export default {
name: "CanCoupon",
components: {datetime},
data() {
return {
is_loading: false,
is_modify: false,
show_write_dialog: false,
show_modify_dialog: false,
show_coupon_number_list_dialog: false,
coupon_number_list: [],
selected_coupon: null,
coupon_name: null,
can: null,
validity: null,
is_active: null,
is_multiple_use: false,
coupon_number_count: null,
coupon_type: 'CAN',
page: 1,
total_page: 0,
coupon_number_page: 1,
coupon_number_total_page: 0,
detail_headers: [
{
text: '순번',
align: 'center',
sortable: false,
value: 'couponNumberId',
},
{
text: '쿠폰번호',
align: 'center',
sortable: false,
value: 'couponNumber',
},
{
text: '사용여부',
align: 'center',
sortable: false,
value: 'isUsed',
},
],
headers: [
{
text: '순번',
align: 'center',
sortable: false,
value: 'id',
},
{
text: '쿠폰명',
align: 'center',
sortable: false,
value: 'couponName',
},
{
text: '쿠폰종류',
align: 'center',
sortable: false,
value: 'couponType',
},
{
text: '쿠폰금액',
align: 'center',
sortable: false,
value: 'can',
},
{
text: '발행수량',
align: 'center',
sortable: false,
value: 'couponCount',
},
{
text: '사용수량',
align: 'center',
sortable: false,
value: 'useCouponCount',
},
{
text: '유효기간',
align: 'center',
sortable: false,
value: 'validity',
},
{
text: '중복사용여부',
align: 'center',
sortable: false,
value: 'isMultipleUse',
},
{
text: '활성화',
align: 'center',
sortable: false,
value: 'isActive',
},
{
text: '엑셀 다운로드',
align: 'center',
sortable: false,
value: 'download',
},
{
text: '관리',
align: 'center',
sortable: false,
value: 'management',
}
],
coupon_list: [],
}
},
async created() {
await this.getCouponList();
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
showWriteDialog() {
this.show_write_dialog = true
},
validate() {
if (this.coupon_name === null) {
this.notifyError('쿠폰명을 입력하세요.')
return
}
if (this.can === null) {
this.notifyError('쿠폰금액을 입력하세요.')
return
}
if (isNaN(this.can)) {
this.notifyError('쿠폰금액은 숫자만 입력 가능합니다.')
return
}
if (this.can < 1) {
this.notifyError('쿠폰금액은 1 이상 입력 가능합니다.')
return
}
if (this.coupon_number_count === null) {
this.notifyError('발행수량을 입력하세요.')
return
}
if (isNaN(this.coupon_number_count)) {
this.notifyError('발행수량은 숫자만 입력 가능합니다.')
return
}
if (this.coupon_number_count < 5) {
this.notifyError('발행수량은 5 이상 입력 가능합니다.')
return
}
if (this.validity === null) {
this.notifyError('유효기간을 입력하세요.')
return
}
this.submit()
},
cancel() {
this.show_modify_dialog = false
this.show_write_dialog = false
this.show_coupon_number_list_dialog = false
this.selected_coupon = null
this.coupon_number_list = []
this.coupon_name = null
this.can = null
this.validity = null
this.is_active = null
this.is_multiple_use = false
this.coupon_number_count = null
this.coupon_type = 'CAN'
},
showModifyDialog(value) {
this.selected_coupon = value
this.validity = value.validity
this.is_multiple_use = value.isMultipleUse
this.is_active = value.isActive
this.show_modify_dialog = true
},
formatWithHyphens(value) {
const chunks = [];
for (let i = 0; i < value.length; i += 4) {
chunks.push(value.substr(i, 4));
}
return chunks.join('-');
},
async getCouponList() {
this.isLoading = true
try {
let res = await api.getCouponList(this.page);
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const total_page = Math.ceil(data.totalCount / 20)
this.coupon_list = data.items
if (total_page <= 0)
this.total_page = 1
else
this.total_page = total_page
}
} catch (e) {
this.notifyError("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
} finally {
this.isLoading = false
}
},
async getCouponNumberList(value) {
this.selected_coupon = value
await this.couponNumberNext()
},
async couponNumberNext() {
this.is_loading = true
try {
const res = await api.getCouponNumberList(this.selected_coupon.id, this.coupon_number_page)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const total_page = Math.ceil(data.totalCount / 20)
this.coupon_number_list = data.items
if (total_page <= 0)
this.coupon_number_total_page = 1
else
this.coupon_number_total_page = total_page
this.show_coupon_number_list_dialog = true
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
},
async next() {
await this.getCouponList()
},
async submit() {
if (this.is_loading) return;
this.is_loading = true
try {
const res = await api.generateCoupon(
this.coupon_name,
this.coupon_type,
this.can,
this.validity,
this.is_multiple_use,
this.coupon_number_count
);
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess(res.data.message || '쿠폰생성이 시작되었습니다. 잠시만 기다려 주세요')
this.coupon_list = [];
await this.getCouponList()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
this.is_loading = false
},
async downloadCouponNumberList(item) {
this.is_loading = true
try {
const response = await api.downloadCouponNumberList(item.id)
// Create a Blob from the PDF Stream
const file = new Blob(
[response.data],
{ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }
);
// Use FileSaver to save the file
saveAs(file, '쿠폰번호리스트.xlsx');
this.is_loading = false
} catch (e) {
this.notifyError('다운로드를 하지 못했습니다.\n다시 시도해 주세요.')
this.is_loading = false
}
},
async modify() {
if (this.is_loading) return;
this.is_loading = true
try {
const request = {couponId: this.selected_coupon.id}
if (this.validity !== this.selected_coupon.validity) {
request.validity = this.validity + ' 23:59:59';
}
if (this.is_active !== this.selected_coupon.isActive) {
request.isActive = this.is_active;
}
if (this.is_multiple_use !== this.selected_coupon.isMultipleUse) {
request.isMultipleUse = this.is_multiple_use;
}
const res = await api.modifyCoupon(request);
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess(res.data.message || '수정되었습니다.')
this.coupon_list = [];
await this.getCouponList()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
this.is_loading = false
}
}
}
</script>

View File

@@ -2,7 +2,7 @@
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>코인 환율관리</v-toolbar-title>
<v-toolbar-title> 환율관리</v-toolbar-title>
<v-spacer />
</v-toolbar>
@@ -27,7 +27,7 @@
v-bind="attrs"
v-on="on"
>
코인등록
등록
</v-btn>
</v-col>
</v-row>
@@ -35,33 +35,26 @@
<v-col>
<v-data-table
:headers="headers"
:items="coins"
:items="cans"
class="elevation-1"
hide-default-footer
>
<template v-slot:item.price="{ item }">
{{ item.price.toLocaleString('en-US') }}
{{ item.price.toLocaleString('en-US') }}
</template>
<template v-slot:item.coin="{ item }">
{{ item.coin.toLocaleString('en-US') }}코인
<template v-slot:item.can="{ item }">
{{ item.can.toLocaleString('en-US') }}
</template>
<template v-slot:item.rewardCoin="{ item }">
{{ item.rewardCoin.toLocaleString('en-US') }}코인
<template v-slot:item.rewardCan="{ item }">
{{ item.rewardCan.toLocaleString('en-US') }}
</template>
<template v-slot:item.management="{ item }">
<v-btn
:disabled="isLoading"
@click="showModifyCoinDialog(item)"
>
수정
</v-btn>
&nbsp;&nbsp;
<v-btn
:disabled="isLoading"
@click="deleteCoin(item)"
@click="deleteCan(item)"
>
삭제
</v-btn>
@@ -73,7 +66,7 @@
</template>
<v-card>
<v-card-title>코인 등록</v-card-title>
<v-card-title> 등록</v-card-title>
<v-card-text>
<v-text-field
v-model="price"
@@ -83,15 +76,15 @@
</v-card-text>
<v-card-text>
<v-text-field
v-model="coin"
label="코인"
v-model="can"
label=""
required
/>
</v-card-text>
<v-card-text>
<v-text-field
v-model="reward_coin"
label="리워드 코인"
v-model="reward_can"
label="리워드 "
required
/>
</v-card-text>
@@ -105,15 +98,6 @@
취소
</v-btn>
<v-btn
v-if="selected_coin !== null"
color="blue darken-1"
text
@click="modify"
>
수정
</v-btn>
<v-btn
v-else
color="blue darken-1"
text
@click="submit"
@@ -128,19 +112,19 @@
</template>
<script>
import * as api from '@/api/coin'
import * as api from '@/api/can'
export default {
name: "CoinView",
name: "CanView",
data() {
return {
isLoading: false,
show_dialog: false,
selected_coin: null,
selected_can: null,
price: null,
coin: null,
reward_coin: null,
can: null,
reward_can: null,
headers: [
{
text: '원화(VAT포함)',
@@ -149,16 +133,16 @@ export default {
value: 'price',
},
{
text: '충전코인',
text: '충전',
align: 'center',
sortable: false,
value: 'coin',
value: 'can',
},
{
text: '리워드코인',
text: '리워드',
align: 'center',
sortable: false,
value: 'rewardCoin',
value: 'rewardCan',
},
{
text: '관리',
@@ -167,12 +151,12 @@ export default {
value: 'management'
},
],
coins: [],
cans: [],
}
},
async created() {
await this.getCoins()
await this.getCans()
},
methods: {
@@ -186,26 +170,17 @@ export default {
cancel() {
this.show_dialog = false
this.coin = null
this.can = null
this.price = null
this.reward_coin = null
this.selected_coin = null
this.reward_can = null
this.selected_can = null
},
showModifyCoinDialog(item) {
this.selected_coin = item
this.price = item.price
this.coin = item.coin
this.reward_coin = item.rewardCoin
this.show_dialog = true
},
async getCoins() {
async getCans() {
this.isLoading = true
try {
let res = await api.getCoins();
this.coins = res.data.data
let res = await api.getCans();
this.cans = res.data.data
} catch (e) {
this.notifyError("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
} finally {
@@ -213,60 +188,34 @@ export default {
}
},
async deleteCoin(item) {
async deleteCan(item) {
this.isLoading = true
let res = await api.deleteCoin(item.id)
let res = await api.deleteCan(item.id)
if (res.status === 200 && res.data.success === true) {
this.notifySuccess(res.data.message || '삭제되었습니다.')
this.coins = []
await this.getCoins()
this.cans = []
await this.getCans()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
},
async modify() {
this.isLoading = true
try {
const res = await api.modifyCoin(this.selected_coin.id, this.coin, this.reward_coin, this.price)
if (res.status === 200 && res.data.success === true) {
this.show_dialog = false
this.coin = null
this.price = null
this.reward_coin = null
this.selected_coin = null
this.notifySuccess(res.data.message || '수정되었습니다.')
this.coins = []
await this.getCoins()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
} finally {
this.isLoading = false
}
},
async submit() {
this.isLoading = true
const res = await api.insertCoin(this.coin, this.reward_coin, this.price)
const res = await api.insertCan(this.can, this.reward_can, this.price)
if (res.status === 200 && res.data.success === true) {
this.show_dialog = false
this.coin = null
this.can = null
this.price = null
this.reward_coin = null
this.selected_coin = null
this.reward_can = null
this.selected_can = null
this.notifySuccess(res.data.message || '등록되었습니다.')
this.coins = []
await this.getCoins()
this.cans = []
await this.getCans()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}

View File

@@ -0,0 +1,757 @@
<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="#9970ff"
dark
depressed
@click="showWriteDialog"
>
시그니처 등록
</v-btn>
</v-col>
</v-row>
<v-row class="row-sort">
<v-col>
<input
id="sort-newest"
v-model="sort_type"
type="radio"
value="NEWEST"
class="radio-sort"
@change="changeSortType"
>
<label
for="sort-newest"
class="radio-label-sort"
>
최신순
</label>
</v-col>
<v-col>
<input
id="sort-can-high"
v-model="sort_type"
type="radio"
value="CAN_HIGH"
class="radio-sort"
@change="changeSortType"
>
<label
for="sort-can-high"
class="radio-label-sort"
>
높은캔순
</label>
</v-col>
<v-col>
<input
id="sort-can-low"
v-model="sort_type"
type="radio"
value="CAN_LOW"
class="radio-sort"
@change="changeSortType"
>
<label
for="sort-can-low"
class="radio-label-sort"
>
낮은캔순
</label>
</v-col>
<v-col cols="10" />
</v-row>
<v-row>
<v-col>
<v-data-table
:headers="headers"
:items="signature_list"
: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.nickname="{ item }">
{{ item.nickname }}
</template>
<template v-slot:item.can="{ item }">
{{ item.can }}
</template>
<template v-slot:item.isAdult="{ item }">
<h3 v-if="item.isAdult">
O
</h3>
<h3 v-else>
X
</h3>
</template>
<template v-slot:item.image="{ item }">
<v-img
:src="item.image"
max-width="200"
max-height="200"
align="center"
class="center-image"
/>
</template>
<template v-slot:item.management="{ item }">
<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="showDeleteConfirm(item)"
>
삭제
</v-btn>
</v-col>
<v-col />
</v-row>
</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-row align="center">
<v-col cols="4">
크리에이터 번호
</v-col>
<v-col cols="8">
<v-text-field
v-model="creator_id"
label="크리에이터 번호"
/>
</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-text-field
v-model="can"
label="캔"
/>
</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="is_adult"
type="checkbox"
>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
이미지
</v-col>
<v-col cols="8">
<div class="image-select">
<label for="image">
이미지 불러오기
</label>
<v-file-input
id="image"
v-model="image"
accept="image/*"
@change="imageAdd"
/>
</div>
<img
v-if="image_url"
:src="image_url"
alt=""
class="image-preview"
>
</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-text-field
v-model="time"
label="시간(초)"
/>
</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="validate"
>
등록
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-row>
<v-row>
<v-dialog
v-model="show_modify_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="can"
label="캔"
/>
</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="is_adult"
type="checkbox"
>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row align="center">
<v-col cols="4">
이미지
</v-col>
<v-col cols="8">
<div class="image-select">
<label for="image">
이미지 불러오기
</label>
<v-file-input
id="image"
v-model="image"
accept="image/*"
@change="imageAdd"
/>
</div>
<img
v-if="image_url"
:src="image_url"
alt=""
class="image-preview"
>
</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-text-field
v-model="time"
label="시간(초)"
/>
</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="modifySignatureCan"
>
수정
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-row>
<v-row>
<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="deleteSignature"
>
확인
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-row>
</div>
</template>
<script>
import * as api from "@/api/signature";
export default {
name: "CanSignature",
data() {
return {
is_loading: false,
show_write_dialog: false,
show_modify_dialog: false,
show_delete_confirm_dialog: false,
signature_list: [],
page: 1,
total_page: 0,
can: 0,
time: 7,
image: null,
is_adult: false,
image_url: null,
creator_id: null,
is_active: null,
selected_signature_can: {},
sort_type: 'NEWEST',
headers: [
{
text: '순번',
align: 'center',
sortable: false,
value: 'id',
},
{
text: '닉네임',
align: 'center',
sortable: false,
value: 'nickname',
},
{
text: '캔',
align: 'center',
sortable: false,
value: 'can',
},
{
text: '19금',
align: 'center',
sortable: false,
value: 'isAdult',
},
{
text: '이미지',
align: 'center',
sortable: false,
value: 'image',
},
{
text: '시간(초)',
align: 'center',
sortable: false,
value: 'time',
},
{
text: '관리',
align: 'center',
sortable: false,
value: 'management',
},
],
}
},
async created() {
await this.getSignatureList();
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
imageAdd(payload) {
const file = payload;
if (file) {
this.image_url = URL.createObjectURL(file)
URL.revokeObjectURL(file)
} else {
this.image_url = null
}
},
showWriteDialog() {
this.show_write_dialog = true
},
showModifyDialog(item) {
this.can = item.can;
this.time = item.time;
this.is_adult = item.isAdult
this.image_url = item.image;
this.selected_signature_can = item
this.show_modify_dialog = true
},
showDeleteConfirm(item) {
this.selected_signature_can = item
this.show_delete_confirm_dialog = true
},
validate() {
if (
this.can === 0 ||
this.image === null ||
this.creator_id === null
) {
this.notifyError('내용을 입력하세요')
} else if (this.time < 3 || this.time > 20) {
this.notifyError('시간은 3초 이상 20초 이하를 입력하세요.')
} else {
this.submit()
}
},
cancel() {
this.show_write_dialog = false
this.show_modify_dialog = false
this.show_delete_confirm_dialog = false
this.image = null
this.image_url = null
this.can = 0
this.time = 7
this.is_active = null
this.is_adult = false
this.selected_signature_can = {}
},
async changeSortType() {
await this.getSignatureList();
},
async getSignatureList() {
this.isLoading = true
try {
let res = await api.getSignatureList(this.page, this.sort_type);
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const total_page = Math.ceil(data.totalCount / 20)
this.signature_list = data.items
if (total_page <= 0)
this.total_page = 1
else
this.total_page = total_page
}
} catch (e) {
this.notifyError("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
} finally {
this.isLoading = false
}
},
async submit() {
if (this.is_loading) return;
this.is_loading = true
try {
const formData = new FormData()
formData.append("can", this.can)
formData.append("time", this.time)
formData.append("image", this.image)
formData.append("isAdult", this.is_adult)
formData.append("creator_id", this.creator_id)
const res = await api.createSignature(formData)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess(res.data.message || '등록되었습니다.')
this.page = 1
await this.getSignatureList()
} else {
this.is_loading = false
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
} finally {
this.is_loading = false
}
},
async modifySignatureCan() {
if (
this.image === null &&
this.is_adult === this.selected_signature_can.isAdult &&
this.can === this.selected_signature_can.can &&
this.time === this.selected_signature_can.time
) {
this.notifyError('변경사항이 없습니다.')
return;
}
if (this.is_loading) return;
this.is_loading = true
try {
const formData = new FormData()
formData.append("id", this.selected_signature_can.id)
if (this.image !== null) {
formData.append("image", this.image)
}
if (this.is_adult !== this.selected_signature_can.isAdult) {
formData.append("isAdult", this.is_adult)
}
if (this.can !== this.selected_signature_can.can) {
formData.append("can", this.can)
}
if (this.time !== this.selected_signature_can.time) {
formData.append("time", this.time)
}
const res = await api.modifySignature(formData)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess(res.data.message || '수정되었습니다.')
this.page = 1
await this.getSignatureList()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
} finally {
this.is_loading = false
}
},
async deleteSignature() {
if (this.is_loading) return;
this.is_loading = true
try {
const formData = new FormData()
formData.append("id", this.selected_signature_can.id)
formData.append("isActive", false)
const res = await api.modifySignature(formData)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess(res.data.message || '등록되었습니다.')
this.page = 1
await this.getSignatureList()
} else {
this.is_loading = false
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
} finally {
this.is_loading = false
}
},
async next() {
await this.getSignatureList();
}
}
}
</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;
}
.v-card__text {
margin-top: 20px;
}
.v-card__actions {
margin-top: 100px;
}
.v-card__actions > .v-btn {
font-size: 20px;
}
.center-image {
display: block;
margin: 0 auto;
}
.row-sort {
margin-top: 40px;
}
.radio-sort {
display: none;
}
.radio-label-sort {
font-weight: normal;
color: black;
}
.radio-sort:checked + .radio-label-sort {
font-weight: bold;
color: #3bb9f1;
}
</style>

View File

@@ -2,7 +2,7 @@
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>코인 충전 현황</v-toolbar-title>
<v-toolbar-title> 충전 현황</v-toolbar-title>
<v-spacer />
</v-toolbar>
@@ -36,7 +36,7 @@
<v-col cols="2">
<v-btn
block
color="#9970ff"
color="#3bb9f1"
dark
depressed
@click="getChargeStatus"
@@ -65,6 +65,10 @@
{{ item.chargeAmount.toLocaleString() }}
</template>
<template v-slot:item.locale="{ item }">
{{ item.locale }}
</template>
<template v-slot:item.chargeCount="{ item }">
{{ item.chargeCount }}
</template>
@@ -132,7 +136,7 @@ import * as api from "@/api/charge_status";
import datetime from 'vuejs-datetimepicker';
export default {
name: "CoinStatus",
name: "CanStatus",
components: {datetime},
data() {
@@ -168,6 +172,12 @@ export default {
sortable: false,
value: 'amount',
},
{
text: '화폐단위',
align: 'center',
sortable: false,
value: 'locale',
},
{
text: '날짜',
align: 'center',

View File

@@ -1,13 +0,0 @@
<template>
<div>회원별 코인관리</div>
</template>
<script>
export default {
name: "CoinByUser"
}
</script>
<style scoped>
</style>

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

@@ -10,26 +10,25 @@
<v-container>
<v-row>
<v-col>
<v-text-field
v-model="utm_source"
label="예) youtube, google"
/>
<v-col cols="8">
<v-radio-group
v-model="status"
row
@change="getAudioContent"
>
<v-radio
label="오픈됨"
value="OPEN"
/>
<v-radio
label="오픈예정"
value="SCHEDULED"
/>
</v-radio-group>
</v-col>
<v-spacer />
<v-col>
<v-text-field
v-model="utm_medium"
label="예) email, cpc"
/>
</v-col>
<v-col>
<v-text-field
v-model="utm_campaign"
label="예) 화이트데이"
/>
</v-col>
<v-col cols="2" />
<v-col cols="4">
<v-text-field
v-model="search_word"
label="콘텐츠 제목 혹은 크리에이터 닉네임을 입력하세요"
@@ -64,9 +63,6 @@
<th class="text-center">
내용
</th>
<th class="text-center">
큐레이션
</th>
<th class="text-center">
크리에이터
</th>
@@ -79,6 +75,9 @@
<th class="text-center">
가격
</th>
<th class="text-center">
한정판
</th>
<th class="text-center">
19
</th>
@@ -91,6 +90,9 @@
<th class="text-center">
등록일
</th>
<th class="text-center">
오픈 예정일
</th>
<th class="text-center">
관리
</th>
@@ -104,28 +106,60 @@
<td>{{ item.audioContentId }}</td>
<td align="center">
<v-img
max-width="100"
max-height="100"
max-width="70"
max-height="70"
:src="item.coverImageUrl"
class="rounded-circle"
/>
<br>
<a
:href="item.coverImageUrl"
class="v-btn v-btn--outlined"
>
다운로드
</a>
</td>
<td>{{ item.title }}</td>
<td style="max-width: 350px !important; word-break:break-all; height: auto;">
{{ item.detail }}
<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.detail"
:lines="3"
/>
</td>
<td>{{ item.curationTitle || '없음' }}</td>
<td>{{ item.creatorNickname }}</td>
<td>{{ item.theme }}</td>
<td style="max-width: 200px !important; word-break:break-all; height: auto;">
{{ item.tags }}
<td style="max-width: 100px !important; word-break:break-all; height: auto;">
<vue-show-more-text
:text="item.tags"
:lines="3"
/>
</td>
<td v-if="item.price > 0">
{{ item.price }} 코인
{{ item.price }}
</td>
<td v-else>
무료
</td>
<td
v-if="item.totalContentCount > 0 && item.remainingContentCount > 0"
style="min-width: 100px !important; word-break:break-all; height: auto;"
>
{{ item.totalContentCount - item.remainingContentCount }} / {{ item.totalContentCount }}
</td>
<td
v-else-if="item.totalContentCount > 0 && item.remainingContentCount <= 0"
style="min-width: 100px !important; word-break:break-all; height: auto;"
>
Sold Out
</td>
<td v-else>
X
</td>
<td>
<div v-if="item.isAdult">
O
@@ -143,6 +177,7 @@
/>
</td>
<td>{{ item.date }}</td>
<td>{{ item.releaseDate }}</td>
<td>
<v-row>
<v-col>
@@ -271,15 +306,15 @@
<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"
v-model="audio_content.theme_id"
:items="themeList"
item-text="title"
item-value="value"
label="큐레이션 선택"
label="테마 선택"
/>
</v-col>
</v-row>
@@ -342,279 +377,276 @@ import * as api from '@/api/audio_content'
import * as dynamicLink from "@/api/firebase_dynamic_link";
import VuetifyAudio from 'vuetify-audio'
import VueShowMoreText from 'vue-show-more-text'
export default {
name: "AudioContentList",
name: "AudioContentList",
components: {VuetifyAudio},
components: {VuetifyAudio, VueShowMoreText},
data() {
return {
is_loading: false,
show_modify_dialog: false,
show_delete_confirm_dialog: false,
page: 1,
total_page: 0,
search_word: '',
audio_content: {},
audio_contents: [],
curations: [],
selected_audio_content: {},
utm_source: '',
utm_medium: '',
utm_campaign: '',
}
},
async created() {
await this.getCurations()
await this.getAudioContent()
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
deleteConfirm(item) {
this.selected_audio_content = item
this.show_delete_confirm_dialog = true
},
deleteCancel() {
this.selected_audio_content = {}
this.show_delete_confirm_dialog = false
},
showModifyDialog(item) {
this.selected_audio_content = item
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.is_adult = item.isAdult
this.audio_content.is_comment_available = item.isCommentAvailable
this.audio_content.is_default_cover_image = false
console.log(this.audio_content)
this.show_modify_dialog = true
},
cancel() {
this.selected_audio_content = {}
this.audio_content = {}
this.show_modify_dialog = false
this.show_delete_confirm_dialog = false
},
async modify() {
if (
this.audio_content.title === null ||
this.audio_content.title === undefined ||
this.audio_content.title.trim().length <= 0
) {
this.notifyError("제목을 입력하세요")
return
}
if (
this.audio_content.detail === null ||
this.audio_content.detail === undefined ||
this.audio_content.detail.trim().length <= 0
) {
this.notifyError("내용을 입력하세요")
return
}
if (this.is_loading) return;
this.isLoading = true
try {
const request = {
id: this.audio_content.id,
isDefaultCoverImage: this.audio_content.is_default_cover_image
}
if (
this.selected_audio_content.title !== this.audio_content.title &&
this.audio_content.title.trim().length > 0
) {
request.title = this.audio_content.title
}
if (
this.selected_audio_content.detail !== this.audio_content.detail &&
this.audio_content.detail.trim().length > 0
) {
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.isAdult !== this.audio_content.is_adult) {
request.isAdult = this.audio_content.is_adult
}
if (this.selected_audio_content.isCommentAvailable !== this.audio_content.is_comment_available) {
request.isCommentAvailable = this.audio_content.is_comment_available
}
console.log(request)
const res = await api.modifyAudioContent(request)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess('수정되었습니다.')
this.audio_contents = []
await this.getAudioContent()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async deleteAudioContent() {
if (this.is_loading) return;
this.is_loading = true
try {
let request = {id: this.selected_audio_content.audioContentId, isActive: false}
const res = await api.modifyAudioContent(request)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess('삭제되었습니다.')
this.audio_contents = []
await this.getAudioContent()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async next() {
if (this.search_word.length < 2) {
this.search_word = ''
await this.getAudioContent()
} else {
await this.searchAudioContent()
}
},
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 {
const res = await api.getAudioContentList(this.page)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const total_page = Math.ceil(data.totalCount / 10)
this.audio_contents = data.items
if (total_page <= 0)
this.total_page = 1
else
this.total_page = total_page
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
},
async search() {
this.page = 1
await this.searchAudioContent()
},
async searchAudioContent() {
if (this.search_word.length === 0) {
await this.getAudioContent()
} else if (this.search_word.length < 2) {
this.notifyError('검색어를 2글자 이상 입력하세요.')
} else {
this.is_loading = true
try {
const res = await api.searchAudioContent(this.search_word, this.page)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const total_page = Math.ceil(data.totalCount / 10)
this.audio_contents = data.items
if (total_page <= 0)
this.total_page = 1
else
this.total_page = total_page
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
}
},
async shareAudioContent(item) {
this.is_loading = true
try {
const linkData = await dynamicLink.shareAudioContent(item, this.utm_source, this.utm_medium, this.utm_campaign);
if (linkData.status === 200) {
await navigator.clipboard.writeText(linkData.data.shortLink)
this.notifySuccess("링크가 복사되었습니다.")
} else {
this.notifyError("링크를 생성하지 못했습니다.")
}
} finally {
this.is_loading = false
}
},
data() {
return {
is_loading: false,
show_modify_dialog: false,
show_delete_confirm_dialog: false,
page: 1,
total_page: 0,
status: 'OPEN',
search_word: '',
audio_content: {},
audio_contents: [],
themeList: [],
selected_audio_content: {},
utm_source: '',
utm_medium: '',
utm_campaign: '',
}
},
async created() {
await this.getAudioContentThemeList();
await this.getAudioContent()
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
deleteConfirm(item) {
this.selected_audio_content = item
this.show_delete_confirm_dialog = true
},
deleteCancel() {
this.selected_audio_content = {}
this.show_delete_confirm_dialog = false
},
showModifyDialog(item) {
this.selected_audio_content = item
this.audio_content.id = item.audioContentId
this.audio_content.title = item.title
this.audio_content.detail = item.detail
this.audio_content.theme_id = item.themeId
this.audio_content.is_adult = item.isAdult
this.audio_content.is_comment_available = item.isCommentAvailable
this.audio_content.is_default_cover_image = false
this.show_modify_dialog = true
},
cancel() {
this.selected_audio_content = {}
this.audio_content = {}
this.show_modify_dialog = false
this.show_delete_confirm_dialog = false
},
async modify() {
if (
this.audio_content.title === null ||
this.audio_content.title === undefined ||
this.audio_content.title.trim().length <= 0
) {
this.notifyError("제목을 입력하세요")
return
}
if (
this.audio_content.detail === null ||
this.audio_content.detail === undefined ||
this.audio_content.detail.trim().length <= 0
) {
this.notifyError("내용을 입력하세요")
return
}
if (this.is_loading) return;
this.isLoading = true
try {
const request = {
id: this.audio_content.id,
isDefaultCoverImage: this.audio_content.is_default_cover_image
}
if (
this.selected_audio_content.title !== this.audio_content.title &&
this.audio_content.title.trim().length > 0
) {
request.title = this.audio_content.title
}
if (
this.selected_audio_content.detail !== this.audio_content.detail &&
this.audio_content.detail.trim().length > 0
) {
request.detail = this.audio_content.detail
}
if (this.selected_audio_content.themeId !== this.audio_content.theme_id) {
request.themeId = this.audio_content.theme_id
}
if (this.selected_audio_content.isAdult !== this.audio_content.is_adult) {
request.isAdult = this.audio_content.is_adult
}
if (this.selected_audio_content.isCommentAvailable !== this.audio_content.is_comment_available) {
request.isCommentAvailable = this.audio_content.is_comment_available
}
const res = await api.modifyAudioContent(request)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess('수정되었습니다.')
this.audio_contents = []
await this.getAudioContent()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async deleteAudioContent() {
if (this.is_loading) return;
this.is_loading = true
try {
let request = {id: this.selected_audio_content.audioContentId, isActive: false}
const res = await api.modifyAudioContent(request)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess('삭제되었습니다.')
this.audio_contents = []
await this.getAudioContent()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async next() {
if (this.search_word.length < 2) {
this.search_word = ''
await this.getAudioContent()
} else {
await this.searchAudioContent()
}
},
async getAudioContentThemeList() {
this.is_loading = true
try {
const res = await api.getAudioContentThemeList()
if (res.status === 200 && res.data.success === true) {
this.themeList = res.data.data.map((item) => {
return {title: item.theme, value: item.id}
})
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
},
async getAudioContent() {
this.is_loading = true
try {
const res = await api.getAudioContentList(this.status, this.page)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const total_page = Math.ceil(data.totalCount / 10)
this.audio_contents = data.items
if (total_page <= 0)
this.total_page = 1
else
this.total_page = total_page
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
},
async search() {
this.page = 1
await this.searchAudioContent()
},
async searchAudioContent() {
if (this.search_word.length === 0) {
await this.getAudioContent()
} else if (this.search_word.length < 2) {
this.notifyError('검색어를 2글자 이상 입력하세요.')
} else {
this.is_loading = true
try {
const res = await api.searchAudioContent(this.search_word, this.page)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const total_page = Math.ceil(data.totalCount / 10)
this.audio_contents = data.items
if (total_page <= 0)
this.total_page = 1
else
this.total_page = total_page
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
}
},
async shareAudioContent(item) {
this.is_loading = true
try {
const linkData = await dynamicLink.shareAudioContent(item, this.utm_source, this.utm_medium, this.utm_campaign);
if (linkData.status === 200) {
await navigator.clipboard.writeText(linkData.data.shortLink)
this.notifySuccess("링크가 복사되었습니다.")
} else {
this.notifyError("링크를 생성하지 못했습니다.")
}
} finally {
this.is_loading = false
}
},
}
}
</script>

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"
@@ -69,7 +83,32 @@
</template>
<v-card>
<v-card-title>배너 등록</v-card-title>
<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-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">
@@ -92,6 +131,10 @@
value="EVENT"
label="이벤트"
/>
<v-radio
value="SERIES"
label="시리즈"
/>
</v-radio-group>
</v-col>
</v-row>
@@ -102,12 +145,19 @@
크리에이터
</v-col>
<v-col cols="8">
<v-select
v-model="banner.creator_id"
<v-combobox
v-model="banner.creator_nickname"
:items="creators"
:loading="is_loading"
:search-input.sync="search_query_creator"
label="크리에이터를 검색하세요"
item-text="name"
item-value="value"
label="크리에이터 선택"
no-data-text="No results found"
hide-selected
clearable
@change="onSelect"
@update:search-input="onSearchUpdate"
/>
</v-col>
</v-row>
@@ -126,6 +176,29 @@
</v-col>
</v-row>
</v-card-text>
<v-card-text v-else-if="banner.type === 'SERIES'">
<v-row align="center">
<v-col cols="4">
시리즈
</v-col>
<v-col cols="8">
<v-combobox
v-model="banner.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>
<v-card-text v-else>
<v-row align="center">
<v-col cols="4">
@@ -234,8 +307,10 @@
<script>
import Draggable from "vuedraggable";
import debounce from "lodash/debounce";
import * as accountApi from "@/api/member";
import * as seriesApi from "@/api/audio_content_series"
import * as memberApi from "@/api/member";
import * as eventApi from "@/api/event";
import * as api from "@/api/audio_content"
@@ -246,22 +321,46 @@ export default {
data() {
return {
is_selecting: false,
is_loading: false,
is_modify: false,
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
}
},
watch: {
search_query_creator() {
if (!this.is_selecting) {
this.debouncedSearch();
}
},
search_query_series() {
if (!this.is_selecting) {
this.debouncedSearchSeries();
}
}
},
async created() {
await this.getCreatorList()
await this.getEvents()
await this.getBanners()
await this.getAudioContentMainTabList()
},
mounted() {
this.debouncedSearch = debounce(this.searchCreator, 500);
this.debouncedSearchSeries = debounce(this.searchSeries, 500);
},
methods: {
@@ -277,9 +376,13 @@ export default {
cancel() {
this.is_modify = false
this.is_selecting = false
this.show_write_dialog = false
this.banner = {type: 'CREATOR'}
this.show_delete_confirm_dialog = false
this.banner = {type: 'CREATOR', tab_id: 1}
this.selected_banner = {}
this.search_query_creator = ''
this.search_query_series = ''
},
notifyError(message) {
@@ -290,10 +393,33 @@ 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
this.is_selecting = true; // 선택 중 플래그 활성화
this.banner.id = banner.id
this.banner.type = banner.type
this.banner.thumbnail_image_url = banner.thumbnailImageUrl
@@ -301,8 +427,15 @@ export default {
this.banner.event_thumbnail_image = banner.eventThumbnailImage
this.banner.creator_id = banner.creatorId
this.banner.creator_nickname = banner.creatorNickname
this.banner.series_id = banner.seriesId
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; // 선택 상태 해제
}, 1000);
this.show_write_dialog = true
},
@@ -320,6 +453,13 @@ export default {
return false;
}
if (
this.banner.type === 'SERIES' &&
(this.banner.series_id === null || this.banner.series_id === undefined)) {
this.notifyError("시리즈를 선택하세요")
return false;
}
if (
this.banner.type === 'LINK' &&
(this.banner.link === null || this.banner.link === undefined || this.banner.link.trim().length <= 0)
@@ -366,6 +506,12 @@ export default {
request.eventId = this.banner.event_id
} else if (this.banner.type === 'LINK') {
request.link = this.banner.link
} else if (this.banner.type === 'SERIES') {
request.seriesId = this.banner.series_id
}
if (this.banner.tab_id !== 1) {
request.tabId = this.banner.tab_id
}
formData.append("request", JSON.stringify(request))
@@ -417,6 +563,15 @@ export default {
request.creatorId = this.banner.creator_id
}
if (
this.selected_banner.series_id !== this.banner.series_id &&
this.banner.series_id !== null &&
this.banner.series_id !== undefined
) {
request.type = this.banner.type
request.seriesId = this.banner.series_id
}
if (
this.selected_banner.link !== this.banner.link &&
this.banner.link !== null &&
@@ -430,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)
@@ -485,34 +644,92 @@ export default {
}
},
async getCreatorList() {
this.is_loading = true
async searchCreator() {
if (this.search_query_creator === null || this.search_query_creator.length < 2) {
this.creators = [];
return;
}
this.is_loading = true;
try {
const res = await accountApi.getCounselorList()
const res = await memberApi.searchCreator(this.search_query_creator, 1);
if (res.status === 200 && res.data.success === true) {
this.creators = res.data.data.map((item) => {
return {name: item.nickname, value: item.id}
this.creators = res.data.data.items.map((item) => {
return {name: item.nickname, value: item.id}
})
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
this.creators = []
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
} finally {
this.is_loading = false
this.is_loading = false
}
},
onSelect(value) {
this.banner.creator_id = value.value
this.is_selecting = true; // 선택 중 플래그 활성화
setTimeout(() => {
this.is_selecting = false; // 선택 상태 해제
}, 0);
},
onSearchUpdate(value) {
if (!this.is_selecting) {
this.search_query_creator = value
}
},
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
}
},
onSelectSeries(value) {
this.banner.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
}
},
async getEvents() {
this.is_loading = true
try {
const res = await eventApi.getEvents(1)
if (res.status === 200 && res.data.success === true) {
this.events = res.data.data.eventList.map((item) => {
this.events = res.data.data.map((item) => {
return {title: item.title, value: item.id}
})
} else {
@@ -529,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

@@ -1,13 +0,0 @@
<template>
<div>수다친구-정산관리</div>
</template>
<script>
export default {
name: "CounselorCalculate"
}
</script>
<style scoped>
</style>

View File

@@ -2,7 +2,7 @@
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>요즘친구 리스트</v-toolbar-title>
<v-toolbar-title>크리에이터 리스트</v-toolbar-title>
<v-spacer />
</v-toolbar>
@@ -10,25 +10,7 @@
<v-container>
<v-row>
<v-col>
<v-text-field
v-model="utm_source"
label="예) youtube, google"
/>
</v-col>
<v-col>
<v-text-field
v-model="utm_medium"
label="예) email, cpc"
/>
</v-col>
<v-col>
<v-text-field
v-model="utm_campaign"
label="예) 화이트데이"
/>
</v-col>
<v-col cols="2" />
<v-spacer />
<v-col cols="4">
<v-text-field
v-model="search_word"
@@ -46,6 +28,11 @@
</v-text-field>
</v-col>
</v-row>
<v-row>
<v-col class="text-left total-creator">
전체 <span>{{ total_creator_count }}</span>
</v-col>
</v-row>
<v-row>
<v-col>
<v-simple-table class="elevation-10">
@@ -99,6 +86,13 @@
:src="item.profileUrl"
class="rounded-circle"
/>
<br>
<a
:href="item.profileUrl"
class="v-btn v-btn--outlined"
>
다운로드
</a>
</td>
<td>{{ item.userType }}</td>
<td>
@@ -159,6 +153,7 @@ export default {
is_loading: false,
page: 1,
total_page: 0,
total_creator_count: 0,
search_word: '',
accounts: [],
account: {},
@@ -194,7 +189,7 @@ export default {
} else {
this.is_loading = true
try {
const res = await api.searchCreatorAccount(this.search_word, this.page)
const res = await api.searchCreator(this.search_word, this.page)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
@@ -229,11 +224,12 @@ export default {
async getAccounts() {
this.is_loading = true
try {
const res = await api.getCreatorAccountList(this.page)
const res = await api.getCreatorList(this.page)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const total_page = Math.ceil(data.totalCount / 20)
this.total_creator_count = data.totalCount;
this.accounts = data.items
if (total_page <= 0)
@@ -269,6 +265,12 @@ export default {
}
</script>
<style scoped>
<style>
.total-creator {
padding-bottom: 0;
}
.total-creator > span {
color: #3bb9f1;
}
</style>

View File

@@ -1,13 +0,0 @@
<template>
<div>후기관리</div>
</template>
<script>
export default {
name: "CounselorReview"
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,294 @@
<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.nickname="{ item }">
{{ item.nickname }}
</template>
<template v-slot:item.subsidy="{ item }">
{{ item.subsidy.toLocaleString('en-US') }}
</template>
<template v-slot:item.liveSettlementRatio="{ item }">
{{ item.liveSettlementRatio }}%
</template>
<template v-slot:item.contentSettlementRatio="{ item }">
{{ item.contentSettlementRatio }}%
</template>
<template v-slot:item.communitySettlementRatio="{ item }">
{{ item.communitySettlementRatio }}%
</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-text-field
v-model="creator_settlement_ratio.creator_id"
label="크리에이터 번호"
/>
</v-card-text>
<v-card-text>
<v-text-field
v-model="creator_settlement_ratio.subsidy"
label="지원금"
/>
</v-card-text>
<v-card-text>
<v-text-field
v-model="creator_settlement_ratio.liveSettlementRatio"
label="라이브 정산비율(%)"
/>
</v-card-text>
<v-card-text>
<v-text-field
v-model="creator_settlement_ratio.contentSettlementRatio"
label="콘텐츠 정산비율(%)"
/>
</v-card-text>
<v-card-text>
<v-text-field
v-model="creator_settlement_ratio.communitySettlementRatio"
label="커뮤니티 정산비율(%)"
/>
</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="validate"
>
등록하기
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-row>
</v-container>
</div>
</template>
<script>
import * as api from "@/api/calculate";
export default {
name: "CreatorSettlementRatio",
data() {
return {
is_loading: false,
page: 1,
total_page: 0,
items: [],
creator_settlement_ratio: {},
show_write_dialog: false,
headers: [
{
text: '닉네임',
align: 'center',
sortable: false,
value: 'nickname',
},
{
text: '지원금',
align: 'center',
sortable: false,
value: 'subsidy',
},
{
text: '라이브 정산비율',
align: 'center',
sortable: false,
value: 'liveSettlementRatio',
},
{
text: '콘텐츠 정산비율',
align: 'center',
sortable: false,
value: 'contentSettlementRatio',
},
{
text: '커뮤니티 정산비율',
align: 'center',
sortable: false,
value: 'communitySettlementRatio',
},
],
}
},
async created() {
await this.getSettlementRatio()
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
showWriteDialog() {
this.show_write_dialog = true
},
cancel() {
this.creator_settlement_ratio = {}
this.show_write_dialog = false
},
validate() {
if (this.creator_settlement_ratio.creator_id === null) {
this.notifyError('크리에이터 번호를 입력하세요')
return
}
if (this.creator_settlement_ratio.subsidy === null || isNaN(this.creator_settlement_ratio.subsidy)) {
this.notifyError('지원금은 숫자만 입력가능합니다')
return
}
if (this.creator_settlement_ratio.liveSettlementRatio === null || isNaN(this.creator_settlement_ratio.liveSettlementRatio)) {
this.notifyError('라이브 정산비율은 숫자만 입력가능합니다')
return
}
if (this.creator_settlement_ratio.contentSettlementRatio === null || isNaN(this.creator_settlement_ratio.contentSettlementRatio)) {
this.notifyError('콘텐츠 정산비율은 숫자만 입력가능합니다')
return
}
if (this.creator_settlement_ratio.communitySettlementRatio === null || isNaN(this.creator_settlement_ratio.communitySettlementRatio)) {
this.notifyError('커뮤니티 정산비율은 숫자만 입력가능합니다')
return
}
this.createCreatorSettlementRatio();
},
async createCreatorSettlementRatio() {
if (this.is_loading) return;
this.is_loading = true
try {
const res = await api.createCreatorSettlementRatio(this.creator_settlement_ratio)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess(res.data.message || '등록되었습니다.')
this.items = [];
this.creator_settlement_ratio = {};
await this.getSettlementRatio()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
this.is_loading = false
},
async getSettlementRatio() {
this.is_loading = true
try {
const res = await api.getSettlementRatio(this.page)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const total_page = Math.ceil(data.totalCount / 20)
this.items = 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('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async next() {
if (this.search_word.length < 2) {
this.search_word = ''
}
await this.getSettlementRatio()
},
},
}
</script>
<style scoped>
</style>

View File

@@ -2,7 +2,7 @@
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>수다친구 관심사 관리</v-toolbar-title>
<v-toolbar-title>크리에이터 관심사 관리</v-toolbar-title>
<v-spacer />
</v-toolbar>
@@ -78,7 +78,12 @@
</template>
<v-card>
<v-card-title>관심사 등록</v-card-title>
<v-card-title v-if="selected_tag !== null">
관심사 수정
</v-card-title>
<v-card-title v-else>
관심사 등록
</v-card-title>
<v-card-text>
<v-text-field
v-model="tag_text"
@@ -86,6 +91,19 @@
required
/>
</v-card-text>
<v-card-text>
<v-row>
<v-col cols="4">
연령제한
</v-col>
<v-col cols="8">
<input
v-model="is_adult"
type="checkbox"
>
</v-col>
</v-row>
</v-card-text>
<div class="image-select">
<label for="image">
이미지 등록
@@ -135,11 +153,11 @@
</template>
<script>
import * as api from "@/api/counselor_tag";
import * as api from "@/api/creator_tag";
import Draggable from "vuedraggable";
export default {
name: "CounselorTags",
name: "CreatorTags",
components: {Draggable},
@@ -150,6 +168,7 @@ export default {
show_dialog: false,
selected_tag: null,
tag_text: null,
is_adult: false,
image: null,
image_url: null
}
@@ -194,12 +213,15 @@ export default {
this.image = null
this.image_url = null
this.tag_text = null
this.is_adult = false
this.selected_tag = null
},
showModifyTagDialog(tag) {
this.selected_tag = tag
this.image_url = tag.image
this.tag_text = tag.tag
this.is_adult = tag.isAdult
this.show_dialog = true
},
@@ -208,15 +230,13 @@ export default {
const formData = new FormData()
formData.append("image", this.image)
formData.append("request", JSON.stringify({tag: this.tag_text}))
formData.append("request", JSON.stringify({tag: this.tag_text, isAdult: this.is_adult}))
const res = await api.enrollment(formData)
if (res.status === 200 && res.data.success === true) {
this.show_dialog = false
this.image = null
this.image_url = null
this.tag_text = null
this.cancel()
this.tags = []
this.notifySuccess(res.data.message)
@@ -263,15 +283,13 @@ export default {
const formData = new FormData()
formData.append("image", this.image)
formData.append("request", JSON.stringify({tag: this.tag_text}))
formData.append("request", JSON.stringify({tag: this.tag_text, isAdult: this.is_adult}))
const res = await api.modifyTag(this.selected_tag.id, formData)
if (res.status === 200 && res.data.success === true) {
this.show_dialog = false
this.image = null
this.image_url = null
this.tag_text = null
this.cancel()
this.tags = []
this.notifySuccess(res.data.message)

View File

@@ -194,7 +194,7 @@
<script>
import * as api from "@/api/explorer"
import * as creatorTagApi from "@/api/counselor_tag";
import * as creatorTagApi from "@/api/creator_tag";
import Draggable from "vuedraggable";
export default {

View File

@@ -31,6 +31,10 @@
{{ item.content }}
</template>
<template v-slot:item.numberOfParticipants="{ item }">
{{ item.numberOfParticipants }}
</template>
<template v-slot:item.nickname="{ item }">
{{ item.managerNickname }}
</template>
@@ -55,7 +59,7 @@
<span v-else>공개</span>
</template>
<template v-slot:item.nickname="{ item }">
<template v-slot:item.password="{ item }">
{{ item.password }}
</template>
@@ -66,6 +70,16 @@
</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>
@@ -79,6 +93,9 @@ export default {
data() {
return {
isLoading: false,
page: 1,
page_size: 20,
total_page: 0,
liveList: [],
headers: [
{
@@ -99,6 +116,12 @@ export default {
sortable: false,
value: 'content',
},
{
text: '현재참여인원',
align: 'center',
sortable: false,
value: 'numberOfParticipants',
},
{
text: '방장',
align: 'center',
@@ -152,16 +175,27 @@ export default {
this.$dialog.notify.success(message)
},
async next() {
await this.getLive()
},
async getLive() {
this.isLoading = true
try {
this.isLoading = false
const res = await api.getLive()
const res = await api.getLive(this.page, this.page_size)
if (res.status === 200 && res.data.success === true) {
this.liveList = res.data.data.liveList
const data = res.data.data;
const totalPage = Math.ceil(data.totalCount / this.page_size)
this.liveList = data.liveList
if (totalPage <= 0)
this.total_page = 1
else
this.total_page = totalPage
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}

View File

@@ -2,7 +2,7 @@
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>추천 요즘라이브</v-toolbar-title>
<v-toolbar-title>추천 라이브 크리에이터</v-toolbar-title>
<v-spacer />
</v-toolbar>
@@ -19,7 +19,7 @@
depressed
@click="showWriteDialog"
>
추천 요즘라이브 등록
추천 라이브 크리에이터 등록
</v-btn>
</v-col>
</v-row>
@@ -29,7 +29,7 @@
<v-data-table
:headers="headers"
:items="recommendLiveList"
:loading="isLoading"
:loading="is_loading"
:items-per-page="20"
item-key="id"
class="elevation-1"
@@ -53,7 +53,7 @@
width="100"
>
</td>
<td> <h3>{{ item.creatorNickname }}</h3> </td>
<td><h3>{{ item.creatorNickname }}</h3></td>
<td>
<h3>
{{ item.startDate }} ~ {{ item.endDate }}
@@ -69,7 +69,7 @@
</td>
<td>
<v-btn
:disabled="isLoading"
:disabled="is_loading"
@click="showModifyDialog(item)"
>
수정
@@ -100,11 +100,11 @@
persistent
>
<v-card>
<v-card-title>추천 요즘라이브 등록</v-card-title>
<v-card-title>추천 라이브 크리에이터 등록</v-card-title>
<v-card-text>
<v-row align="center">
<v-col cols="4">
요즘친구
크리에이터
</v-col>
<v-col cols="8">
<v-select
@@ -131,7 +131,7 @@
class="datepicker"
format="YYYY-MM-DD H:i"
/>
<div> ~ </div>
<div> ~</div>
<datetime
v-model="end_date"
class="datepicker"
@@ -178,7 +178,7 @@
</v-col>
</v-row>
</v-card-text>
<v-card-actions v-show="!isLoading">
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
@@ -213,15 +213,15 @@
<script>
import Draggable from 'vuedraggable';
import datetime from 'vuejs-datetimepicker';
import * as accountApi from "@/api/member";
import * as memberApi from "@/api/member";
import * as api from "@/api/recommend_suda_creator"
export default {
name: "LiveRecommendView",
components: { datetime, Draggable },
components: {datetime, Draggable},
data() {
return {
isLoading: false,
is_loading: false,
page: 1,
total_page: 0,
show_write_dialog: false,
@@ -242,7 +242,7 @@ export default {
value: 'image',
},
{
text: '요즘친구',
text: '크리에이터',
align: 'center',
sortable: false,
value: 'creatorNickname',
@@ -271,7 +271,7 @@ export default {
async created() {
await this.getCreatorList()
await this.getRecommendSudaCreator()
await this.getRecommendCreator()
},
methods: {
@@ -284,7 +284,7 @@ export default {
const firstOrders = (this.page - 1) * 20 + 1
const res = await api.updateRecommendSudaCreatorOrders(firstOrders, ids)
const res = await api.updateRecommendCreatorBannerOrders(firstOrders, ids)
if (res.status === 200 && res.data.success === true) {
this.notifySuccess(res.data.message)
}
@@ -306,7 +306,7 @@ export default {
try {
const formData = new FormData()
formData.append("recommend_suda_creator_id", this.selected_recommend_live.id)
formData.append("recommend_creator_banner_id", this.selected_recommend_live.id)
if (this.image !== null) {
formData.append("image", this.image)
@@ -328,12 +328,12 @@ export default {
formData.append("is_adult", this.is_adult)
}
const res = await api.updateRecommendSudaCreator(formData)
const res = await api.updateRecommendCreatorBanner(formData)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess('수정되었습니다.')
this.page = 1
await this.getRecommendSudaCreator()
await this.getRecommendCreator()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
@@ -349,7 +349,7 @@ export default {
async submit() {
if (this.is_loading) return;
this.isLoading = true
this.is_loading = true
try {
const formData = new FormData()
@@ -359,13 +359,14 @@ export default {
formData.append("end_date", this.end_date)
formData.append("is_adult", this.is_adult)
const res = await api.createRecommendSudaCreator(formData);
const res = await api.createRecommendCreatorBanner(formData);
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess('등록되었습니다.')
this.page = 1
await this.getRecommendSudaCreator()
await this.getRecommendCreator()
} else {
this.is_loading = false
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
@@ -408,14 +409,14 @@ export default {
},
async next() {
await this.getRecommendSudaCreator()
await this.getRecommendCreator()
},
async getRecommendSudaCreator() {
this.isLoading = true
async getRecommendCreator() {
this.is_loading = true
try {
const res = await api.getRecommendSudaCreator(this.page)
const res = await api.getRecommendCreatorBanner(this.page)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
@@ -435,15 +436,15 @@ export default {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
} finally {
this.isLoading = false
this.is_loading = false
}
},
async getCreatorList() {
this.isLoading = true
this.is_loading = true
try {
const res = await accountApi.getCounselorList()
const res = await memberApi.getCreatorAllList()
if (res.status === 200 && res.data.success === true) {
this.creatorList = res.data.data.map((item) => {
@@ -457,7 +458,7 @@ export default {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
} finally {
this.isLoading = false
this.is_loading = false
}
},
imageAdd(payload) {

View File

@@ -78,7 +78,12 @@
</template>
<v-card>
<v-card-title>관심사 등록</v-card-title>
<v-card-title v-if="selected_tag !== null">
관심사 수정
</v-card-title>
<v-card-title v-else>
관심사 등록
</v-card-title>
<v-card-text>
<v-text-field
v-model="tag_text"
@@ -86,6 +91,19 @@
required
/>
</v-card-text>
<v-card-text>
<v-row>
<v-col cols="4">
연령제한
</v-col>
<v-col cols="8">
<input
v-model="is_adult"
type="checkbox"
>
</v-col>
</v-row>
</v-card-text>
<div class="image-select">
<label for="image">
이미지 등록
@@ -150,6 +168,7 @@ export default {
show_dialog: false,
selected_tag: null,
tag_text: null,
is_adult: false,
image: null,
image_url: null
}
@@ -194,12 +213,15 @@ export default {
this.image = null
this.image_url = null
this.tag_text = null
this.selected_tag = null
this.is_adult = false
},
showModifyTagDialog(tag) {
this.selected_tag = tag
this.image_url = tag.image
this.tag_text = tag.tag
this.is_adult = tag.isAdult
this.show_dialog = true
},
@@ -208,15 +230,13 @@ export default {
const formData = new FormData()
formData.append("image", this.image)
formData.append("request", JSON.stringify({tag: this.tag_text}))
formData.append("request", JSON.stringify({tag: this.tag_text, isAdult: this.is_adult}))
const res = await api.enrollment(formData)
if (res.status === 200 && res.data.success === true) {
this.show_dialog = false
this.image = null
this.image_url = null
this.tag_text = null
this.cancel()
this.tags = []
this.notifySuccess(res.data.message)
@@ -263,15 +283,13 @@ export default {
const formData = new FormData()
formData.append("image", this.image)
formData.append("request", JSON.stringify({tag: this.tag_text}))
formData.append("request", JSON.stringify({tag: this.tag_text, isAdult: this.is_adult}))
const res = await api.modifyTag(this.selected_tag.id, formData)
if (res.status === 200 && res.data.success === true) {
this.show_dialog = false
this.image = null
this.image_url = null
this.tag_text = null
this.cancel()
this.tags = []
this.notifySuccess(res.data.message)

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>
@@ -68,21 +71,31 @@
</thead>
<tbody>
<tr
v-for="item in accounts"
v-for="item in memberList"
:key="item.id"
>
<td>{{ item.id }}</td>
<td>{{ item.email }}</td>
<td>{{ item.nickname }}</td>
<td align="center">
<v-img
max-width="100"
max-height="100"
:src="item.profileUrl"
class="rounded-circle"
/>
<div>
<v-img
max-width="100"
max-height="100"
:src="item.profileUrl"
class="rounded-circle"
/>
<br>
<a
:href="item.profileUrl"
class="v-btn v-btn--outlined"
>
Download Image
</a>
</div>
</td>
<td>{{ item.userType }}</td>
<td>{{ item.loginType }}</td>
<td>
<div v-if="item.container === 'aos'">
Android
@@ -180,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"
@@ -199,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>
@@ -206,7 +259,7 @@
import * as api from '@/api/member'
export default {
name: "AccountList",
name: "MemberList",
data() {
return {
@@ -214,17 +267,18 @@ export default {
page: 1,
total_page: 0,
search_word: '',
accounts: [],
account: null,
memberList: [],
member: null,
email: null,
nickname: null,
user_type: null,
show_popup_dialog: false
show_popup_dialog: false,
show_confirm_reset_password_dialog: false,
}
},
async created() {
await this.getAccounts()
await this.getMemberList()
},
methods: {
@@ -238,23 +292,23 @@ export default {
async search() {
this.page = 1
await this.searchAccount()
await this.searchMember()
},
async searchAccount() {
async searchMember() {
if (this.search_word.length === 0) {
await this.getAccounts()
await this.getMemberList()
} else if (this.search_word.length < 2) {
this.notifyError('검색어를 2글자 이상 입력하세요.')
} else {
this.is_loading = true
try {
const res = await api.searchAccount(this.search_word, this.page)
const res = await api.searchMember(this.search_word, this.page)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const total_page = Math.ceil(data.totalCount / 20)
this.accounts = data.items
this.memberList = data.items
if (total_page <= 0)
this.total_page = 1
@@ -275,21 +329,21 @@ export default {
async next() {
if (this.search_word.length < 2) {
this.search_word = ''
await this.getAccounts()
await this.getMemberList()
} else {
await this.searchAccount()
await this.searchMember()
}
},
async getAccounts() {
async getMemberList() {
this.is_loading = true
try {
const res = await api.getAccountList(this.page)
const res = await api.getMemberList(this.page)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const total_page = Math.ceil(data.totalCount / 20)
this.accounts = data.items
this.memberList = data.items
if (total_page <= 0)
this.total_page = 1
@@ -306,47 +360,48 @@ export default {
}
},
showPopupDialog(account) {
this.account = account
showPopupDialog(member) {
this.member = member
if (account.userType === '일반회원') {
if (member.userType === '일반회원') {
this.user_type = 'USER'
} else if (account.userType === '요즘친구') {
} else if (member.userType === '크리에이터') {
this.user_type = 'CREATOR'
}
this.email = account.email
this.nickname = account.nickname
this.email = member.email
this.nickname = member.nickname
this.show_popup_dialog = true
},
cancel() {
this.account = null
this.member = null
this.email = null
this.nickname = null
this.user_type = null
this.show_popup_dialog = false
this.show_confirm_reset_password_dialog = false
},
async modify() {
this.is_loading = true
if (
(this.user_type === 'CREATOR' && this.account.userType === '요즘친구') ||
(this.user_type === 'USER' && this.account.userType === '일반회원')
(this.user_type === 'CREATOR' && this.member.userType === '크리에이터') ||
(this.user_type === 'USER' && this.member.userType === '일반회원')
) {
this.notifyError("변경사항이 없습니다.")
} else {
try {
const res = await api.updateAccount(this.account.id, this.user_type)
const res = await api.updateMember(this.member.id, this.user_type)
if (res.status === 200 && res.data.success === true) {
this.page = 1
this.total_page = 0
this.search_word = ''
this.accounts = []
await this.getAccounts()
this.memberList = []
await this.getMemberList()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
@@ -357,11 +412,36 @@ 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
}
}
}
}
</script>
<style scoped>
</style>

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

@@ -41,16 +41,6 @@
</v-card>
</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
@@ -73,6 +63,29 @@
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="event.startDate"
class="datepicker"
format="YYYY-MM-DD H:i"
/>
<div> ~</div>
<datetime
v-model="event.endDate"
class="datepicker"
format="YYYY-MM-DD H:i"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<div class="image-select">
<label for="thumbnailImage">
@@ -147,6 +160,50 @@
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row>
<v-col cols="4">
연령제한
</v-col>
<v-col cols="8">
<v-row>
<v-col>
<input
id="no-auth"
v-model="event.isAdult"
type="radio"
value="false"
>
<label for="no-auth">
본인인증을 하지 않은 유저만
</label>
</v-col>
<v-col>
<input
id="auth"
v-model="event.isAdult"
type="radio"
value="true"
>
<label for="auth">
본인인증을 유저만
</label>
</v-col>
<v-col>
<input
id="both"
v-model="event.isAdult"
type="radio"
value=""
>
<label for="both">
상관없음
</label>
</v-col>
</v-row>
</v-col>
</v-row>
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-btn
v-show="is_modify"
@@ -220,276 +277,320 @@
<script>
import * as api from '@/api/event'
import datetime from 'vuejs-datetimepicker';
export default {
name: "EventView",
name: "EventView",
components: {datetime},
data() {
return {
is_loading: false,
is_modify: false,
page: 1,
events: [],
event: {},
show_write_dialog: false,
show_delete_confirm_dialog: false,
}
},
async created() {
await this.getEvents()
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
thumbnailImageAdd(payload) {
const file = payload;
if (file) {
this.event.thumbnailImageUrl = URL.createObjectURL(file)
URL.revokeObjectURL(file)
} else {
this.event.thumbnailImageUrl = null
}
},
detailImageAdd(payload) {
const file = payload;
if (file) {
this.event.detailImageUrl = URL.createObjectURL(file)
URL.revokeObjectURL(file)
} else {
this.event.detailImageUrl = null
}
},
popupImageAdd(payload) {
const file = payload;
if (file) {
this.event.popupImageUrl = URL.createObjectURL(file)
URL.revokeObjectURL(file)
} else {
this.event.popupImageUrl = null
}
},
async getEvents() {
this.is_loading = true
try {
const res = await api.getEvents(this.page)
if (res.status === 200 && res.data.success === true) {
this.events = res.data.data.eventList
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async next() {
await this.getEvents()
},
showWriteDialog() {
this.show_write_dialog = true
},
clickEvent(item) {
this.is_modify = true
this.event.id = item.id
this.event.thumbnailImageUrl = item.thumbnailImageUrl
this.event.detailImageUrl = item.detailImageUrl
this.event.link = item.link
this.event.title = item.title
this.event.isPopup = item.isPopup
this.event.popupImageUrl = item.popupImageUrl
this.show_write_dialog = true
},
cancel() {
this.is_modify = false
this.event = {}
this.show_write_dialog = false
},
validate() {
if (this.event.title == null) {
this.notifyError("제목을 입력하세요")
return false;
}
if (this.event.thumbnailImage == null) {
this.notifyError("썸네일 이미지를 등록하세요")
return false;
}
if ((this.event.link == null || this.event.link.trim().length <= 0) && this.event.detailImage == null) {
this.notifyError("상세이미지 혹은 link 둘 중 하나는 반드시 입력해야 합니다.")
return false;
}
return true
},
async submit() {
if (!this.validate()) return;
if (this.is_loading) return;
this.is_loading = true
try {
const formData = new FormData()
formData.append("title", this.event.title)
formData.append("thumbnail", this.event.thumbnailImage)
formData.append("isPopup", this.event.isPopup ? this.event.isPopup : false)
if (this.event.detailImage != null) {
formData.append("detail", this.event.detailImage)
}
if (this.event.popupImage != null) {
formData.append("popup", this.event.popupImage)
}
if (this.event.link != null && this.event.link.trim().length > 0) {
formData.append("link", this.event.link)
}
const res = await api.save(formData)
if (res.status === 200 && res.data.success === true) {
this.show_write_dialog = false
this.notifySuccess('등록되었습니다.')
this.page = 1
await this.getEvents()
this.event = {}
}
} 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()
formData.append("id", this.event.id)
if (this.event.title != null && this.event.title.trim().length > 0) {
formData.append("title", this.event.title)
}
if (this.event.thumbnailImage != null) {
formData.append("thumbnail", this.event.thumbnailImage)
}
if (this.event.detailImage != null) {
formData.append("detail", this.event.detailImage)
}
if (this.event.popupImage != null) {
formData.append("popup", this.event.popupImage)
}
if (this.event.isPopup != null) {
formData.append("isPopup", this.event.isPopup)
}
if (this.event.link != null && this.event.link.trim().length > 0) {
formData.append("link", this.event.link)
}
const res = await api.modify(formData)
if (res.status === 200 && res.data.success === true) {
this.show_write_dialog = false
this.notifySuccess('수정되었습니다.')
this.page = 1
await this.getEvents()
this.event = {}
this.is_modify = false
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
deleteConfirm() {
this.show_delete_confirm_dialog = true
},
deleteCancel() {
this.show_delete_confirm_dialog = false
},
async deleteEvent() {
if (this.is_loading) return;
this.is_loading = true
try {
const res = await api.deleteEvent(this.event.id)
if (res.status === 200 && res.data.success === true) {
this.show_write_dialog = false
this.show_delete_confirm_dialog = false
this.notifySuccess('삭제되었습니다.')
this.page = 1
await this.getEvents()
this.event = {}
this.is_modify = false
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
}
data() {
return {
is_loading: false,
is_modify: false,
events: [],
event: {isAdult: ''},
show_write_dialog: false,
show_delete_confirm_dialog: false,
selected_event: {},
}
},
async created() {
await this.getEvents()
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
thumbnailImageAdd(payload) {
const file = payload;
if (file) {
this.event.thumbnailImageUrl = URL.createObjectURL(file)
URL.revokeObjectURL(file)
} else {
this.event.thumbnailImageUrl = null
}
},
detailImageAdd(payload) {
const file = payload;
if (file) {
this.event.detailImageUrl = URL.createObjectURL(file)
URL.revokeObjectURL(file)
} else {
this.event.detailImageUrl = null
}
},
popupImageAdd(payload) {
const file = payload;
if (file) {
this.event.popupImageUrl = URL.createObjectURL(file)
URL.revokeObjectURL(file)
} else {
this.event.popupImageUrl = null
}
},
async getEvents() {
this.is_loading = true
try {
const res = await api.getEvents(this.page)
if (res.status === 200 && res.data.success === true) {
this.events = res.data.data
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
showWriteDialog() {
this.event.isAdult = ''
this.show_write_dialog = true
},
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
this.event.link = item.link
this.event.title = item.title
this.event.isPopup = item.isPopup
this.event.isAdult = item.isAdult === null ? '' : item.isAdult
this.event.popupImageUrl = item.popupImageUrl
this.event.startDate = item.startDate
this.event.endDate = item.endDate
this.show_write_dialog = true
},
cancel() {
this.is_modify = false
this.event = {isAdult: ''}
this.selected_event = {}
this.show_write_dialog = false
},
validate() {
if (this.event.title == null) {
this.notifyError("제목을 입력하세요")
return false;
}
if (this.event.thumbnailImage == null) {
this.notifyError("썸네일 이미지를 등록하세요")
return false;
}
if ((this.event.link == null || this.event.link.trim().length <= 0) && this.event.detailImage == null) {
this.notifyError("상세이미지 혹은 link 둘 중 하나는 반드시 입력해야 합니다.")
return false;
}
if (this.event.startDate == null || this.event.endDate == null) {
this.notifyError("이벤트 기간을 선택하세요")
return false;
}
return true
},
async submit() {
if (!this.validate()) return;
if (this.is_loading) return;
this.is_loading = true
try {
const formData = new FormData()
formData.append("title", this.event.title)
formData.append("thumbnail", this.event.thumbnailImage)
formData.append("isPopup", this.event.isPopup ? this.event.isPopup : false)
formData.append("startDate", this.event.startDate)
formData.append("endDate", this.event.endDate)
if (this.event.detailImage != null) {
formData.append("detail", this.event.detailImage)
}
if (this.event.popupImage != null) {
formData.append("popup", this.event.popupImage)
}
if (this.event.link != null && this.event.link.trim().length > 0) {
formData.append("link", this.event.link)
}
if (this.event.isAdult !== undefined && this.event.isAdult !== null && this.event.isAdult !== '') {
formData.append("isAdult", JSON.parse(this.event.isAdult))
}
const res = await api.save(formData)
if (res.status === 200 && res.data.success === true) {
this.show_write_dialog = false
this.notifySuccess('등록되었습니다.')
this.page = 1
await this.getEvents()
this.event = {isAdult: ''}
}
} 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()
formData.append("id", this.event.id)
if (
this.event.title != null &&
this.event.title.trim().length > 0 &&
this.selected_event.title !== this.event.title
) {
formData.append("title", this.event.title)
}
if (this.event.thumbnailImage != null) {
formData.append("thumbnail", this.event.thumbnailImage)
}
if (this.event.detailImage != null) {
formData.append("detail", this.event.detailImage)
}
if (this.event.popupImage != null) {
formData.append("popup", this.event.popupImage)
}
if (this.event.isPopup != null) {
formData.append("isPopup", this.event.isPopup)
}
if (this.selected_event.link !== this.event.link) {
formData.append("link", this.event.link)
}
if (this.event.isAdult !== undefined && this.event.isAdult !== null && this.event.isAdult !== '') {
formData.append("isAdult", JSON.parse(this.event.isAdult))
}
if (this.event.startDate != null && this.event.startDate !== this.selected_event.startDate) {
formData.append("startDate", this.event.startDate)
}
if (this.event.endDate != null && this.event.endDate !== this.selected_event.endDate) {
formData.append("endDate", this.event.endDate)
}
const res = await api.modify(formData)
if (res.status === 200 && res.data.success === true) {
this.show_write_dialog = false
this.notifySuccess('수정되었습니다.')
this.page = 1
await this.getEvents()
this.event = {isAdult: ''}
this.is_modify = false
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
deleteConfirm() {
this.show_delete_confirm_dialog = true
},
deleteCancel() {
this.show_delete_confirm_dialog = false
},
async deleteEvent() {
if (this.is_loading) return;
this.is_loading = true
try {
const res = await api.deleteEvent(this.event.id)
if (res.status === 200 && res.data.success === true) {
this.show_write_dialog = false
this.show_delete_confirm_dialog = false
this.notifySuccess('삭제되었습니다.')
this.page = 1
await this.getEvents()
this.event = {}
this.is_modify = false
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
}
}
}
</script>
<style scoped>
.datepicker {
text-align: center;
}
.datepicker-wrapper {
display: flex;
flex-direction: row;
}
.datepicker-wrapper > div {
margin: 20px;
}
.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;
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;
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;
max-width: 100%;
width: 250px;
object-fit: cover;
margin-top: 10px;
}
</style>

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

@@ -9,8 +9,49 @@
<br>
<v-container>
<v-row class="is-auth">
<v-col cols="6" />
<v-col cols="6">
<v-row>
<v-col>
<input
id="no-auth"
v-model="isAuth"
type="radio"
value="false"
>
<label for="no-auth">
본인인증을 하지 않은 유저만
</label>
</v-col>
<v-col>
<input
id="auth"
v-model="isAuth"
type="radio"
value="true"
>
<label for="auth">
본인인증을 유저만
</label>
</v-col>
<v-col>
<input
id="both"
v-model="isAuth"
type="radio"
value=""
>
<label for="both">
모두
</label>
</v-col>
</v-row>
</v-col>
</v-row>
<v-text-field
v-model="account_id"
v-model="member_id"
label="회원번호(입력하지 않으면 전체회원에게 발송)"
outlined
required
@@ -53,8 +94,8 @@
>
<v-card>
<v-card-title>푸시메시지 발송 확인</v-card-title>
<v-card-text v-if="account_id.length > 0">
발송대상(회원번호): {{ send_account_id }}
<v-card-text v-if="member_id.length > 0">
발송대상(회원번호): {{ send_member_id }}
</v-card-text>
<v-card-text v-else>
발송대상(회원번호): 전체
@@ -65,6 +106,9 @@
<v-card-text>
내용: {{ message }}
</v-card-text>
<v-card-text>
본인인증 여부: {{ isAuth === 'true' ? 'O' : isAuth === 'false' ? 'X' : '모두' }}
</v-card-text>
<v-card-actions v-show="!isLoading">
<v-spacer />
<v-btn
@@ -101,10 +145,11 @@ export default {
return {
show_confirm: false,
isLoading: false,
account_id: '',
send_account_id: [],
member_id: '',
send_member_id: [],
title: '',
message: ''
message: '',
isAuth: ''
}
},
@@ -127,12 +172,12 @@ export default {
}
if (!this.isLoading) {
if (this.account_id.length > 0) {
this.send_account_id = Array.from(
if (this.member_id.length > 0) {
this.send_member_id = Array.from(
new Set(
this.account_id.split(",")
.map(accountId => accountId.trim())
.filter(accountId => Number(accountId))
this.member_id.split(",")
.map(memberId => memberId.trim())
.filter(memberId => Number(memberId))
)
)
}
@@ -150,17 +195,19 @@ export default {
try {
await api.sendPush(
this.send_account_id,
this.send_member_id,
this.title,
this.message
this.message,
this.isAuth
)
} finally {
this.isLoading = false
this.show_confirm = false
this.account_id = ''
this.send_account_id = []
this.member_id = ''
this.send_member_id = []
this.title = ''
this.message = ''
this.isAuth = ''
}
}
}
@@ -169,5 +216,9 @@ export default {
</script>
<style scoped>
.is-auth {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,330 @@
<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="#9970ff"
dark
depressed
@click="showWriteDialog"
>
시리즈 장르 등록
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col>
<v-data-table
:headers="headers"
:items="series_genre_list"
: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>
{{ item.genre }}
</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="deleteConfirm(item)"
>
삭제
</v-btn>
</v-col>
<v-col />
</v-row>
</td>
</tr>
</draggable>
</template>
</v-data-table>
</v-col>
</v-row>
</v-container>
<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="series_genre.genre"
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="series_genre.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
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>
"{{ selected_series_genre.genre }}" 삭제하시겠습니까?
</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="deleteSeriesGenre"
>
확인
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import Draggable from 'vuedraggable';
import * as api from '@/api/audio_content_series';
export default {
name: "ContentSeriesGenre",
components: {Draggable},
data() {
return {
is_loading: false,
show_delete_confirm_dialog: false,
show_write_dialog: false,
selected_series_genre: {},
series_genre: {is_adult: false},
series_genre_list: [],
headers: [
{
text: '장르',
align: 'center',
sortable: false,
value: 'genre',
},
{
text: '19금',
align: 'center',
sortable: false,
value: 'isAdult',
},
{
text: '관리',
align: 'center',
sortable: false,
value: 'management'
},
],
}
},
async created() {
await this.getSeriesGenreList();
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
showWriteDialog() {
this.show_write_dialog = true
},
cancel() {
this.series_genre = {is_adult: false}
this.selected_series_genre = {}
this.show_write_dialog = false
},
deleteConfirm(series_genre) {
this.selected_series_genre = series_genre
this.show_delete_confirm_dialog = true
},
deleteCancel() {
this.selected_series_genre = {}
this.show_delete_confirm_dialog = false
},
async submit() {
if (this.is_loading) return;
this.isLoading = true
try {
const res = await api.createAudioContentSeriesGenre(this.series_genre.genre, this.series_genre.is_adult)
if (res.status === 200 && res.data.success === true) {
this.cancel()
this.notifySuccess('등록되었습니다.')
this.series_genre_list = []
await this.getSeriesGenreList()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async getSeriesGenreList() {
this.is_loading = true
try {
const res = await api.getAudioContentSeriesGenreList()
if (res.status === 200 && res.data.success === true) {
this.series_genre_list = res.data.data
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
async onDropCallback(items) {
this.series_genre_list = items
const ids = items.map((item) => {
return item.id
})
try {
this.is_loading = true
const res = await api.updateAudioContentSeriesGenreOrders(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 deleteSeriesGenre() {
if (this.is_loading) return;
this.is_loading = true
try {
let request = {id: this.selected_series_genre.id, isActive: false}
const res = await api.updateAudioContentSeriesGenre(request)
if (res.status === 200 && res.data.success === true) {
this.show_delete_confirm_dialog = false
this.cancel()
this.notifySuccess('삭제되었습니다.')
this.series_genre_list = []
await this.getSeriesGenreList()
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
} finally {
this.is_loading = false
}
},
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,191 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>시리즈 리스트</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-container>
<v-row>
<v-col class="text-left total-count">
전체 <span>{{ total_count }}</span>
</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">
크리에이터
</th>
<th class="text-center">
장르
</th>
<th class="text-center">
작품개수
</th>
<th class="text-center">
연재여부
</th>
<th class="text-center">
19
</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in series_list"
:key="item.id"
>
<td>{{ item.id }}</td>
<td align="center">
<v-img
max-width="70"
max-height="70"
:src="item.coverImageUrl"
class="rounded-circle"
/>
<br>
<a
:href="item.coverImageUrl"
class="v-btn v-btn--outlined"
>
다운로드
</a>
</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.introduction"
:lines="3"
/>
</td>
<td>{{ item.creatorNickname }}</td>
<td>{{ item.genre }}</td>
<td>{{ item.numberOfWorks }}</td>
<td>{{ item.state }}</td>
<td>
<div v-if="item.isAdult">
O
</div>
<div v-else>
X
</div>
</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>
</div>
</template>
<script>
import * as api from '@/api/audio_content_series'
import VueShowMoreText from 'vue-show-more-text'
export default {
name: "AudioContentSeriesList",
components: {VueShowMoreText},
data() {
return {
is_loading: false,
page: 1,
total_page: 0,
total_count: 0,
series_list: []
}
},
async created() {
await this.getAudioContentSeries()
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
async getAudioContentSeries() {
this.is_loading = true
try {
const res = await api.getAudioContentSeriesList(this.page)
if (res.status === 200 && res.data.success === true) {
const data = res.data.data
const total_page = Math.ceil(data.totalCount / 10)
this.series_list = data.items
this.total_count = data.totalCount
if (total_page <= 0)
this.total_page = 1
else
this.total_page = total_page
} else {
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
}
this.is_loading = false
} catch (e) {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
this.is_loading = false
}
},
async next() {
await this.getAudioContentSeries()
},
}
}
</script>
<style>
.total-count {
padding-bottom: 0;
}
.total-count > span {
color: #3bb9f1;
}
</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>