Spaces:
Running
Running
神代綺凛
commited on
Commit
·
afc074a
1
Parent(s):
ef2c178
feat: cookie display
Browse files- .eslintrc.cjs +3 -0
- .vscode/settings.json +4 -1
- README.md +9 -0
- _api/qr.ts +92 -27
- server/utils.ts +1 -1
- src/App.vue +19 -67
- src/assets/icons/content_copy.svg +1 -0
- src/components/CookieDisplay.vue +51 -0
- src/components/RefreshBtn.vue +5 -2
- src/main.ts +1 -0
- src/style.less +65 -0
- src/utils/qrSSE.ts +155 -0
- src/utils/tipText.ts +17 -0
- vercel.json +5 -0
.eslintrc.cjs
CHANGED
@@ -23,6 +23,9 @@ module.exports = {
|
|
23 |
'@typescript-eslint/ban-types': 'off',
|
24 |
'@typescript-eslint/no-explicit-any': 'off',
|
25 |
'@typescript-eslint/ban-ts-comment': 'off',
|
|
|
|
|
|
|
26 |
'@typescript-eslint/consistent-type-imports': [
|
27 |
'error',
|
28 |
{
|
|
|
23 |
'@typescript-eslint/ban-types': 'off',
|
24 |
'@typescript-eslint/no-explicit-any': 'off',
|
25 |
'@typescript-eslint/ban-ts-comment': 'off',
|
26 |
+
'@typescript-eslint/no-unused-vars': 'warn',
|
27 |
+
'@typescript-eslint/explicit-member-accessibility': 'warn',
|
28 |
+
'@typescript-eslint/member-ordering': 'warn',
|
29 |
'@typescript-eslint/consistent-type-imports': [
|
30 |
'error',
|
31 |
{
|
.vscode/settings.json
CHANGED
@@ -1,3 +1,6 @@
|
|
1 |
{
|
2 |
-
"typescript.tsdk": "node_modules/typescript/lib"
|
|
|
|
|
|
|
3 |
}
|
|
|
1 |
{
|
2 |
+
"typescript.tsdk": "node_modules/typescript/lib",
|
3 |
+
"editor.codeActionsOnSave": {
|
4 |
+
"source.fixAll.eslint": "explicit"
|
5 |
+
}
|
6 |
}
|
README.md
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
## 哔哩哔哩 cookie 获取工具
|
2 |
+
|
3 |
+
扫码登录获取B站 cookie,用于各种开发场景
|
4 |
+
|
5 |
+
Vercel 部署:[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FTsuk1ko%2Fbilibili-qr-login)
|
6 |
+
|
7 |
+
### TODO
|
8 |
+
|
9 |
+
- [ ] iframe / 窗口 模式
|
_api/qr.ts
CHANGED
@@ -6,15 +6,35 @@ import type { StreamingApi } from 'hono/utils/stream';
|
|
6 |
enum SSEEvent {
|
7 |
GENERATE = 'generate',
|
8 |
POLL = 'poll',
|
|
|
9 |
}
|
10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
export const runtime = 'edge';
|
12 |
|
13 |
export const app = new Hono().basePath('/api');
|
14 |
|
15 |
-
app.get('/qr', c =>
|
16 |
-
|
17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
c.header('Content-Type', 'text/event-stream; charset=utf-8');
|
19 |
|
20 |
let streamClosed = false;
|
@@ -22,28 +42,45 @@ app.get('/qr', c =>
|
|
22 |
streamClosed = true;
|
23 |
});
|
24 |
|
|
|
|
|
|
|
25 |
// 获取登录链接
|
26 |
-
const qr = new LoginQr();
|
27 |
-
|
28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
29 |
|
30 |
-
|
31 |
-
|
|
|
32 |
const result = await qr.poll();
|
33 |
await stream.writeSSE({ data: JSON.stringify(result), event: SSEEvent.POLL });
|
34 |
-
if (result.code
|
35 |
-
stream.
|
36 |
-
|
|
|
37 |
}
|
38 |
await stream.sleep(2000);
|
39 |
}
|
40 |
-
|
41 |
-
);
|
|
|
|
|
|
|
42 |
|
43 |
export const GET = handle(app);
|
44 |
|
45 |
interface GenerateQrResp {
|
46 |
-
code:
|
47 |
message: string;
|
48 |
ttl: number;
|
49 |
data: {
|
@@ -53,7 +90,7 @@ interface GenerateQrResp {
|
|
53 |
}
|
54 |
|
55 |
interface PollQrResp {
|
56 |
-
code:
|
57 |
message: string;
|
58 |
ttl: number;
|
59 |
data: {
|
@@ -72,34 +109,56 @@ interface PollQrResult {
|
|
72 |
}
|
73 |
|
74 |
class LoginQr {
|
75 |
-
private
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
76 |
|
77 |
public async generate() {
|
78 |
-
const r = await fetch('https://passport.bilibili.com/x/passport-login/web/qrcode/generate?source=main-fe-header'
|
|
|
|
|
79 |
const {
|
80 |
-
|
|
|
|
|
81 |
} = (await r.json()) as GenerateQrResp;
|
82 |
this.key = key;
|
83 |
-
return { url, key };
|
84 |
}
|
85 |
|
86 |
public async poll() {
|
87 |
const r0 = await fetch(
|
88 |
`https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key=${this.key}&source=main-fe-header`,
|
|
|
89 |
);
|
90 |
-
const { data } = (await r0.json()) as PollQrResp;
|
91 |
-
const result: PollQrResult =
|
92 |
-
code
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
97 |
|
98 |
const cookie = new Cookie(r0.headers.getSetCookie());
|
99 |
const r1 = await fetch(data.url);
|
100 |
|
101 |
cookie.add(r1.headers.getSetCookie());
|
102 |
-
result.cookie = cookie.toString();
|
103 |
|
104 |
return result;
|
105 |
}
|
@@ -118,6 +177,12 @@ class Cookie {
|
|
118 |
const [name, ...values] = nv.split('=');
|
119 |
this.cookie.set(name, values.join('='));
|
120 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
121 |
}
|
122 |
|
123 |
public toString() {
|
|
|
6 |
enum SSEEvent {
|
7 |
GENERATE = 'generate',
|
8 |
POLL = 'poll',
|
9 |
+
END = 'end',
|
10 |
}
|
11 |
|
12 |
+
enum PollQrResultCode {
|
13 |
+
SUCCESS = 0,
|
14 |
+
EXPIRED = 86038,
|
15 |
+
NOT_CONFIRMED = 86090,
|
16 |
+
NOT_SCANNED = 86101,
|
17 |
+
}
|
18 |
+
|
19 |
+
const keepPollQrResultCode = new Set([PollQrResultCode.NOT_CONFIRMED, PollQrResultCode.NOT_SCANNED]);
|
20 |
+
|
21 |
export const runtime = 'edge';
|
22 |
|
23 |
export const app = new Hono().basePath('/api');
|
24 |
|
25 |
+
app.get('/qr', c => {
|
26 |
+
if (process.env.NODE_ENV !== 'development') {
|
27 |
+
try {
|
28 |
+
const referer = c.req.header('Referer');
|
29 |
+
if (!referer || new URL(referer).origin !== new URL(c.req.url).origin) {
|
30 |
+
return c.text('', 403);
|
31 |
+
}
|
32 |
+
} catch {
|
33 |
+
return c.text('', 403);
|
34 |
+
}
|
35 |
+
}
|
36 |
+
return streamSSE(c, async stream => {
|
37 |
+
// 编码加上 charset
|
38 |
c.header('Content-Type', 'text/event-stream; charset=utf-8');
|
39 |
|
40 |
let streamClosed = false;
|
|
|
42 |
streamClosed = true;
|
43 |
});
|
44 |
|
45 |
+
// 断线重连时的 key
|
46 |
+
const lastEventID = c.req.header('Last-Event-ID');
|
47 |
+
|
48 |
// 获取登录链接
|
49 |
+
const qr = new LoginQr(c.req.header('User-Agent'), lastEventID);
|
50 |
+
if (!lastEventID) {
|
51 |
+
const genRes = await qr.generate();
|
52 |
+
console.log('generate', Date.now());
|
53 |
+
await stream.writeSSE({ data: JSON.stringify(genRes), event: SSEEvent.GENERATE, id: genRes.key });
|
54 |
+
if (genRes.code !== 0) {
|
55 |
+
await stream.writeSSE({ data: '', event: SSEEvent.END });
|
56 |
+
await stream.close();
|
57 |
+
return;
|
58 |
+
}
|
59 |
+
await stream.sleep(2000);
|
60 |
+
}
|
61 |
|
62 |
+
// 轮询
|
63 |
+
for (let i = 0; i < 100 && !streamClosed; i++) {
|
64 |
+
console.log('poll', i, Date.now());
|
65 |
const result = await qr.poll();
|
66 |
await stream.writeSSE({ data: JSON.stringify(result), event: SSEEvent.POLL });
|
67 |
+
if (!keepPollQrResultCode.has(result.code)) {
|
68 |
+
await stream.writeSSE({ data: '', event: SSEEvent.END });
|
69 |
+
await stream.close();
|
70 |
+
return;
|
71 |
}
|
72 |
await stream.sleep(2000);
|
73 |
}
|
74 |
+
|
75 |
+
await stream.writeSSE({ data: '超时终止', event: SSEEvent.END });
|
76 |
+
await stream.close();
|
77 |
+
});
|
78 |
+
});
|
79 |
|
80 |
export const GET = handle(app);
|
81 |
|
82 |
interface GenerateQrResp {
|
83 |
+
code: number;
|
84 |
message: string;
|
85 |
ttl: number;
|
86 |
data: {
|
|
|
90 |
}
|
91 |
|
92 |
interface PollQrResp {
|
93 |
+
code: number;
|
94 |
message: string;
|
95 |
ttl: number;
|
96 |
data: {
|
|
|
109 |
}
|
110 |
|
111 |
class LoginQr {
|
112 |
+
private readonly header: Record<string, string | undefined> = {};
|
113 |
+
|
114 |
+
public constructor(
|
115 |
+
userAgent?: string,
|
116 |
+
private key = '',
|
117 |
+
) {
|
118 |
+
this.header = {
|
119 |
+
'User-Agent': userAgent,
|
120 |
+
Origin: 'https://www.bilibili.com',
|
121 |
+
Referer: 'https://www.bilibili.com/',
|
122 |
+
};
|
123 |
+
}
|
124 |
|
125 |
public async generate() {
|
126 |
+
const r = await fetch('https://passport.bilibili.com/x/passport-login/web/qrcode/generate?source=main-fe-header', {
|
127 |
+
headers: this.header,
|
128 |
+
});
|
129 |
const {
|
130 |
+
code,
|
131 |
+
message,
|
132 |
+
data: { url, qrcode_key: key } = { url: '', qrcode_key: '' },
|
133 |
} = (await r.json()) as GenerateQrResp;
|
134 |
this.key = key;
|
135 |
+
return { code, msg: message, url, key };
|
136 |
}
|
137 |
|
138 |
public async poll() {
|
139 |
const r0 = await fetch(
|
140 |
`https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key=${this.key}&source=main-fe-header`,
|
141 |
+
{ headers: this.header },
|
142 |
);
|
143 |
+
const { code, message, data } = (await r0.json()) as PollQrResp;
|
144 |
+
const result: PollQrResult =
|
145 |
+
code === 0
|
146 |
+
? {
|
147 |
+
code: data.code,
|
148 |
+
msg: data.message,
|
149 |
+
}
|
150 |
+
: {
|
151 |
+
code: Number(code),
|
152 |
+
msg: message,
|
153 |
+
};
|
154 |
+
|
155 |
+
if (result.code !== 0) return result;
|
156 |
|
157 |
const cookie = new Cookie(r0.headers.getSetCookie());
|
158 |
const r1 = await fetch(data.url);
|
159 |
|
160 |
cookie.add(r1.headers.getSetCookie());
|
161 |
+
result.cookie = cookie.del('i-wanna-go-back').toString();
|
162 |
|
163 |
return result;
|
164 |
}
|
|
|
177 |
const [name, ...values] = nv.split('=');
|
178 |
this.cookie.set(name, values.join('='));
|
179 |
});
|
180 |
+
return this;
|
181 |
+
}
|
182 |
+
|
183 |
+
public del(name: string) {
|
184 |
+
this.cookie.delete(name);
|
185 |
+
return this;
|
186 |
}
|
187 |
|
188 |
public toString() {
|
server/utils.ts
CHANGED
@@ -16,7 +16,7 @@ export const startApiServer = async () => {
|
|
16 |
nodemon({
|
17 |
script: 'server/index.ts',
|
18 |
ext: 'ts',
|
19 |
-
watch: ['_api'],
|
20 |
execMap: {
|
21 |
ts: 'tsx',
|
22 |
},
|
|
|
16 |
nodemon({
|
17 |
script: 'server/index.ts',
|
18 |
ext: 'ts',
|
19 |
+
watch: ['_api', 'server'],
|
20 |
execMap: {
|
21 |
ts: 'tsx',
|
22 |
},
|
src/App.vue
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
<template>
|
2 |
-
<div class="text-center">
|
3 |
<h2>哔哩哔哩 cookie 获取工具</h2>
|
4 |
<p>
|
5 |
使用手机 APP 扫码登录后即可获取 cookie (<a
|
@@ -10,89 +10,37 @@
|
|
10 |
>)
|
11 |
</p>
|
12 |
</div>
|
13 |
-
<div class="qrcode flex">
|
14 |
-
<QrCode :value="
|
15 |
-
<div v-if="
|
16 |
-
<CheckIcon v-if="
|
17 |
-
<RefreshBtn v-else />
|
18 |
</div>
|
19 |
</div>
|
20 |
-
<div class="text-center">
|
21 |
-
<p>{{
|
|
|
|
|
|
|
22 |
</div>
|
23 |
</template>
|
24 |
|
25 |
<script setup lang="ts">
|
26 |
-
import { computed, ref } from 'vue';
|
27 |
import QrCode from '@chenfengyuan/vue-qrcode';
|
28 |
import RefreshBtn from './components/RefreshBtn.vue';
|
|
|
29 |
import CheckIcon from './assets/icons/check_circle.svg';
|
|
|
30 |
import type { QRCodeRenderersOptions } from 'qrcode';
|
31 |
|
32 |
-
enum QrStatus {
|
33 |
-
WAIT,
|
34 |
-
SCANNED,
|
35 |
-
EXPIRED,
|
36 |
-
SUCCESS,
|
37 |
-
ERROR,
|
38 |
-
}
|
39 |
-
|
40 |
const qrCodeOption: QRCodeRenderersOptions = {
|
41 |
margin: 0,
|
42 |
};
|
43 |
|
44 |
-
const
|
45 |
-
'https://passport.bilibili.com/h5-app/passport/login/scan?navhide=1&qrcode_key=ed99a307afd9a309098071fb9d9258a7&from=main-fe-header',
|
46 |
-
);
|
47 |
-
const qrErrorMsg = ref('');
|
48 |
-
const qrStatus = ref(QrStatus.EXPIRED);
|
49 |
-
const qrStatusText = computed(() => {
|
50 |
-
if (qrErrorMsg.value) return qrErrorMsg.value;
|
51 |
-
|
52 |
-
switch (qrStatus.value) {
|
53 |
-
case QrStatus.WAIT:
|
54 |
-
return '等待扫码';
|
55 |
-
case QrStatus.SCANNED:
|
56 |
-
return '已扫码,等待登录';
|
57 |
-
case QrStatus.EXPIRED:
|
58 |
-
return '二维码已过期,请刷新';
|
59 |
-
}
|
60 |
-
|
61 |
-
return '';
|
62 |
-
});
|
63 |
</script>
|
64 |
|
65 |
-
<style lang="less">
|
66 |
-
html,
|
67 |
-
body {
|
68 |
-
margin: 0;
|
69 |
-
padding: 0;
|
70 |
-
height: 100%;
|
71 |
-
}
|
72 |
-
|
73 |
-
body {
|
74 |
-
-webkit-font-smoothing: antialiased;
|
75 |
-
-moz-osx-font-smoothing: auto;
|
76 |
-
}
|
77 |
-
|
78 |
-
body,
|
79 |
-
#app,
|
80 |
-
.flex {
|
81 |
-
display: flex;
|
82 |
-
flex-direction: column;
|
83 |
-
align-items: center;
|
84 |
-
justify-content: center;
|
85 |
-
}
|
86 |
-
|
87 |
-
.text-center {
|
88 |
-
text-align: center;
|
89 |
-
}
|
90 |
-
|
91 |
-
.link {
|
92 |
-
color: rgb(0, 0, 238);
|
93 |
-
text-decoration: none;
|
94 |
-
}
|
95 |
-
|
96 |
.qrcode {
|
97 |
position: relative;
|
98 |
min-width: 196px;
|
@@ -107,4 +55,8 @@ body,
|
|
107 |
background-color: rgba(255, 255, 255, 0.85);
|
108 |
}
|
109 |
}
|
|
|
|
|
|
|
|
|
110 |
</style>
|
|
|
1 |
<template>
|
2 |
+
<div class="text-center no-select">
|
3 |
<h2>哔哩哔哩 cookie 获取工具</h2>
|
4 |
<p>
|
5 |
使用手机 APP 扫码登录后即可获取 cookie (<a
|
|
|
10 |
>)
|
11 |
</p>
|
12 |
</div>
|
13 |
+
<div class="qrcode flex no-select">
|
14 |
+
<QrCode v-if="state.url" :value="state.url" :options="qrCodeOption" />
|
15 |
+
<div v-if="state.status !== QrStatus.WAIT" class="qrcode__mask flex">
|
16 |
+
<CheckIcon v-if="state.status === QrStatus.SCANNED || state.status === QrStatus.SUCCESS" />
|
17 |
+
<RefreshBtn v-else @click="restart" />
|
18 |
</div>
|
19 |
</div>
|
20 |
+
<div class="text-center no-select">
|
21 |
+
<p>{{ getters.statusText }}</p>
|
22 |
+
</div>
|
23 |
+
<div class="cookie-box">
|
24 |
+
<CookieDisplay v-if="state.cookie" :value="state.cookie" />
|
25 |
</div>
|
26 |
</template>
|
27 |
|
28 |
<script setup lang="ts">
|
|
|
29 |
import QrCode from '@chenfengyuan/vue-qrcode';
|
30 |
import RefreshBtn from './components/RefreshBtn.vue';
|
31 |
+
import CookieDisplay from './components/CookieDisplay.vue';
|
32 |
import CheckIcon from './assets/icons/check_circle.svg';
|
33 |
+
import { useQrSSE, QrStatus } from './utils/qrSSE';
|
34 |
import type { QRCodeRenderersOptions } from 'qrcode';
|
35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
36 |
const qrCodeOption: QRCodeRenderersOptions = {
|
37 |
margin: 0,
|
38 |
};
|
39 |
|
40 |
+
const { state, getters, restart } = useQrSSE();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
41 |
</script>
|
42 |
|
43 |
+
<style scoped lang="less">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
44 |
.qrcode {
|
45 |
position: relative;
|
46 |
min-width: 196px;
|
|
|
55 |
background-color: rgba(255, 255, 255, 0.85);
|
56 |
}
|
57 |
}
|
58 |
+
|
59 |
+
.cookie-box {
|
60 |
+
min-height: 180px;
|
61 |
+
}
|
62 |
</style>
|
src/assets/icons/content_copy.svg
ADDED
|
src/components/CookieDisplay.vue
ADDED
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<template>
|
2 |
+
<div class="cookie">
|
3 |
+
<pre ref="pre" class="cookie__pre">{{ value }}</pre>
|
4 |
+
<div class="cookie__copy flex flex-row btn" @click="copy"><CopyIcon style="margin-right: 4px" />{{ text }}</div>
|
5 |
+
</div>
|
6 |
+
</template>
|
7 |
+
|
8 |
+
<script setup lang="ts">
|
9 |
+
import { ref } from 'vue';
|
10 |
+
import CopyIcon from '../assets/icons/content_copy.svg';
|
11 |
+
import { useTipText } from '../utils/tipText';
|
12 |
+
|
13 |
+
defineProps<{ value: string }>();
|
14 |
+
|
15 |
+
const pre = ref<HTMLElement>();
|
16 |
+
const { text, changeText } = useTipText('复制');
|
17 |
+
|
18 |
+
const copy = () => {
|
19 |
+
const selection = window.getSelection()!;
|
20 |
+
const range = window.document.createRange();
|
21 |
+
selection.removeAllRanges();
|
22 |
+
range.selectNode(pre.value!);
|
23 |
+
selection.addRange(range);
|
24 |
+
window.document.execCommand('copy');
|
25 |
+
selection.removeAllRanges();
|
26 |
+
changeText('已复制');
|
27 |
+
};
|
28 |
+
</script>
|
29 |
+
|
30 |
+
<style lang="less" scoped>
|
31 |
+
.cookie {
|
32 |
+
max-width: 596px;
|
33 |
+
border-radius: 4px;
|
34 |
+
background-color: rgba(0, 0, 0, 0.1);
|
35 |
+
overflow: hidden;
|
36 |
+
|
37 |
+
&__pre {
|
38 |
+
text-wrap: wrap;
|
39 |
+
word-break: break-all;
|
40 |
+
margin: 0;
|
41 |
+
padding: 8px;
|
42 |
+
}
|
43 |
+
|
44 |
+
&__copy {
|
45 |
+
border-top: 1px solid rgba(0, 0, 0, 0.2);
|
46 |
+
padding: 8px;
|
47 |
+
text-align: center;
|
48 |
+
cursor: pointer;
|
49 |
+
}
|
50 |
+
}
|
51 |
+
</style>
|
src/components/RefreshBtn.vue
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
<template>
|
2 |
-
<div class="refresh-btn flex"><RefreshIcon /></div>
|
3 |
</template>
|
4 |
|
5 |
<script setup lang="ts">
|
@@ -12,6 +12,9 @@ import RefreshIcon from '../assets/icons/refresh.svg';
|
|
12 |
height: 32px;
|
13 |
background-color: #fff;
|
14 |
border-radius: 50%;
|
15 |
-
|
|
|
|
|
|
|
16 |
}
|
17 |
</style>
|
|
|
1 |
<template>
|
2 |
+
<div class="refresh-btn btn flex"><RefreshIcon /></div>
|
3 |
</template>
|
4 |
|
5 |
<script setup lang="ts">
|
|
|
12 |
height: 32px;
|
13 |
background-color: #fff;
|
14 |
border-radius: 50%;
|
15 |
+
box-shadow:
|
16 |
+
0 1.25px 5px 0 rgba(0, 0, 0, 0.2),
|
17 |
+
0 0.3333px 1.5px 0 rgba(0, 0, 0, 0.04);
|
18 |
+
overflow: hidden;
|
19 |
}
|
20 |
</style>
|
src/main.ts
CHANGED
@@ -1,3 +1,4 @@
|
|
|
|
1 |
import { createApp } from 'vue';
|
2 |
import App from './App.vue';
|
3 |
|
|
|
1 |
+
import './style.less';
|
2 |
import { createApp } from 'vue';
|
3 |
import App from './App.vue';
|
4 |
|
src/style.less
ADDED
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
html,
|
2 |
+
body {
|
3 |
+
margin: 0;
|
4 |
+
padding: 0;
|
5 |
+
height: 100%;
|
6 |
+
}
|
7 |
+
|
8 |
+
body {
|
9 |
+
-webkit-font-smoothing: antialiased;
|
10 |
+
-moz-osx-font-smoothing: auto;
|
11 |
+
}
|
12 |
+
|
13 |
+
body,
|
14 |
+
#app,
|
15 |
+
.flex {
|
16 |
+
display: flex;
|
17 |
+
flex-direction: column;
|
18 |
+
align-items: center;
|
19 |
+
justify-content: center;
|
20 |
+
}
|
21 |
+
|
22 |
+
.flex-row {
|
23 |
+
flex-direction: row;
|
24 |
+
}
|
25 |
+
|
26 |
+
#app {
|
27 |
+
margin: 0 16px;
|
28 |
+
}
|
29 |
+
|
30 |
+
.text-center {
|
31 |
+
text-align: center;
|
32 |
+
}
|
33 |
+
|
34 |
+
.no-select {
|
35 |
+
user-select: none;
|
36 |
+
}
|
37 |
+
|
38 |
+
.link {
|
39 |
+
color: rgb(0, 0, 238);
|
40 |
+
text-decoration: none;
|
41 |
+
}
|
42 |
+
|
43 |
+
.btn {
|
44 |
+
position: relative;
|
45 |
+
cursor: pointer;
|
46 |
+
user-select: none;
|
47 |
+
|
48 |
+
&::after {
|
49 |
+
position: absolute;
|
50 |
+
content: '';
|
51 |
+
top: 0;
|
52 |
+
right: 0;
|
53 |
+
left: 0;
|
54 |
+
bottom: 0;
|
55 |
+
transition: background-color 0.1s;
|
56 |
+
}
|
57 |
+
|
58 |
+
&:hover::after {
|
59 |
+
background-color: rgba(0, 0, 0, 0.08);
|
60 |
+
}
|
61 |
+
|
62 |
+
&:active::after {
|
63 |
+
background-color: rgba(0, 0, 0, 0.12);
|
64 |
+
}
|
65 |
+
}
|
src/utils/qrSSE.ts
ADDED
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { computed, reactive } from 'vue';
|
2 |
+
|
3 |
+
enum SSEEvent {
|
4 |
+
GENERATE = 'generate',
|
5 |
+
POLL = 'poll',
|
6 |
+
END = 'end',
|
7 |
+
}
|
8 |
+
|
9 |
+
interface SSEGenerateData {
|
10 |
+
code: number;
|
11 |
+
msg: string;
|
12 |
+
url: string;
|
13 |
+
key: string;
|
14 |
+
}
|
15 |
+
|
16 |
+
interface SSEPollData {
|
17 |
+
code: number;
|
18 |
+
msg: string;
|
19 |
+
cookie?: string;
|
20 |
+
}
|
21 |
+
|
22 |
+
enum PollQrResultCode {
|
23 |
+
SUCCESS = 0,
|
24 |
+
EXPIRED = 86038,
|
25 |
+
NOT_CONFIRMED = 86090,
|
26 |
+
NOT_SCANNED = 86101,
|
27 |
+
}
|
28 |
+
|
29 |
+
export enum QrStatus {
|
30 |
+
WAIT,
|
31 |
+
SCANNED,
|
32 |
+
EXPIRED,
|
33 |
+
SUCCESS,
|
34 |
+
ERROR,
|
35 |
+
}
|
36 |
+
|
37 |
+
interface QrState {
|
38 |
+
url: string;
|
39 |
+
cookie: string;
|
40 |
+
errMsg: string;
|
41 |
+
status: QrStatus;
|
42 |
+
}
|
43 |
+
|
44 |
+
const defaultState = (): QrState => ({
|
45 |
+
url: '',
|
46 |
+
cookie: '',
|
47 |
+
errMsg: '',
|
48 |
+
status: QrStatus.WAIT,
|
49 |
+
});
|
50 |
+
|
51 |
+
class QrSSE {
|
52 |
+
private es!: EventSource;
|
53 |
+
|
54 |
+
public constructor(private state: QrState) {
|
55 |
+
this.start();
|
56 |
+
}
|
57 |
+
|
58 |
+
public restart() {
|
59 |
+
Object.assign(this.state, defaultState());
|
60 |
+
this.start();
|
61 |
+
}
|
62 |
+
|
63 |
+
public stop() {
|
64 |
+
if (!this.es) return;
|
65 |
+
this.es.close();
|
66 |
+
}
|
67 |
+
|
68 |
+
private start() {
|
69 |
+
this.stop();
|
70 |
+
this.es = new EventSource('/api/qr');
|
71 |
+
this.es.addEventListener(SSEEvent.GENERATE, this.handleMessage);
|
72 |
+
this.es.addEventListener(SSEEvent.POLL, this.handleMessage);
|
73 |
+
this.es.addEventListener(SSEEvent.END, this.handleEnd);
|
74 |
+
}
|
75 |
+
|
76 |
+
private handleMessage = ({ type, data }: MessageEvent<string>) => {
|
77 |
+
const obj = JSON.parse(data);
|
78 |
+
console.log(type, obj);
|
79 |
+
|
80 |
+
switch (type) {
|
81 |
+
case SSEEvent.POLL:
|
82 |
+
this.handlePoll(obj);
|
83 |
+
break;
|
84 |
+
case SSEEvent.GENERATE:
|
85 |
+
this.handleGenerate(obj);
|
86 |
+
break;
|
87 |
+
}
|
88 |
+
};
|
89 |
+
|
90 |
+
private handleEnd = ({ data }: MessageEvent<string>) => {
|
91 |
+
if (data) this.handleError(data);
|
92 |
+
this.stop();
|
93 |
+
};
|
94 |
+
|
95 |
+
private handleError(msg: string) {
|
96 |
+
this.state.errMsg = msg;
|
97 |
+
this.state.status = QrStatus.ERROR;
|
98 |
+
return;
|
99 |
+
}
|
100 |
+
|
101 |
+
private handleGenerate({ code, msg, url }: SSEGenerateData) {
|
102 |
+
if (code !== 0) {
|
103 |
+
this.handleError(msg);
|
104 |
+
return;
|
105 |
+
}
|
106 |
+
|
107 |
+
this.state.url = url;
|
108 |
+
this.state.status = QrStatus.WAIT;
|
109 |
+
}
|
110 |
+
|
111 |
+
private handlePoll({ code, msg, cookie }: SSEPollData) {
|
112 |
+
switch (code) {
|
113 |
+
case PollQrResultCode.NOT_SCANNED:
|
114 |
+
this.state.status = QrStatus.WAIT;
|
115 |
+
break;
|
116 |
+
case PollQrResultCode.NOT_CONFIRMED:
|
117 |
+
this.state.status = QrStatus.SCANNED;
|
118 |
+
break;
|
119 |
+
case PollQrResultCode.EXPIRED:
|
120 |
+
this.state.status = QrStatus.EXPIRED;
|
121 |
+
break;
|
122 |
+
case PollQrResultCode.SUCCESS:
|
123 |
+
this.state.status = QrStatus.SUCCESS;
|
124 |
+
this.state.cookie = cookie!;
|
125 |
+
break;
|
126 |
+
default:
|
127 |
+
this.handleError(msg);
|
128 |
+
break;
|
129 |
+
}
|
130 |
+
}
|
131 |
+
}
|
132 |
+
|
133 |
+
export const useQrSSE = () => {
|
134 |
+
const state = reactive<QrState>(defaultState());
|
135 |
+
const getters = reactive({
|
136 |
+
statusText: computed(() => {
|
137 |
+
switch (state.status) {
|
138 |
+
case QrStatus.WAIT:
|
139 |
+
return '等待扫码';
|
140 |
+
case QrStatus.SCANNED:
|
141 |
+
return '已扫码,等待登录';
|
142 |
+
case QrStatus.EXPIRED:
|
143 |
+
return '二维码已过期,请刷新';
|
144 |
+
case QrStatus.SUCCESS:
|
145 |
+
return '登录成功';
|
146 |
+
default:
|
147 |
+
return state.errMsg;
|
148 |
+
}
|
149 |
+
}),
|
150 |
+
});
|
151 |
+
|
152 |
+
const qrSSE = new QrSSE(state);
|
153 |
+
|
154 |
+
return { state, getters, restart: qrSSE.restart.bind(qrSSE) };
|
155 |
+
};
|
src/utils/tipText.ts
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ref } from 'vue';
|
2 |
+
|
3 |
+
export const useTipText = (initText: string) => {
|
4 |
+
const text = ref(initText);
|
5 |
+
let timer: NodeJS.Timeout | undefined;
|
6 |
+
|
7 |
+
const changeText = (tipText: string, timeout = 2000) => {
|
8 |
+
if (timer) clearTimeout(timer);
|
9 |
+
text.value = tipText;
|
10 |
+
timer = setTimeout(() => {
|
11 |
+
text.value = initText;
|
12 |
+
timer = undefined;
|
13 |
+
}, timeout);
|
14 |
+
};
|
15 |
+
|
16 |
+
return { text, changeText };
|
17 |
+
};
|
vercel.json
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"github": {
|
3 |
+
"silent": true
|
4 |
+
}
|
5 |
+
}
|