create-m2m-bo-template-vue 1.0.5 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/src/components/SearchField/SearchField.module.css +11 -2
- package/template/src/components/SearchField/SearchField.vue +5 -1
- package/template/src/components/shell/Header.vue +8 -1
- package/template/src/pages/auth/Auth.module.css +164 -0
- package/template/src/pages/auth/AuthLayout.module.css +85 -0
- package/template/src/pages/auth/AuthLayout.vue +29 -0
- package/template/src/pages/auth/LoginPage.vue +52 -0
- package/template/src/pages/auth/PasswordResetPage.vue +96 -0
- package/template/src/pages/auth/TwoFactorPage.vue +97 -0
- package/template/src/pages/c2c/reports/ReportProcessingPage.vue +1 -1
- package/template/src/pages/cs/ConsultationsPage.vue +3 -1
- package/template/src/pages/cs/CsHistoryDetailDrawer.vue +3 -1
- package/template/src/pages/cs/CsInquiriesPage.vue +3 -2
- package/template/src/pages/cs/ProductInquiryDetailDrawer.vue +3 -1
- package/template/src/pages/deliverypolicy/ShippingBaseFeePage.vue +23 -23
- package/template/src/pages/members/MembersPage.module.css +4 -0
- package/template/src/pages/members/MembersPage.vue +1 -1
- package/template/src/pages/policy/CancelPolicyPage.vue +37 -32
- package/template/src/pages/policy/FeePolicyPage.vue +32 -28
- package/template/src/pages/policy/PaymentPolicyPage.vue +41 -34
- package/template/src/pages/promotions/PromotionStackSettingPage.vue +8 -8
- package/template/src/pages/stats/DeliveryClaimsStatsPage.vue +411 -0
- package/template/src/pages/stats/InventoryAnalysisPage.vue +853 -0
- package/template/src/pages/stats/PromotionAnalysisPage.vue +444 -0
- package/template/src/pages/stats/SellerActivityStatsPage.vue +535 -0
- package/template/src/pages/stats/TransactionConversionStatsPage.vue +470 -0
- package/template/src/pages/system/CommonCodesPage.vue +1 -0
- package/template/src/pages/system/IntegrationManagementPage.vue +1 -0
- package/template/src/pages/system/JobManagementPage.vue +1 -0
- package/template/src/pages/system/ServiceSettingsPage.vue +1 -0
- package/template/src/router/index.ts +4 -2
- package/template/src/router/routes.auth.ts +12 -0
- package/template/src/router/routes.stats.ts +17 -5
- package/template/src/style.css +0 -19
package/package.json
CHANGED
|
@@ -36,7 +36,9 @@
|
|
|
36
36
|
min-width: 0;
|
|
37
37
|
max-width: none;
|
|
38
38
|
height: 100%;
|
|
39
|
-
|
|
39
|
+
/* m2m-uiux-vue의 .bsSearchField .bsSearchFieldInput 리셋 규칙과 클래스 특이도가
|
|
40
|
+
같아 로드 순서에 따라 우리 패딩이 밀릴 수 있다 — !important로 확정 고정 */
|
|
41
|
+
padding: 0 36px 0 12px !important;
|
|
40
42
|
border: 0;
|
|
41
43
|
outline: 0;
|
|
42
44
|
background: transparent;
|
|
@@ -46,7 +48,7 @@
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
.field:has(.shortcut) .control > input {
|
|
49
|
-
padding-right: 64px;
|
|
51
|
+
padding-right: 64px !important;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
.control > button:first-child {
|
|
@@ -71,6 +73,13 @@
|
|
|
71
73
|
font-size: 0.625rem;
|
|
72
74
|
pointer-events: none;
|
|
73
75
|
transform: translateY(-50%);
|
|
76
|
+
background: #fff;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* 입력 내용이 있거나 포커스 시 shortcut 자동 숨김 */
|
|
80
|
+
.field:has(input:not(:placeholder-shown)) .shortcut,
|
|
81
|
+
.field:has(input:focus) .shortcut {
|
|
82
|
+
display: none !important;
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
@media (max-width: 720px) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
2
3
|
import { SearchField as M2MSearchField } from 'm2m-uiux-vue/SearchField';
|
|
3
4
|
|
|
4
5
|
export interface SearchFieldProps {
|
|
@@ -26,6 +27,9 @@ function handleUpdate(value: string) {
|
|
|
26
27
|
props.onChange?.({ target: { value } } as unknown as Event);
|
|
27
28
|
props.onValueChange?.(value);
|
|
28
29
|
}
|
|
30
|
+
|
|
31
|
+
const currentVal = computed(() => String((props.value ?? props.defaultValue) ?? ''));
|
|
32
|
+
const hasValue = computed(() => currentVal.value.trim().length > 0);
|
|
29
33
|
</script>
|
|
30
34
|
|
|
31
35
|
<template>
|
|
@@ -39,7 +43,7 @@ function handleUpdate(value: string) {
|
|
|
39
43
|
:class-names="$style.control"
|
|
40
44
|
@update:model-value="handleUpdate"
|
|
41
45
|
/>
|
|
42
|
-
<span v-if="shortcutHint" :class="$style.shortcut" aria-hidden="true">{{ shortcutHint }}</span>
|
|
46
|
+
<span v-if="shortcutHint && !hasValue" :class="$style.shortcut" aria-hidden="true">{{ shortcutHint }}</span>
|
|
43
47
|
</span>
|
|
44
48
|
</template>
|
|
45
49
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
|
3
|
-
import { Blocks, Settings, ChevronDown, Check } from 'lucide-vue-next';
|
|
3
|
+
import { Blocks, Settings, ChevronDown, Check, LogOut } from 'lucide-vue-next';
|
|
4
4
|
import { Header as M2MHeader } from 'm2m-uiux-vue/Header';
|
|
5
5
|
import { useRoute, useRouter } from 'vue-router';
|
|
6
6
|
import { activeKeyForPath, breadcrumbForKey } from '../../lib/nav';
|
|
@@ -75,6 +75,9 @@ function goSystemDisplay() {
|
|
|
75
75
|
modeDropdownOpen.value = false;
|
|
76
76
|
router.push('/system/display');
|
|
77
77
|
}
|
|
78
|
+
function logout() {
|
|
79
|
+
router.push('/login');
|
|
80
|
+
}
|
|
78
81
|
</script>
|
|
79
82
|
|
|
80
83
|
<template>
|
|
@@ -128,6 +131,10 @@ function goSystemDisplay() {
|
|
|
128
131
|
<Settings :size="15" :stroke-width="1.8" />
|
|
129
132
|
</button>
|
|
130
133
|
|
|
134
|
+
<button type="button" :class="styles.iconBtn" title="로그아웃" @click="logout">
|
|
135
|
+
<LogOut :size="15" :stroke-width="1.8" />
|
|
136
|
+
</button>
|
|
137
|
+
|
|
131
138
|
<div :class="styles.account">
|
|
132
139
|
<div :class="styles.accountAvatar">관</div>
|
|
133
140
|
<span :class="styles.accountName">관리자</span>
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
.form {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: 16px;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.field {
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
gap: 6px;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.label {
|
|
14
|
+
font-size: 0.75rem;
|
|
15
|
+
font-weight: 600;
|
|
16
|
+
color: var(--common-text-sub, #52525b);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.optionsRow {
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
justify-content: space-between;
|
|
23
|
+
margin-top: -4px;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.link {
|
|
27
|
+
font-size: 0.75rem;
|
|
28
|
+
font-weight: 500;
|
|
29
|
+
color: var(--common-primary, #2563eb);
|
|
30
|
+
text-decoration: none;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.link:hover {
|
|
34
|
+
text-decoration: underline;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.helper {
|
|
38
|
+
margin: 0;
|
|
39
|
+
font-size: 0.75rem;
|
|
40
|
+
line-height: 1.5;
|
|
41
|
+
color: var(--common-text-sub, #52525b);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.divider {
|
|
45
|
+
height: 1px;
|
|
46
|
+
background: var(--common-border, #e2e4ec);
|
|
47
|
+
border: none;
|
|
48
|
+
margin: 2px 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.backLink {
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
gap: 4px;
|
|
55
|
+
font-size: 0.75rem;
|
|
56
|
+
font-weight: 500;
|
|
57
|
+
color: var(--common-text-sub, #52525b);
|
|
58
|
+
text-decoration: none;
|
|
59
|
+
align-self: center;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.backLink:hover {
|
|
63
|
+
color: var(--common-text, #18181b);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.otpRow {
|
|
67
|
+
display: flex;
|
|
68
|
+
gap: 8px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.otpInput {
|
|
72
|
+
width: 0;
|
|
73
|
+
flex: 1 1 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.otpInput input {
|
|
77
|
+
width: 100%;
|
|
78
|
+
height: 48px;
|
|
79
|
+
box-sizing: border-box;
|
|
80
|
+
border: 1px solid var(--common-border, #e2e4ec);
|
|
81
|
+
border-radius: var(--common-radius-md, 8px);
|
|
82
|
+
background: var(--common-bg, #fff);
|
|
83
|
+
color: var(--common-text, #18181b);
|
|
84
|
+
text-align: center;
|
|
85
|
+
font-size: 1.125rem;
|
|
86
|
+
font-weight: 700;
|
|
87
|
+
font-family: inherit;
|
|
88
|
+
outline: none;
|
|
89
|
+
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.otpInput input:focus-visible {
|
|
93
|
+
border-color: var(--common-primary, #2563eb);
|
|
94
|
+
box-shadow: 0 0 0 2px var(--common-primary-light, rgba(37, 99, 235, 0.15));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.resendRow {
|
|
98
|
+
display: flex;
|
|
99
|
+
align-items: center;
|
|
100
|
+
justify-content: center;
|
|
101
|
+
gap: 6px;
|
|
102
|
+
font-size: 0.75rem;
|
|
103
|
+
color: var(--common-text-sub, #52525b);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.resendBtn {
|
|
107
|
+
background: none;
|
|
108
|
+
border: none;
|
|
109
|
+
padding: 0;
|
|
110
|
+
font: inherit;
|
|
111
|
+
font-weight: 600;
|
|
112
|
+
color: var(--common-primary, #2563eb);
|
|
113
|
+
cursor: pointer;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.resendBtn:disabled {
|
|
117
|
+
color: var(--common-text-disabled, #a1a1aa);
|
|
118
|
+
cursor: not-allowed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.successBox {
|
|
122
|
+
display: flex;
|
|
123
|
+
flex-direction: column;
|
|
124
|
+
align-items: center;
|
|
125
|
+
gap: 10px;
|
|
126
|
+
text-align: center;
|
|
127
|
+
padding: 8px 0 4px;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.successIcon {
|
|
131
|
+
width: 44px;
|
|
132
|
+
height: 44px;
|
|
133
|
+
border-radius: 999px;
|
|
134
|
+
background: var(--common-primary-light, oklch(0.52 0.16 258 / 0.1));
|
|
135
|
+
color: var(--common-primary, #2563eb);
|
|
136
|
+
display: flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
justify-content: center;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.successText {
|
|
142
|
+
margin: 0;
|
|
143
|
+
font-size: 0.8125rem;
|
|
144
|
+
line-height: 1.5;
|
|
145
|
+
color: var(--common-text-sub, #52525b);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.stepDots {
|
|
149
|
+
display: flex;
|
|
150
|
+
align-items: center;
|
|
151
|
+
justify-content: center;
|
|
152
|
+
gap: 6px;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.stepDot {
|
|
156
|
+
width: 6px;
|
|
157
|
+
height: 6px;
|
|
158
|
+
border-radius: 999px;
|
|
159
|
+
background: var(--common-border-strong, #cbd0df);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.stepDotActive {
|
|
163
|
+
background: var(--common-primary, #2563eb);
|
|
164
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
.page {
|
|
2
|
+
min-height: 100vh;
|
|
3
|
+
width: 100%;
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
align-items: center;
|
|
7
|
+
justify-content: center;
|
|
8
|
+
gap: 20px;
|
|
9
|
+
padding: 32px 16px;
|
|
10
|
+
background: var(--common-bg-subtle, #f8f8fa);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.card {
|
|
14
|
+
width: 100%;
|
|
15
|
+
max-width: 384px;
|
|
16
|
+
background: var(--common-bg, #fff);
|
|
17
|
+
border: 1px solid var(--common-border, #e2e4ec);
|
|
18
|
+
border-radius: var(--common-radius-lg, 12px);
|
|
19
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 12px 32px rgba(24, 24, 27, 0.06);
|
|
20
|
+
padding: 32px 28px 28px;
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-direction: column;
|
|
23
|
+
gap: 22px;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.brandRow {
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
gap: 8px;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.logo {
|
|
33
|
+
width: 26px;
|
|
34
|
+
height: 26px;
|
|
35
|
+
flex: none;
|
|
36
|
+
border-radius: 7px;
|
|
37
|
+
background: var(--common-primary, oklch(0.62 0.15 258));
|
|
38
|
+
color: #fff;
|
|
39
|
+
display: flex;
|
|
40
|
+
align-items: center;
|
|
41
|
+
justify-content: center;
|
|
42
|
+
font-size: 0.8125rem;
|
|
43
|
+
font-weight: 800;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.brand {
|
|
47
|
+
font-size: 0.875rem;
|
|
48
|
+
font-weight: 700;
|
|
49
|
+
color: var(--common-text, #18181b);
|
|
50
|
+
letter-spacing: -0.01em;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.heading {
|
|
54
|
+
display: flex;
|
|
55
|
+
flex-direction: column;
|
|
56
|
+
gap: 6px;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.eyebrow {
|
|
60
|
+
font-size: 0.6875rem;
|
|
61
|
+
font-weight: 600;
|
|
62
|
+
color: var(--common-primary, #2563eb);
|
|
63
|
+
letter-spacing: 0.02em;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.title {
|
|
67
|
+
margin: 0;
|
|
68
|
+
font-size: 1.25rem;
|
|
69
|
+
font-weight: 700;
|
|
70
|
+
color: var(--common-text, #18181b);
|
|
71
|
+
letter-spacing: -0.01em;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.subtitle {
|
|
75
|
+
margin: 0;
|
|
76
|
+
font-size: 0.8125rem;
|
|
77
|
+
line-height: 1.5;
|
|
78
|
+
color: var(--common-text-sub, #52525b);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.footer {
|
|
82
|
+
margin: 0;
|
|
83
|
+
font-size: 0.75rem;
|
|
84
|
+
color: var(--common-text-muted, #8b8b93);
|
|
85
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
export interface AuthLayoutProps {
|
|
3
|
+
title: string;
|
|
4
|
+
subtitle?: string;
|
|
5
|
+
eyebrow?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
defineProps<AuthLayoutProps>();
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<div :class="$style.page">
|
|
13
|
+
<div :class="$style.card">
|
|
14
|
+
<div :class="$style.brandRow">
|
|
15
|
+
<span :class="$style.logo">A</span>
|
|
16
|
+
<span :class="$style.brand">백오피스</span>
|
|
17
|
+
</div>
|
|
18
|
+
<div :class="$style.heading">
|
|
19
|
+
<span v-if="eyebrow" :class="$style.eyebrow">{{ eyebrow }}</span>
|
|
20
|
+
<h1 :class="$style.title">{{ title }}</h1>
|
|
21
|
+
<p v-if="subtitle" :class="$style.subtitle">{{ subtitle }}</p>
|
|
22
|
+
</div>
|
|
23
|
+
<slot />
|
|
24
|
+
</div>
|
|
25
|
+
<p :class="$style.footer">© 2026 백오피스. All rights reserved.</p>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<style module src="./AuthLayout.module.css"></style>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { reactive, ref } from 'vue';
|
|
3
|
+
import { useRouter } from 'vue-router';
|
|
4
|
+
import { Button } from 'm2m-uiux-vue/Button';
|
|
5
|
+
import { Input } from 'm2m-uiux-vue/Input';
|
|
6
|
+
import { Checkbox } from 'm2m-uiux-vue/Checkbox';
|
|
7
|
+
import AuthLayout from './AuthLayout.vue';
|
|
8
|
+
import styles from './Auth.module.css';
|
|
9
|
+
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
|
|
12
|
+
const form = reactive({ id: '', password: '' });
|
|
13
|
+
const keepSignedIn = ref(false);
|
|
14
|
+
const idError = ref('');
|
|
15
|
+
const passwordError = ref('');
|
|
16
|
+
const submitting = ref(false);
|
|
17
|
+
|
|
18
|
+
function validate() {
|
|
19
|
+
idError.value = form.id.trim() ? '' : '아이디를 입력해 주세요.';
|
|
20
|
+
passwordError.value = form.password ? '' : '비밀번호를 입력해 주세요.';
|
|
21
|
+
return !idError.value && !passwordError.value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function submit() {
|
|
25
|
+
if (!validate() || submitting.value) return;
|
|
26
|
+
submitting.value = true;
|
|
27
|
+
window.setTimeout(() => {
|
|
28
|
+
submitting.value = false;
|
|
29
|
+
router.push({ path: '/verify-2fa', query: { id: form.id } });
|
|
30
|
+
}, 500);
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<AuthLayout eyebrow="ADMIN LOGIN" title="백오피스에 로그인" subtitle="관리자 계정 정보를 입력해 주세요.">
|
|
36
|
+
<form :class="styles.form" @submit.prevent="submit">
|
|
37
|
+
<label :class="styles.field">
|
|
38
|
+
<span :class="styles.label">아이디</span>
|
|
39
|
+
<Input v-model="form.id" placeholder="아이디를 입력하세요" :error="idError || undefined" autocomplete="username" />
|
|
40
|
+
</label>
|
|
41
|
+
<label :class="styles.field">
|
|
42
|
+
<span :class="styles.label">비밀번호</span>
|
|
43
|
+
<Input v-model="form.password" type="password" show-toggle placeholder="비밀번호를 입력하세요" :error="passwordError || undefined" autocomplete="current-password" />
|
|
44
|
+
</label>
|
|
45
|
+
<div :class="styles.optionsRow">
|
|
46
|
+
<Checkbox v-model="keepSignedIn" label="로그인 상태 유지" size="md" />
|
|
47
|
+
<RouterLink :class="styles.link" to="/reset-password">비밀번호를 잊으셨나요?</RouterLink>
|
|
48
|
+
</div>
|
|
49
|
+
<Button html-type="submit" size="lg" full-width :loading="submitting">로그인</Button>
|
|
50
|
+
</form>
|
|
51
|
+
</AuthLayout>
|
|
52
|
+
</template>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, reactive, ref } from 'vue';
|
|
3
|
+
import { useRouter } from 'vue-router';
|
|
4
|
+
import { Button } from 'm2m-uiux-vue/Button';
|
|
5
|
+
import { Input } from 'm2m-uiux-vue/Input';
|
|
6
|
+
import AuthLayout from './AuthLayout.vue';
|
|
7
|
+
import styles from './Auth.module.css';
|
|
8
|
+
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
|
|
11
|
+
const step = ref<1 | 2 | 3>(1);
|
|
12
|
+
const identify = reactive({ id: '', email: '' });
|
|
13
|
+
const identifyErrors = reactive({ id: '', email: '' });
|
|
14
|
+
const reset = reactive({ code: '', password: '', confirm: '' });
|
|
15
|
+
const resetErrors = reactive({ code: '', password: '', confirm: '' });
|
|
16
|
+
const submitting = ref(false);
|
|
17
|
+
|
|
18
|
+
const heading = computed(() => {
|
|
19
|
+
if (step.value === 1) return { eyebrow: 'PASSWORD RESET', title: '비밀번호 초기화', subtitle: '가입 시 등록한 아이디와 이메일을 입력해 주세요.' };
|
|
20
|
+
if (step.value === 2) return { eyebrow: 'PASSWORD RESET', title: '새 비밀번호 설정', subtitle: '이메일로 전송된 인증코드와 새 비밀번호를 입력해 주세요.' };
|
|
21
|
+
return { eyebrow: 'PASSWORD RESET', title: '비밀번호가 변경되었습니다', subtitle: '새로운 비밀번호로 다시 로그인해 주세요.' };
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function submitIdentify() {
|
|
25
|
+
identifyErrors.id = identify.id.trim() ? '' : '아이디를 입력해 주세요.';
|
|
26
|
+
identifyErrors.email = /.+@.+\..+/.test(identify.email) ? '' : '올바른 이메일 주소를 입력해 주세요.';
|
|
27
|
+
if (identifyErrors.id || identifyErrors.email) return;
|
|
28
|
+
submitting.value = true;
|
|
29
|
+
window.setTimeout(() => {
|
|
30
|
+
submitting.value = false;
|
|
31
|
+
step.value = 2;
|
|
32
|
+
}, 500);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function submitReset() {
|
|
36
|
+
resetErrors.code = reset.code.trim().length === 6 ? '' : '인증코드 6자리를 입력해 주세요.';
|
|
37
|
+
resetErrors.password = reset.password.length >= 8 ? '' : '비밀번호는 8자 이상이어야 합니다.';
|
|
38
|
+
resetErrors.confirm = reset.confirm && reset.confirm === reset.password ? '' : '비밀번호가 일치하지 않습니다.';
|
|
39
|
+
if (resetErrors.code || resetErrors.password || resetErrors.confirm) return;
|
|
40
|
+
submitting.value = true;
|
|
41
|
+
window.setTimeout(() => {
|
|
42
|
+
submitting.value = false;
|
|
43
|
+
step.value = 3;
|
|
44
|
+
}, 500);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function goLogin() {
|
|
48
|
+
router.push('/login');
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<template>
|
|
53
|
+
<AuthLayout :eyebrow="heading.eyebrow" :title="heading.title" :subtitle="heading.subtitle">
|
|
54
|
+
<div :class="styles.stepDots">
|
|
55
|
+
<span :class="[styles.stepDot, step >= 1 && styles.stepDotActive]" />
|
|
56
|
+
<span :class="[styles.stepDot, step >= 2 && styles.stepDotActive]" />
|
|
57
|
+
<span :class="[styles.stepDot, step >= 3 && styles.stepDotActive]" />
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<form v-if="step === 1" :class="styles.form" @submit.prevent="submitIdentify">
|
|
61
|
+
<label :class="styles.field">
|
|
62
|
+
<span :class="styles.label">아이디</span>
|
|
63
|
+
<Input v-model="identify.id" placeholder="아이디를 입력하세요" :error="identifyErrors.id || undefined" />
|
|
64
|
+
</label>
|
|
65
|
+
<label :class="styles.field">
|
|
66
|
+
<span :class="styles.label">이메일</span>
|
|
67
|
+
<Input v-model="identify.email" type="email" placeholder="가입 시 등록한 이메일" :error="identifyErrors.email || undefined" autocomplete="email" />
|
|
68
|
+
</label>
|
|
69
|
+
<Button html-type="submit" full-width :loading="submitting">인증코드 받기</Button>
|
|
70
|
+
<RouterLink :class="styles.backLink" to="/login">← 로그인으로 돌아가기</RouterLink>
|
|
71
|
+
</form>
|
|
72
|
+
|
|
73
|
+
<form v-else-if="step === 2" :class="styles.form" @submit.prevent="submitReset">
|
|
74
|
+
<label :class="styles.field">
|
|
75
|
+
<span :class="styles.label">인증코드</span>
|
|
76
|
+
<Input v-model="reset.code" placeholder="이메일로 받은 인증코드 6자리" :max-length="6" :error="resetErrors.code || undefined" />
|
|
77
|
+
</label>
|
|
78
|
+
<label :class="styles.field">
|
|
79
|
+
<span :class="styles.label">새 비밀번호</span>
|
|
80
|
+
<Input v-model="reset.password" type="password" show-toggle placeholder="8자 이상 입력하세요" :error="resetErrors.password || undefined" autocomplete="new-password" />
|
|
81
|
+
</label>
|
|
82
|
+
<label :class="styles.field">
|
|
83
|
+
<span :class="styles.label">새 비밀번호 확인</span>
|
|
84
|
+
<Input v-model="reset.confirm" type="password" show-toggle placeholder="새 비밀번호를 다시 입력하세요" :error="resetErrors.confirm || undefined" autocomplete="new-password" />
|
|
85
|
+
</label>
|
|
86
|
+
<Button html-type="submit" full-width :loading="submitting">비밀번호 변경</Button>
|
|
87
|
+
<button type="button" :class="styles.backLink" style="border: none; background: none; cursor: pointer" @click="step = 1">← 이전 단계로</button>
|
|
88
|
+
</form>
|
|
89
|
+
|
|
90
|
+
<div v-else :class="styles.successBox">
|
|
91
|
+
<span :class="styles.successIcon">✓</span>
|
|
92
|
+
<p :class="styles.successText">비밀번호가 성공적으로 변경되었습니다.<br />새 비밀번호로 로그인해 주세요.</p>
|
|
93
|
+
<Button full-width @click="goLogin">로그인 화면으로 이동</Button>
|
|
94
|
+
</div>
|
|
95
|
+
</AuthLayout>
|
|
96
|
+
</template>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
3
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
4
|
+
import { Button } from 'm2m-uiux-vue/Button';
|
|
5
|
+
import { showToast } from '../../components/common';
|
|
6
|
+
import AuthLayout from './AuthLayout.vue';
|
|
7
|
+
import styles from './Auth.module.css';
|
|
8
|
+
|
|
9
|
+
const route = useRoute();
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
|
|
12
|
+
const loginId = computed(() => (typeof route.query.id === 'string' ? route.query.id : ''));
|
|
13
|
+
const digits = ref<string[]>(['', '', '', '', '', '']);
|
|
14
|
+
const inputs = ref<Array<HTMLInputElement | null>>([]);
|
|
15
|
+
const error = ref('');
|
|
16
|
+
const verifying = ref(false);
|
|
17
|
+
const resendCooldown = ref(30);
|
|
18
|
+
let timer: number | undefined;
|
|
19
|
+
|
|
20
|
+
onMounted(() => {
|
|
21
|
+
inputs.value[0]?.focus();
|
|
22
|
+
timer = window.setInterval(() => {
|
|
23
|
+
if (resendCooldown.value > 0) resendCooldown.value -= 1;
|
|
24
|
+
}, 1000);
|
|
25
|
+
});
|
|
26
|
+
onBeforeUnmount(() => window.clearInterval(timer));
|
|
27
|
+
|
|
28
|
+
function onInput(index: number, event: Event) {
|
|
29
|
+
const target = event.target as HTMLInputElement;
|
|
30
|
+
const value = target.value.replace(/[^0-9]/g, '').slice(-1);
|
|
31
|
+
digits.value[index] = value;
|
|
32
|
+
error.value = '';
|
|
33
|
+
if (value && index < digits.value.length - 1) inputs.value[index + 1]?.focus();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function onKeydown(index: number, event: KeyboardEvent) {
|
|
37
|
+
if (event.key === 'Backspace' && !digits.value[index] && index > 0) {
|
|
38
|
+
inputs.value[index - 1]?.focus();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function onPaste(event: ClipboardEvent) {
|
|
43
|
+
const text = event.clipboardData?.getData('text') ?? '';
|
|
44
|
+
const numbers = text.replace(/[^0-9]/g, '').slice(0, digits.value.length).split('');
|
|
45
|
+
if (!numbers.length) return;
|
|
46
|
+
event.preventDefault();
|
|
47
|
+
numbers.forEach((n, i) => (digits.value[i] = n));
|
|
48
|
+
const nextIndex = Math.min(numbers.length, digits.value.length - 1);
|
|
49
|
+
inputs.value[nextIndex]?.focus();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resend() {
|
|
53
|
+
if (resendCooldown.value > 0) return;
|
|
54
|
+
resendCooldown.value = 30;
|
|
55
|
+
showToast({ message: '인증번호를 다시 전송했습니다.', type: 'success' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function submit() {
|
|
59
|
+
const code = digits.value.join('');
|
|
60
|
+
if (code.length < digits.value.length) {
|
|
61
|
+
error.value = '인증번호 6자리를 모두 입력해 주세요.';
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
verifying.value = true;
|
|
65
|
+
window.setTimeout(() => {
|
|
66
|
+
verifying.value = false;
|
|
67
|
+
router.push('/');
|
|
68
|
+
}, 500);
|
|
69
|
+
}
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<template>
|
|
73
|
+
<AuthLayout eyebrow="TWO-FACTOR AUTHENTICATION" title="2차 인증" :subtitle="loginId ? `${loginId} 계정으로 전송된 인증번호 6자리를 입력해 주세요.` : '등록된 인증 수단으로 전송된 인증번호 6자리를 입력해 주세요.'">
|
|
74
|
+
<form :class="styles.form" @submit.prevent="submit">
|
|
75
|
+
<div :class="styles.otpRow" @paste="onPaste">
|
|
76
|
+
<div v-for="(_, i) in digits" :key="i" :class="styles.otpInput">
|
|
77
|
+
<input
|
|
78
|
+
:ref="(el) => (inputs[i] = el as HTMLInputElement)"
|
|
79
|
+
:value="digits[i]"
|
|
80
|
+
inputmode="numeric"
|
|
81
|
+
maxlength="1"
|
|
82
|
+
:aria-label="`인증번호 ${i + 1}번째 자리`"
|
|
83
|
+
@input="onInput(i, $event)"
|
|
84
|
+
@keydown="onKeydown(i, $event)"
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<p v-if="error" :class="styles.helper" style="color: var(--common-error, #dc2626)">{{ error }}</p>
|
|
89
|
+
<div :class="styles.resendRow">
|
|
90
|
+
<span>인증번호를 받지 못하셨나요?</span>
|
|
91
|
+
<button type="button" :class="styles.resendBtn" :disabled="resendCooldown > 0" @click="resend">{{ resendCooldown > 0 ? `재전송 (${resendCooldown}s)` : '재전송' }}</button>
|
|
92
|
+
</div>
|
|
93
|
+
<Button html-type="submit" full-width :loading="verifying">인증하기</Button>
|
|
94
|
+
<RouterLink :class="styles.backLink" to="/login">← 로그인으로 돌아가기</RouterLink>
|
|
95
|
+
</form>
|
|
96
|
+
</AuthLayout>
|
|
97
|
+
</template>
|
|
@@ -120,7 +120,7 @@ function reset() {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
function goToLinkedAction() {
|
|
123
|
-
if (selected.value
|
|
123
|
+
if (selected.value && selected.value.linkedAction !== '-') router.push(selected.value.linkedAction);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
function download() {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, ref } from 'vue';
|
|
3
|
+
import { useRouter } from 'vue-router';
|
|
3
4
|
import { DataGrid } from '../../components/DataGrid';
|
|
4
5
|
import type { GridRow } from '../../components/DataGrid/types';
|
|
5
6
|
import { BUSINESS_BADGE_META, BUSINESS_SCOPES, type BusinessScope, type BusinessType } from '../../lib/business';
|
|
@@ -225,8 +226,9 @@ function register() {
|
|
|
225
226
|
toastBriefly('상담 내역을 등록했습니다.');
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
const router = useRouter();
|
|
228
230
|
function goToInquiries() {
|
|
229
|
-
|
|
231
|
+
router.push('/cs/inquiries');
|
|
230
232
|
}
|
|
231
233
|
|
|
232
234
|
function reopenInquiry() {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, ref } from 'vue';
|
|
3
|
+
import { useRouter } from 'vue-router';
|
|
3
4
|
import drawer from '../ops/opsDrawerShared.module.css';
|
|
4
5
|
import styles from './CsHistoryPage.module.css';
|
|
5
6
|
import { useOutsideClose } from '../../lib/useOutsideClose';
|
|
@@ -20,8 +21,9 @@ const adjacent = computed(() => findAdjacent(props.log, props.all));
|
|
|
20
21
|
const asideRef = ref<HTMLElement | null>(null);
|
|
21
22
|
useOutsideClose(asideRef, () => emit('close'));
|
|
22
23
|
|
|
24
|
+
const router = useRouter();
|
|
23
25
|
function goTo(path: string) {
|
|
24
|
-
|
|
26
|
+
router.push(path);
|
|
25
27
|
}
|
|
26
28
|
</script>
|
|
27
29
|
|