15 | onlyRouteEnter?: boolean
16 | },
17 | ) {
18 | let first = true
19 |
20 | const call = async () => {
21 | if (config?.before) config?.before()
22 | if ('syncCall' in cb) await cb.syncCall()
23 | else cb()
24 | if (config?.after) config?.after()
25 | }
26 |
27 | // 在每次判断路由为前进或第一次进入时加载数据
28 | onMounted(async () => {
29 | first = false
30 | await call()
31 | })
32 |
33 | const router = useRoute()
34 | onActivated(async () => {
35 | if (first && (router.meta.reload || (config?.isActive ? !config?.isActive?.value : false))) {
36 | await call()
37 | }
38 | })
39 |
40 | if (config?.onlyRouteEnter) {
41 | onBeforeRouteLeave(() => (first = true))
42 | } else {
43 | onDeactivated(() => (first = true))
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/composition/useFnLoading.ts:
--------------------------------------------------------------------------------
1 | import { ref } from 'vue'
2 |
3 | import type { AnyFunc } from 'src/types/utils'
4 | import type { Ref } from 'vue'
5 |
6 | export interface UseFnLoadingReturn extends AnyFunc
{
7 | loading: Ref
8 | }
9 |
10 | /** 包裹函数,返回函数执行loading */
11 | export function useLoadingFn(fn: AnyFunc
): UseFnLoadingReturn
{
12 | /**
13 | * loading相关的变量
14 | *
15 | * @description
16 | * 记录promise是因为要记录产生loading对应的promise,这样的话,假如两次快速调用函数,也不会因为第一次返回了就把loading写为false
17 | */
18 | const context = [null, ref(false)] as [unknown, Ref]
19 | const [, loading] = context
20 |
21 | function _fn(...args: P): R {
22 | loading.value = true
23 |
24 | /** 运行结果 */
25 | const evalResult = (() => {
26 | try {
27 | return fn(...args)
28 | } catch (e) {
29 | return Promise.reject(e)
30 | }
31 | })()
32 |
33 | // 记录context
34 | context[0] = evalResult
35 |
36 | Promise.resolve(evalResult).finally(() => {
37 | // 判断这个loading是不是自己的context的
38 | if (context[0] === evalResult) {
39 | // 如果是,清除conetxt记录避免内存泄露
40 | context[0] = null
41 | // 然后重置loading
42 | loading.value = false
43 | }
44 | // 如果loading是别人的,那就等别人去重置
45 | })
46 |
47 | return evalResult as R
48 | }
49 |
50 | _fn.loading = loading
51 |
52 | return _fn
53 | }
54 |
--------------------------------------------------------------------------------
/src/composition/useIsActivated.ts:
--------------------------------------------------------------------------------
1 | import { onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, toRaw } from 'vue'
2 | import { onBeforeRouteLeave, useRoute } from 'vue-router'
3 |
4 | import type { Ref } from 'vue'
5 |
6 | /** keep-alive的组件是否激活展示 */
7 | export function useIsActivated(): Ref {
8 | const isActivated = ref(true)
9 | // toRaw得到第一次挂载时所在路由
10 | const route = toRaw(useRoute())
11 |
12 | onMounted(() => (isActivated.value = true))
13 | onActivated(() => (isActivated.value = true))
14 |
15 | /**
16 | * 使用 onBeforeRouteLeave 是因为 onDeactivated 的触发 晚于 useRoute 的改变
17 | * 这会导致外部判断 isActivated 不够准确:
18 | * 即使往别的页面走了, `watch(() => [route, isActivated], () => {})` 也还是因为 isActivated 为 true 而 无法知道用户已经要走了
19 | */
20 | onBeforeRouteLeave((to, from, next) => {
21 | isActivated.value = to.name === route.name
22 |
23 | next()
24 | })
25 | onDeactivated(() => (isActivated.value = false))
26 | onBeforeUnmount(() => (isActivated.value = false))
27 |
28 | return isActivated
29 | }
30 |
--------------------------------------------------------------------------------
/src/composition/useMasonry.ts:
--------------------------------------------------------------------------------
1 | import MiniMasonry from 'minimasonry'
2 | import { onMounted, onBeforeUnmount, ref } from 'vue'
3 |
4 | import type { Ref } from 'vue'
5 |
6 | export interface UseMasonryAction {
7 | layout: () => void
8 | destroy: () => void
9 | }
10 |
11 | export function useMasonry(ele: Ref): UseMasonryAction {
12 | /** masonry实例 */
13 | const instance = ref(null)
14 |
15 | onMounted(() => {
16 | instance.value = new MiniMasonry({
17 | container: ele.value,
18 | surroundingGutter: false,
19 | gutter: 15,
20 | })
21 | })
22 |
23 | const actions: UseMasonryAction = {
24 | layout: () => instance.value?.layout(),
25 | destroy: () => instance.value?.destroy(),
26 | }
27 |
28 | onBeforeUnmount(actions.destroy)
29 |
30 | return actions
31 | }
32 |
--------------------------------------------------------------------------------
/src/composition/useMedia.ts:
--------------------------------------------------------------------------------
1 | import { ref, watch, onUnmounted } from 'vue'
2 |
3 | import type { Ref } from 'vue'
4 |
5 | /**
6 | * 返回当前屏幕是否匹配传入的query
7 | *
8 | * @example
9 | * ```
10 | * useMedia(ref('(min-width: 1080px)'))
11 | * ```
12 | */
13 | export function useMedia(query: Ref, defaultVal = false): Ref {
14 | /** 是否匹配 */
15 | const isMatch = ref(defaultVal)
16 |
17 | let mql: MediaQueryList = window.matchMedia(query.value)
18 |
19 | /** 因为 MediaQueryList change的时候不会触发vue的渲染,所以这里用一个callback来单独触发一次 */
20 | const matchChangeHandle = () => {
21 | isMatch.value = mql.matches
22 | }
23 | mql.addEventListener('change', matchChangeHandle)
24 |
25 | // query里变化了就重新监听
26 | watch(query, (nextQuery) => {
27 | /** 先清理 */
28 | mql.removeEventListener('change', matchChangeHandle)
29 | /** 重建 */
30 | mql = window.matchMedia(nextQuery)
31 | /** 重新监听 */
32 | mql.addEventListener('change', matchChangeHandle)
33 | })
34 |
35 | onUnmounted(() => {
36 | mql.removeEventListener('change', matchChangeHandle)
37 | })
38 |
39 | return isMatch
40 | }
41 |
--------------------------------------------------------------------------------
/src/composition/useMergeState.ts:
--------------------------------------------------------------------------------
1 | import { computed, ref, watch } from 'vue'
2 |
3 | import type { Ref, WritableComputedRef } from 'vue'
4 |
5 | /** @private */
6 | const nil = Symbol()
7 |
8 | /** @private */
9 | type Nil = typeof nil
10 |
11 | export interface UseMergeStateAction {
12 | reset(): void
13 | }
14 |
15 | /** 实现 有内部中间状态的 受控逻辑 */
16 | export function useMergeState(propsValue: Ref): [WritableComputedRef, UseMergeStateAction] {
17 | const state = ref(nil)
18 |
19 | const val = computed({
20 | get() {
21 | return state.value === nil ? propsValue.value : (state.value as T)
22 | },
23 | set(newVal) {
24 | state.value = newVal
25 | },
26 | })
27 |
28 | function reset() {
29 | state.value = nil
30 | }
31 |
32 | watch(propsValue, reset, { flush: 'sync' })
33 |
34 | return [val, { reset }]
35 | }
36 |
--------------------------------------------------------------------------------
/src/composition/useResizeObserver.ts:
--------------------------------------------------------------------------------
1 | import { onMounted, onUnmounted } from 'vue'
2 |
3 | import type { Ref } from 'vue'
4 |
5 | export interface UseResizeObserverAction {
6 | observer: ResizeObserver
7 | }
8 |
9 | /** 对某个元素初始化resize监视 */
10 | export function useResizeObserver(ref: Ref, cb: () => void): UseResizeObserverAction {
11 | const observer = new ResizeObserver(cb)
12 |
13 | onMounted(() => {
14 | observer.observe(ref.value)
15 | })
16 |
17 | onUnmounted(() => {
18 | observer.disconnect()
19 | })
20 |
21 | return { observer }
22 | }
23 |
--------------------------------------------------------------------------------
/src/composition/useTimeoutFn.ts:
--------------------------------------------------------------------------------
1 | import { computed, onDeactivated, onUnmounted, ref } from 'vue'
2 |
3 | import { NOOP } from 'src/const/empty'
4 |
5 | import type { AnyVoidFunc, AnyAsyncFunc, AnyFunc } from 'src/types/utils'
6 | import type { Ref } from 'vue'
7 |
8 | import { useLoadingFn } from './useFnLoading'
9 |
10 | /** 延时执行 */
11 | export interface UseTimeoutFnAction extends AnyAsyncFunc
> {
12 | /** 同步执行cb,不走延时 */
13 | syncCall: AnyFunc
14 | /** 手动取消 */
15 | cancel: AnyVoidFunc
16 | /** loading,从调用开始置为true(包括delay等待的时间),函数调用后置为false */
17 | loading: Ref
18 | }
19 |
20 | /** 延时执行配置项 */
21 | export interface UseTimeoutFnConfig {
22 | /** 自动取消,设置为 false 可以阻止自动取消行为 */
23 | cancelOnUnMount?: boolean
24 | }
25 |
26 | /**
27 | * cancel错误,方便业务判断是cb错误还是只是取消
28 | *
29 | * @example
30 | * ```js
31 | * const fn = useTimeoutFn(function () { throw new Error('test') })
32 | * fn().catch(e => e === CANCEL_ERR ? ( console.log('取消执行'); ) : ( console.log('执行错误'); ) )
33 | * ```
34 | */
35 | export const CANCEL_ERR = new Error('cancel')
36 |
37 | /**
38 | * onActivated回调延时
39 | *
40 | * @description
41 | * 这个延时是为了解决
42 | * **用户在快速后退页面时,会在 onActivated 周期产生大量无用请求**
43 | * 的问题
44 | */
45 | const DELAY_MS = 200
46 |
47 | /**
48 | * 延时执行
49 | *
50 | * @description
51 | * 当组件被 deactivate 或者 unmount 的时候就取消相关cb的执行计划
52 | *
53 | * @description
54 | * 重复调用时只有最后一次调用有效
55 | *
56 | * @example
57 | * ```js
58 | * const fn = useTimeoutFn(function () { fetch('baidu.com') });
59 | *
60 | * fn().catch(
61 | * e => e === CANCEL_ERR
62 | * ? ( console.log('取消执行') )
63 | * : ( console.log('执行错误') )
64 | * )
65 | * ```
66 | */
67 | export function useTimeoutFn(
68 | cb: AnyFunc
,
69 | delay: number = DELAY_MS,
70 | config?: UseTimeoutFnConfig,
71 | ): UseTimeoutFnAction
{
72 | let timeoutContext: NodeJS.Timeout | undefined
73 | let rejector: ((err?: unknown) => void) | undefined
74 | /** 是否有执行计划 */
75 | const scheduled = ref(false)
76 |
77 | /** 清理context相关变量 */
78 | function reset() {
79 | timeoutContext = undefined
80 | rejector = undefined
81 | scheduled.value = false
82 | }
83 |
84 | /** 给函数包裹上loading标志 */
85 | const _cb = useLoadingFn(cb)
86 |
87 | function fn(...args: P) {
88 | // 取消上一个执行计划,实现单发
89 | fn.cancel()
90 |
91 | const promise = new Promise((resolve, reject) => {
92 | scheduled.value = true
93 | rejector = reject
94 |
95 | timeoutContext = setTimeout(() => {
96 | try {
97 | resolve(_cb(...args))
98 | } catch (e) {
99 | reject(e)
100 | }
101 |
102 | reset()
103 | }, delay)
104 | })
105 |
106 | // 兜底错误,避免开发控制台一直报错
107 | // 一定要用then来兜底,用catch的话会使外部的catch无法触发从而无法监听取消事件或者cb执行错误事件
108 | promise.then(NOOP, NOOP)
109 |
110 | return promise
111 | }
112 |
113 | fn.syncCall = function (...args: P): R {
114 | scheduled.value = true
115 |
116 | const evalResult = _cb(...args)
117 | Promise.resolve(evalResult).finally(reset)
118 | return evalResult
119 | }
120 | fn.cancel = function () {
121 | timeoutContext && clearTimeout(timeoutContext)
122 | rejector && rejector(CANCEL_ERR)
123 | reset()
124 | }
125 |
126 | fn.loading = computed(() => scheduled.value || _cb.loading.value)
127 |
128 | // 组件卸载时取消执行
129 | onDeactivated(() => config?.cancelOnUnMount !== false && fn.cancel())
130 | onUnmounted(() => config?.cancelOnUnMount !== false && fn.cancel())
131 |
132 | // activated 时不管,这里只负责取消,业务自己确定调用时机,避免出现无法取消的多余执行
133 |
134 | return fn
135 | }
136 |
--------------------------------------------------------------------------------
/src/composition/useToNowRef.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs'
2 | import { toValue } from 'vue'
3 |
4 | import { parseTime, toNow } from 'src/utils/time'
5 |
6 | import type { Dayjs } from 'dayjs'
7 | import type { MaybeRefOrGetter } from 'vue'
8 |
9 | /**
10 | * 返回一个定时刷新的 'xx天前' 文案
11 | *
12 | * @example
13 | * ```ts
14 | * const book = ref<{ LastUpdateTime: Date }>({ LastUpdateTime: new Date() })
15 | * onMounted(async () => {
16 | * Promise.resolve().then(() => {
17 | * book.value.LastUpdateTime = new Date()
18 | * })
19 | * })
20 | *
21 | * const lastUpdateTimeSourceRef = computed(() => book.value.LastUpdateTime)
22 | * const lastUpdateTime = useToNow(lastUpdateTimeSourceRef)
23 | *
24 | * // 这样合起来写也行↓
25 | * // const lastUpdateTime = useToNow(() => book.value.LastUpdateTime)
26 | *
27 | * return { lastUpdateTime }
28 | * ```
29 | */
30 | export function useToNowRef(
31 | dateGetter: MaybeRefOrGetter,
32 | ): ComputedRef {
33 | const dateRef = computed(() => {
34 | const dateVal = toValue(dateGetter)
35 | if (!dateVal) {
36 | return null
37 | }
38 |
39 | return parseTime(dateVal)
40 | })
41 |
42 | /**
43 | * 最后一次更新 nowRef 对应的 dateRef 值
44 | *
45 | * @description
46 | * 新增这个值的用意是应对vue的组件复用;
47 | * 复用的组件不会重新跑setup也就不会重新构建nowRef,
48 | * 导致会出现前一帧时间还在正常更新,下一帧来了新的dateRef,与缓存中的nowRef去进行toNow调用了
49 | */
50 | const lastDateRef = shallowRef(unref(dateRef))
51 | const nowRef = shallowRef(dayjs())
52 |
53 | function refreshNowRef() {
54 | nowRef.value = dayjs()
55 | // 更新 nowRef 时也更新 lastDateRef
56 | // 直接赋值同一个对象,免掉对象内存申请
57 | lastDateRef.value = dateRef.value
58 | }
59 |
60 | // 刷新nowRef
61 | watchEffect((onClean) => {
62 | if (
63 | // 如果date传进来是空值,保证一次 nowRef 是最新的
64 | // 防止now是几千年前存下来的值
65 | !unref(dateRef) ||
66 | // 如果date和lastDate不同,更新一次nowRef
67 | // 防止now是几个小时前的值但新的date是个几分钟的值,变相出现未来值了
68 | // 这里故意使用引用全等,意在强调 lastDate 与 date 是公用一份内存(/一个对象)的
69 | unref(dateRef) !== unref(lastDateRef)
70 | ) {
71 | refreshNowRef()
72 | }
73 |
74 | // 注意先更新,后取值
75 | const date = unref(dateRef)
76 | const now = unref(nowRef)
77 |
78 | if (
79 | // 如果date传进来是空值,无需定时刷新
80 | // 无效时间无需刷新
81 | !date ||
82 | // 如果date跟现在不在同一天,不刷了,爱咋咋滴
83 | !date.isSame(now, 'day')
84 | ) {
85 | return
86 | }
87 |
88 | // 秒级别的差异,每秒刷新一次
89 | // => "x秒前"
90 | if (date.diff(now, 'second') < 60) {
91 | const timeout = setTimeout(refreshNowRef, 1_000 * 1)
92 |
93 | onClean(() => clearTimeout(timeout))
94 | return
95 | }
96 |
97 | // 分钟级别的差异,每分钟刷新一次
98 | // => "x分钟前"
99 | if (date.diff(now, 'minute') < 60) {
100 | const timeout = setTimeout(refreshNowRef, 1_000 * 60)
101 | onClean(() => clearTimeout(timeout))
102 | return
103 | }
104 |
105 | // 小时级别的差异,每半小时刷新一次
106 | // => "x小时前"
107 | const timeout = setTimeout(refreshNowRef, 1_000 * 60 * 30)
108 | onClean(() => clearTimeout(timeout))
109 | return
110 | })
111 |
112 | return computed(() => {
113 | // console.log('re-calc toNow', dateRef.value.format(), nowRef.value.format())
114 | return dateRef.value ? toNow(dateRef.value, { now: nowRef.value, notNegative: true }) : ''
115 | })
116 | }
117 |
--------------------------------------------------------------------------------
/src/const/empty.ts:
--------------------------------------------------------------------------------
1 | /** 空函数,占位用 */
2 | export function NOOP() {
3 | //
4 | }
5 |
--------------------------------------------------------------------------------
/src/const/index.ts:
--------------------------------------------------------------------------------
1 | /** 代表选项中的全部 */
2 | export const ALL_VALUE = 'ALL_VALUE'
3 |
--------------------------------------------------------------------------------
/src/const/provide.ts:
--------------------------------------------------------------------------------
1 | export const PROVIDE = {
2 | IMAGE_PREVIEW: Symbol(),
3 | }
4 |
--------------------------------------------------------------------------------
/src/css/app.scss:
--------------------------------------------------------------------------------
1 | // app global css in SCSS form
2 |
3 | .flex-align-center {
4 | display: flex;
5 | align-items: center;
6 | }
7 |
8 | .flex-space {
9 | flex: 1;
10 | }
11 |
12 | .mx-auto {
13 | margin-left: auto;
14 | margin-right: auto;
15 | }
16 |
17 | .cursor-pointer {
18 | cursor: pointer;
19 | }
20 |
21 | .m0 {
22 | margin: 0;
23 | }
24 |
25 | .p0 {
26 | margin: 0;
27 | }
28 |
29 | a {
30 | text-decoration: none;
31 | }
32 |
33 | body.desktop {
34 | &::-webkit-scrollbar {
35 | display: none;
36 | }
37 | }
38 |
39 | .text-opacity {
40 | opacity: 0.6;
41 | }
42 |
--------------------------------------------------------------------------------
/src/css/mixin.scss:
--------------------------------------------------------------------------------
1 | @mixin ellipsis($line) {
2 | display: -webkit-box;
3 | -webkit-box-orient: vertical;
4 | -webkit-line-clamp: $line;
5 | white-space: normal;
6 | overflow: hidden;
7 | text-overflow: ellipsis;
8 | word-break: break-all;
9 | }
10 |
--------------------------------------------------------------------------------
/src/css/quasar.variables.scss:
--------------------------------------------------------------------------------
1 | // Quasar SCSS (& Sass) Variables
2 | // --------------------------------------------------
3 | // To customize the look and feel of this app, you can override
4 | // the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
5 |
6 | // Check documentation for full list of Quasar variables
7 |
8 | // Your own variables (that are declared here) and Quasar's own
9 | // ones will be available out of the box in your .vue/.scss/.sass files
10 |
11 | // It's highly recommended to change the default colors
12 | // to match your app's branding.
13 | // Tip: Use the "Theme Builder" on Quasar's documentation website.
14 |
15 | $primary: #1976d2;
16 | $secondary: #26a69a;
17 | $accent: #9c27b0;
18 |
19 | $dark: #1d1d1d;
20 | $dark-page: #121212;
21 |
22 | $positive: #21ba45;
23 | $negative: #c10015;
24 | $info: #31ccec;
25 | $warning: #f2c037;
26 |
27 | // https://material.io/design/layout/responsive-layout-grid.html#breakpoints
28 | $breakpoint-xs: 599px !default;
29 | $breakpoint-sm: 959px !default;
30 | $breakpoint-md: 1263px !default;
31 | $breakpoint-lg: 1919px !default;
32 |
33 | $tooltip-mobile-padding: 6px 10px !default;
34 | $tooltip-mobile-fontsize: 10px !default;
35 |
36 | // 覆盖默认样式
37 | .q-notification {
38 | transition:
39 | transform 0.5s,
40 | opacity 0.5s !important;
41 | }
42 |
43 | .q-badge--floating {
44 | right: unset !important;
45 | left: 100% !important;
46 | transform: translateX(-50%);
47 | }
48 |
49 | .q-item__label--caption {
50 | color: #757575 !important;
51 | }
52 |
53 | div.q-tab-panels {
54 | background-color: inherit;
55 |
56 | .q-tab-panel {
57 | padding: 0;
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/declarations.d.ts:
--------------------------------------------------------------------------------
1 | declare module '@joplin/turndown'
2 | declare module '@joplin/turndown-plugin-gfm'
3 |
--------------------------------------------------------------------------------
/src/directives/longPress.ts:
--------------------------------------------------------------------------------
1 | import { createDirective } from 'src/utils/createDirective'
2 |
3 | /**
4 | * 长按
5 | *
6 | * @param {number} delay 长按多久算长按,单位ms,默认 100
7 | */
8 | export const longPress = createDirective({})
9 |
--------------------------------------------------------------------------------
/src/env.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace NodeJS {
2 | interface ProcessEnv {
3 | NODE_ENV: string
4 | VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined
5 | VUE_ROUTER_BASE: string | undefined
6 | /** Code runs in development mode */
7 | DEV: boolean
8 | /** Code runs in production mode */
9 | PROD: boolean
10 | /** Code runs in development mode or `--debug` flag was set for production mode */
11 | DEBUGGING: boolean
12 | /** Quasar CLI mode (spa, pwa, …) */
13 | MODE: string
14 |
15 | // 环境变量
16 | VUE_APP_NAME: string
17 | VUE_APP_TOKEN_EXP_TIME: string
18 | VUE_SESSION_TOKEN_VALIDITY: string
19 | VUE_CAPTCHA_SITE_KEY: string
20 | VUE_TRACE_SERVER: string
21 | VUE_COMMIT_SHA: string
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/global.d.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | interface Window {
3 | turnstile: any
4 | Sanitizer: any
5 | onloadTurnstileCallback: () => void
6 | onTelegramAuth: (user: any) => void
7 | }
8 | interface Element {
9 | setHTML: any
10 | }
11 | }
12 |
13 | export {}
14 |
--------------------------------------------------------------------------------
/src/pages/Announcement/Announcement.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
公告列表
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
23 |
24 |
25 | [{{ announcement.Create.format('YYYY-MM-DD') }}] {{ announcement.Title }}
26 |
27 |
28 | {{ announcement.PreviewContent }}
29 |
30 |
31 |
32 | {{ announcement.Before }}
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
85 |
86 |
91 |
--------------------------------------------------------------------------------
/src/pages/Announcement/AnnouncementDetail.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | [{{ announcement.Create.format('YYYY-MM-DD') }}] {{ announcement.Title }}
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
63 |
64 |
69 |
--------------------------------------------------------------------------------
/src/pages/Announcement/announcementFormat.ts:
--------------------------------------------------------------------------------
1 | import sanitizerHtml from 'src/utils/sanitizeHtml'
2 | import { parseTime } from 'src/utils/time'
3 |
4 | import { useToNowRef } from 'src/composition/useToNowRef'
5 |
6 | import type { Dayjs } from 'dayjs'
7 | import type { Announcement as _Announcement } from 'src/services/context/type'
8 | import type { Ref } from 'vue'
9 |
10 | export interface Announcement {
11 | Id: number
12 | Title: string
13 | Create: Dayjs
14 | Before: Ref
15 | Content: string
16 | PreviewContent: string
17 | }
18 |
19 | function getPreview(html: string): string {
20 | const div = document.createElement('div')
21 | div.innerHTML = sanitizerHtml(html)
22 | const text = div.textContent!.trim()
23 | div.remove()
24 | return text.length > 50 ? text.substring(0, 50) + '...' : text
25 | }
26 |
27 | export function announcementFormat(element: _Announcement): Announcement {
28 | return {
29 | Id: element.Id,
30 | Create: parseTime(element.CreateTime),
31 | Before: useToNowRef(() => element.CreateTime),
32 | PreviewContent: getPreview(element.Content),
33 | Content: element.Content,
34 | Title: element.Title,
35 | }
36 | }
37 |
38 | export function announcementListFormat(announcementList: _Announcement[]): Announcement[] {
39 | const re: Announcement[] = []
40 | announcementList.forEach((element) => {
41 | re.push(announcementFormat(element))
42 | })
43 | return re
44 | }
45 |
--------------------------------------------------------------------------------
/src/pages/Book/BookList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
35 |
36 |
37 |
38 |
126 |
127 |
134 |
--------------------------------------------------------------------------------
/src/pages/Book/BookRank.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/src/pages/Book/EditChapter.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | 保存
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/src/pages/Book/EditInfo.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
封面预览
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
简介
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | 保存
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
109 |
110 |
111 |
--------------------------------------------------------------------------------
/src/pages/Book/Read/history.ts:
--------------------------------------------------------------------------------
1 | import { debounce } from 'quasar'
2 |
3 | import { userReadPositionDB } from 'src/utils/storage/db'
4 |
5 | import { saveReadPosition } from 'src/services/book'
6 |
7 | import type { Ref } from 'vue'
8 |
9 | function findElementNode(node: Node): Element {
10 | return node.nodeType === Node.ELEMENT_NODE ? (node as Element) : findElementNode(node.parentNode!)
11 | }
12 |
13 | function readXPath(element: Element, context: Element = document.body): string {
14 | if (context === document.body) {
15 | /* eslint-disable */
16 | if (element.id !== '') {
17 | return '//*[@id="' + element.id + '"]'
18 | }
19 | }
20 | if (context && element === context) {
21 | return '//*'
22 | }
23 |
24 | let ix = 1,
25 | siblings = element.parentNode!.childNodes
26 |
27 | for (let i = 0, l = siblings.length; i < l; i++) {
28 | let sibling = siblings[i]
29 | if (sibling === element) {
30 | return readXPath(element.parentNode as Element, context) + '/' + element.tagName.toLowerCase() + '[' + ix + ']'
31 | } else if (sibling.nodeType === 1 && (sibling as Element).tagName === element.tagName) {
32 | ix++
33 | }
34 | }
35 | return ''
36 | }
37 |
38 | export function loadHistory(uid: number, BookId: number) {
39 | return userReadPositionDB.get(`${uid}_${BookId}`)
40 | }
41 |
42 | export async function saveHistory(
43 | uid: number,
44 | BookId: number,
45 | bookParam: {
46 | Id: number
47 | xpath: string
48 | },
49 | ) {
50 | userReadPositionDB.set(`${uid}_${BookId}`, {
51 | cid: bookParam.Id,
52 | xPath: bookParam.xpath,
53 | top: document.scrollingElement!.scrollTop,
54 | })
55 | await saveReadPosition({ Bid: BookId, Cid: bookParam.Id, XPath: bookParam.xpath })
56 | }
57 |
58 | export function scrollToHistory(dom: Element, xPath: string, offset: Ref) {
59 | try {
60 | let rst = document.evaluate(xPath, dom, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null)
61 | let target = rst.iterateNext() as Element
62 | if (target) {
63 | document.scrollingElement!.scrollTop = target.getBoundingClientRect().top - offset.value
64 | }
65 | } catch (e) {
66 | console.log(e)
67 | }
68 | }
69 |
70 | export async function syncReading(
71 | dom: Element,
72 | uid: Ref,
73 | bookParam: {
74 | BookId: Ref
75 | CId: Ref
76 | },
77 | offset: Ref,
78 | ) {
79 | let visibleDom: Element[] = []
80 | let doSync = debounce(async () => {
81 | let topTarget = visibleDom.reduce((res: { target: Element; rect: DOMRect } | null, target: Element) => {
82 | // target.style.background = null
83 | let rect = target.getBoundingClientRect()
84 | if (rect.top >= offset.value) {
85 | if (res) {
86 | if (rect.top < res.rect.top) {
87 | res = {
88 | target,
89 | rect,
90 | }
91 | }
92 | } else {
93 | res = {
94 | target,
95 | rect,
96 | }
97 | }
98 | }
99 | return res
100 | }, null)
101 | if (topTarget) {
102 | // topTarget.target.style.background = 'red'
103 | // console.log(topTarget.target, readXPath(topTarget.target))
104 | let xpath = readXPath(topTarget.target, dom)
105 | await saveHistory(uid.value, bookParam?.BookId.value, {
106 | Id: bookParam?.CId.value,
107 | xpath,
108 | })
109 | }
110 | }, 300)
111 | let io = new IntersectionObserver((entities) => {
112 | entities.forEach((entity) => {
113 | if (entity.target instanceof HTMLElement) {
114 | let domTarget = entity.target as HTMLElement
115 | domTarget.style.background = ''
116 | if (entity.isIntersecting) {
117 | visibleDom.push(domTarget)
118 | } else {
119 | visibleDom = visibleDom.filter((target) => target !== domTarget)
120 | }
121 | }
122 | doSync()
123 | })
124 | })
125 |
126 | let walker = document.createTreeWalker(dom, NodeFilter.SHOW_TEXT, (node) => {
127 | return node.nodeValue!.trim().length > 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
128 | })
129 | while (walker.nextNode()) {
130 | try {
131 | let dom = findElementNode(walker.currentNode)
132 | io.observe(dom)
133 | } catch (e) {
134 | console.log(e)
135 | }
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/src/pages/Collaborator/List.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
60 |
61 |
62 |
63 |
64 |
65 |
76 |
--------------------------------------------------------------------------------
/src/pages/Collaborator/components/Card.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
![]()
5 |
6 | {{ data.Job }}
7 |
8 |
9 |
10 |
{{ data.Title }}
11 |
{{ data.Description }}
12 |
13 |
14 |
15 |
16 |
42 |
43 |
46 |
47 |
108 |
--------------------------------------------------------------------------------
/src/pages/Collaborator/store/data.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from 'nanoid'
2 |
3 | import type { Card } from 'src/types/collaborator'
4 |
5 | const imgs: string[] = [
6 | 'https://img.acgdmzy.com:45112/images/2021/08/22/08579907e581.webp',
7 | 'https://i0.hdslb.com/bfs/archive/307afe2558b4bb3a4a655d284a47459b9c6cd3fa.jpg',
8 | 'https://i0.hdslb.com/bfs/bangumi/image/8b1657cc9ded02796ce317ff7e1fd36f2dc9a0bb.jpg',
9 | 'https://i0.hdslb.com/bfs/manga-static/1cecbe6033d31cc9a49f4c1df88258a0abf72e07.jpg',
10 | 'https://i0.hdslb.com/bfs/archive/ab734a5ec06f568c27ea2212bbfb5e22d31284ae.jpg',
11 | ]
12 |
13 | const mock = (): Card[] => {
14 | return new Array(40).fill('').map((_, idx) => {
15 | return {
16 | Id: nanoid(),
17 | Job: 'Epub',
18 | Avatar: imgs[idx % imgs.length],
19 | Title: nanoid(idx % 2 === 1 ? 40 : 6),
20 | Description: nanoid(idx % 2 === 1 ? 40 : 6),
21 | }
22 | })
23 | }
24 |
25 | /** 固化本地的贡献者列表数据 */
26 | export const collaborators = mock()
27 |
--------------------------------------------------------------------------------
/src/pages/Collaborator/store/index.ts:
--------------------------------------------------------------------------------
1 | import { defineStore } from 'pinia'
2 |
3 | import { collaborators } from './data'
4 |
5 | /** 贡献列表store */
6 | export const useCollaborators = defineStore('page.collaborator', {
7 | state() {
8 | return { collaborators }
9 | },
10 | })
11 |
--------------------------------------------------------------------------------
/src/pages/Community.vue:
--------------------------------------------------------------------------------
1 |
2 | 这是社区
3 |
4 |
5 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/pages/Forum/List/components/ForumList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | {{ forum.Title }}
12 |
13 | {{ forum.Description }}
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
37 |
38 |
39 |
40 |
72 |
73 |
78 |
--------------------------------------------------------------------------------
/src/pages/Forum/List/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
89 |
90 |
91 |
--------------------------------------------------------------------------------
/src/pages/History.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | 是否清空阅读历史
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
132 |
133 |
134 |
--------------------------------------------------------------------------------
/src/pages/Login/Index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/pages/Login/VueTurnstile.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/src/pages/MyShelf/components/NavBackToParentFolder.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
70 |
71 |
77 |
--------------------------------------------------------------------------------
/src/pages/MyShelf/components/RenameDialog.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 重命名为...
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
66 |
--------------------------------------------------------------------------------
/src/pages/MyShelf/components/ShelfBook.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
27 |
--------------------------------------------------------------------------------
/src/pages/MyShelf/components/ShelfCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
20 |
--------------------------------------------------------------------------------
/src/pages/MyShelf/components/ShelfFolder.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | {{ item.title }}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
{{ updateTime }}
37 |
38 |
39 |
40 |
41 |
88 |
89 |
123 |
--------------------------------------------------------------------------------
/src/pages/Test.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | 网站支持度测试
6 |
7 |
8 |
9 | {{ data.name }}
10 |
11 |
12 | {{ data.func() }}
13 |
14 |
15 |
16 |
17 |
18 |
19 |
47 |
48 |
53 |
--------------------------------------------------------------------------------
/src/router/index.ts:
--------------------------------------------------------------------------------
1 | import { defineRouter } from '#q-app/wrappers'
2 | import { nanoid } from 'nanoid'
3 | import { Notify } from 'quasar'
4 | import { createMemoryHistory, createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
5 |
6 | import { longTermToken, sessionToken } from 'src/utils/session'
7 |
8 | import type { HistoryState, RouteRecordNameGeneric } from 'vue-router'
9 |
10 | import routes from './routes'
11 |
12 | /*
13 | * If not building with SSR mode, you can
14 | * directly export the Router instantiation;
15 | *
16 | * The function below can be async too; either use
17 | * async/await or return a Promise which resolves
18 | * with the Router instance.
19 | */
20 | export default defineRouter(function (/* { store, ssrContext } */) {
21 | const keys: string[] = []
22 |
23 | const createHistory = process.env.SERVER
24 | ? createMemoryHistory
25 | : process.env.VUE_ROUTER_MODE === 'history'
26 | ? (baseUrl?: string) => {
27 | const history = createWebHistory(baseUrl)
28 | const _push = history.push
29 | const _replace = history.replace
30 | const setKey = (to: string, data?: HistoryState) => {
31 | if (!data) data = {}
32 | data['key'] = `[${to}] ${nanoid()}`
33 | keys.push(data['key'])
34 | return data
35 | }
36 | history.push = (to, data) => {
37 | data = setKey(to, data)
38 | _push(to, data)
39 | }
40 | history.replace = (to, data) => {
41 | data = setKey(to, data)
42 | _replace(to, data)
43 | }
44 | return history
45 | }
46 | : createWebHashHistory
47 |
48 | const Router = createRouter({
49 | scrollBehavior(to, from, savedPosition) {
50 | // Read 页面的滚动历史由页面自己处理
51 | if (to.name !== 'Read') {
52 | if (savedPosition) {
53 | return savedPosition
54 | } else {
55 | return { top: 0 }
56 | }
57 | }
58 | },
59 | routes,
60 |
61 | // Leave this as is and make changes in quasar.conf.js instead!
62 | // quasar.conf.js -> build -> vueRouterMode
63 | // quasar.conf.js -> build -> publicPath
64 | history: createHistory(process.env.VUE_ROUTER_BASE),
65 | })
66 |
67 | Router.beforeEach(async function (to) {
68 | if (to.params.authRedirect) {
69 | Notify.create({
70 | type: 'negative',
71 | timeout: 1500,
72 | position: 'bottom',
73 | message: '此操作必须登录,正在前往登录页面',
74 | })
75 | }
76 |
77 | // 显式声明不需要授权
78 | if (to.meta.requiresAuth === false) {
79 | return
80 | }
81 |
82 | // 检查有没有授权所需的材料
83 | if (sessionToken.get() || (await longTermToken.get())) {
84 | // 有材料就算过,授权失败等情况由其它地方保证
85 | return
86 | }
87 |
88 | if (!to.params.authRedirect) {
89 | Notify.create({
90 | type: 'negative',
91 | timeout: 1500,
92 | position: 'bottom',
93 | message: '此页面必须登录',
94 | })
95 | }
96 |
97 | // 没有授权材料,跳转到登录页
98 | return { name: 'Login', query: { from: encodeURIComponent(to.fullPath) } }
99 | })
100 |
101 | const readyRoute: RouteRecordNameGeneric[] = []
102 | Router.afterEach((to) => {
103 | const key = history.state['key']
104 | if (readyRoute.includes(to.name)) {
105 | if (key) {
106 | to.meta.reload = keys.findIndex((item) => item === key) === keys.length - 1
107 | } else {
108 | to.meta.reload = false
109 | }
110 | } else {
111 | readyRoute.push(to.name)
112 | to.meta.reload = true
113 | }
114 | })
115 |
116 | return Router
117 | })
118 |
--------------------------------------------------------------------------------
/src/router/routes.ts:
--------------------------------------------------------------------------------
1 | import type { RouteRecordRaw } from 'vue-router'
2 |
3 | const routes: RouteRecordRaw[] = [
4 | {
5 | path: '/',
6 | name: 'App',
7 | meta: { requiresAuth: false },
8 | redirect: { name: 'Home' },
9 | },
10 | {
11 | path: '/home',
12 | name: 'Home',
13 | meta: { requiresAuth: false },
14 | component: () => import('../pages/Home.vue'),
15 | },
16 | {
17 | path: '/announcement',
18 | name: 'Announcement',
19 | meta: { requiresAuth: false },
20 | component: () => import('../pages/Announcement/Announcement.vue'),
21 | },
22 | {
23 | path: '/announcement/detail/:id',
24 | name: 'AnnouncementDetail',
25 | props: true,
26 | meta: { requiresAuth: false },
27 | component: () => import('../pages/Announcement/AnnouncementDetail.vue'),
28 | },
29 | {
30 | path: '/book/list/:order/:page?',
31 | name: 'BookList',
32 | props: true,
33 | component: () => import('../pages/Book/BookList.vue'),
34 | },
35 | {
36 | path: '/book/info/:bid',
37 | name: 'BookInfo',
38 | props: true,
39 | component: () => import('../pages/Book/BookInfo.vue'),
40 | },
41 | {
42 | path: '/book/edit/:bid',
43 | name: 'EditBook',
44 | props: true,
45 | component: () => import('../pages/Book/EditInfo.vue'),
46 | },
47 | {
48 | path: '/book/edit/chapter/:bid/:sortNum',
49 | name: 'EditChapter',
50 | props: true,
51 | component: () => import('../pages/Book/EditChapter.vue'),
52 | },
53 | {
54 | path: '/book/rank/:type',
55 | name: 'BookRank',
56 | props: true,
57 | component: () => import('../pages/Book/BookRank.vue'),
58 | },
59 | {
60 | path: '/read/:bid/:sortNum',
61 | name: 'Read',
62 | props: true,
63 | component: () => import('../pages/Book/Read/Read.vue'),
64 | },
65 | {
66 | path: '/collaborator',
67 | name: 'Collaborator',
68 | meta: { requiresAuth: false },
69 | component: () => import('../pages/Collaborator/List.vue'),
70 | },
71 | {
72 | path: '/setting',
73 | name: 'Setting',
74 | meta: { requiresAuth: false },
75 | component: () => import('../pages/Setting.vue'),
76 | },
77 | {
78 | path: '/user/profile',
79 | name: 'UserProfile',
80 | component: () => import('../pages/User/Profile.vue'),
81 | },
82 | {
83 | path: '/user/publish',
84 | name: 'UserPublish',
85 | component: () => import('../pages/User/Publish.vue'),
86 | },
87 | {
88 | path: '/user/bookEditor/:bookId',
89 | name: 'UserBookEditor',
90 | props: true,
91 | component: () => import('../pages/User/BookEditor.vue'),
92 | },
93 | {
94 | path: '/test',
95 | name: 'Test',
96 | meta: { requiresAuth: false },
97 | component: () => import('../pages/Test.vue'),
98 | },
99 | {
100 | path: '/',
101 | meta: { requiresAuth: false },
102 | component: () => import('../pages/Login/Index.vue'),
103 | children: [
104 | {
105 | path: 'login',
106 | name: 'Login',
107 | meta: { requiresAuth: false },
108 | component: () => import('../pages/Login/Login.vue'),
109 | },
110 | {
111 | path: 'reset',
112 | name: 'Reset',
113 | meta: { requiresAuth: false },
114 | component: () => import('../pages/Login/Reset.vue'),
115 | },
116 | {
117 | path: 'register',
118 | name: 'Register',
119 | meta: { requiresAuth: false },
120 | component: () => import('../pages/Login/Register.vue'),
121 | },
122 | ],
123 | },
124 | {
125 | path: '/my-shelf/:folderID*',
126 | name: 'MyShelf',
127 | // 书架需要获取书本信息,书本信息接口是一个授权接口
128 | // meta: { requiresAuth: false },
129 | component: () => import('../pages/MyShelf/List.vue'),
130 | },
131 | {
132 | path: '/history',
133 | name: 'History',
134 | component: () => import('../pages/History.vue'),
135 | },
136 | {
137 | path: '/search/:keyWords?',
138 | name: 'Search',
139 | props: true,
140 | component: () => import('../pages/Search.vue'),
141 | },
142 | {
143 | path: '/forum/list',
144 | name: 'ForumList',
145 | props: true,
146 | component: () => import('../pages/Forum/List/index.vue'),
147 | },
148 | {
149 | path: '/forum/:id',
150 | name: 'Forum',
151 | props: true,
152 | component: () => import('../pages/Forum/index.vue'),
153 | },
154 | ]
155 |
156 | export default routes
157 |
--------------------------------------------------------------------------------
/src/services/apiServer.ts:
--------------------------------------------------------------------------------
1 | import { useStorage } from '@vueuse/core'
2 |
3 | // 第一个就是默认的
4 | const apiServerOptions = [
5 | {
6 | label: 'lightnovel.life',
7 | value: 'https://api.lightnovel.life',
8 | },
9 | ]
10 |
11 | if (process.env.DEV) {
12 | apiServerOptions.unshift({
13 | label: '开发服务器',
14 | value: 'http://localhost:5000',
15 | })
16 | }
17 |
18 | const apiServer = useStorage(
19 | (process.env.VUE_APP_NAME || 'LightNovelShelf') + '_Api_Server_V5',
20 | apiServerOptions[0].value,
21 | )
22 |
23 | export { apiServer, apiServerOptions }
24 |
--------------------------------------------------------------------------------
/src/services/book/index.ts:
--------------------------------------------------------------------------------
1 | import type { SaveReadPositionRequest } from './types'
2 |
3 | import * as Types from './types'
4 | import { requestWithSignalr } from '../internal/request'
5 |
6 | export { Types as BookServicesTypes }
7 |
8 | /** 获取书籍列表 */
9 | export function getBookList(param: Types.GetBookListRequest) {
10 | return requestWithSignalr('GetBookListBinary', param)
11 | }
12 | /** 获取书籍信息 */
13 | export function getBookInfo(bid: number) {
14 | return requestWithSignalr('GetBookInfo', bid)
15 | }
16 | /** 保存阅读位置 */
17 | export function saveReadPosition(param: SaveReadPositionRequest) {
18 | return requestWithSignalr('SaveReadPosition', param)
19 | }
20 | /** 获取阅读位置 */
21 | export function getReadPosition(bid: number) {
22 | return requestWithSignalr('GetReadPosition', bid)
23 | }
24 | /** 从一批id获取书籍列表 */
25 | export function getBookListByIds(ids: number[]) {
26 | // 校验数量,接口限制一次最多24本
27 | if (process.env.QUASAR_ELECTRON_PRELOAD_EXTENSION) {
28 | if (ids.length > 24) {
29 | throw new Error('单次批量操作最多24本')
30 | }
31 | }
32 | return requestWithSignalr('GetBookListByIds', ids)
33 | }
34 | /** 最大并行数量 */
35 | getBookListByIds.MAX_CONCURRENT = 24
36 |
37 | /** 取最新的6本书,无需登录 */
38 | export function getLatestBookList(param: Types.GetBookListRequest) {
39 | return requestWithSignalr('GetLatestBookListBinary', param)
40 | }
41 |
42 | /** 取最近的排行榜 */
43 | export function getRank(days: number) {
44 | return requestWithSignalr('GetRankBinary', days)
45 | }
46 |
47 | /** 编辑书籍信息 */
48 | export function editBook(request: Types.EditBookRequest) {
49 | return requestWithSignalr('EditBook', request)
50 | }
51 |
52 | /** 取编辑用的书籍信息 */
53 | export function getBookEditInfo(bid: number) {
54 | return requestWithSignalr('GetBookEditInfo', bid)
55 | }
56 |
57 | /** 删除书籍 */
58 | export function deleteBook(bid: number) {
59 | return requestWithSignalr('DeleteBook', bid)
60 | }
61 |
62 | /** 设置书籍 */
63 | export function setBookSetting(request: Types.SetBookSetting) {
64 | return requestWithSignalr('SetBookSetting', request)
65 | }
66 |
67 | export function getBookSetting(request: number) {
68 | return requestWithSignalr('GetBookSetting', request)
69 | }
70 |
--------------------------------------------------------------------------------
/src/services/book/types.ts:
--------------------------------------------------------------------------------
1 | import type { ListResult } from '../types'
2 |
3 | export interface BookInList {
4 | Id: number
5 | Cover: string
6 | Placeholder?: string
7 | // TODO: 走了二进制解码后自动转Date对象的特性丢失了,就是一个ISO 8601的日期
8 | LastUpdateTime: Date
9 | UserName: string
10 | Title: string
11 | Level?: number
12 | InteriorLevel?: number
13 | Category?: {
14 | ShortName: string
15 | Name: string
16 | Color: string
17 | }
18 | }
19 |
20 | export interface GetBookListRes extends ListResult {}
21 | interface ChapterInfo {
22 | Title: string
23 | Id: number
24 | }
25 | export interface GetBookInfoRes {
26 | Book: {
27 | Arthur: string
28 | Category: any
29 | Chapter: ChapterInfo[]
30 | Id: number
31 | Cover: string
32 | Placeholder?: string
33 | ExtraInfo: any
34 | Introduction: string
35 | Author: string
36 | LastUpdate: string
37 | LastUpdateTime: Date
38 | CreatedTime: Date
39 | Likes: number
40 | Title: string
41 | CanEdit: boolean
42 | User: {
43 | Id: number
44 | Avatar: string
45 | UserName: string
46 | }
47 | Views: number
48 | }
49 | ReadPosition: any
50 | }
51 |
52 | export interface GetBookListRequest {
53 | Page?: number
54 | Size?: number
55 | KeyWords?: string
56 | Order?: 'new' | 'view' | 'latest'
57 | IgnoreJapanese?: boolean
58 | IgnoreAI?: boolean
59 | }
60 |
61 | export interface SaveReadPositionRequest {
62 | Bid: number
63 | Cid: number
64 | XPath: string
65 | }
66 |
67 | export interface EditBookRequest {
68 | Bid: number
69 | Cover: string
70 | Title: string
71 | Author: string
72 | Introduction: string
73 | // 分类ID
74 | CategoryId: number
75 | }
76 |
77 | export interface SetBookSetting {
78 | Bid: number
79 | Settings: Record
80 | }
81 |
--------------------------------------------------------------------------------
/src/services/chapter/index.ts:
--------------------------------------------------------------------------------
1 | import { requestWithSignalr } from 'src/services/internal/request'
2 |
3 | import type * as Types from './types'
4 |
5 | /** 获取章节内容信息 */
6 | export function getChapterContent(request: Types.GetChapterContentRequest) {
7 | return requestWithSignalr('GetChapterContentBinary', request)
8 | }
9 |
10 | export function editChapterContent(request: Types.EditChapterContentRequest) {
11 | return requestWithSignalr('EditChapterContent', request)
12 | }
13 |
14 | export function getChapterEditInfo(request: Types.EditChapterContentRequest) {
15 | return requestWithSignalr('GetChapterEditInfo', request)
16 | }
17 |
18 | export function createNewChapter(request: Types.EditChapterContentRequest) {
19 | return requestWithSignalr('CreateNewChapter', request)
20 | }
21 |
22 | export function deleteChapter(request: Types.DeleteChapterRequest) {
23 | return requestWithSignalr('DeleteChapter', request)
24 | }
25 |
26 | export function changeChapterSort(request: Types.ChangeChapterSortRequest) {
27 | return requestWithSignalr('ChangeChapterSort', request)
28 | }
29 |
--------------------------------------------------------------------------------
/src/services/chapter/types.ts:
--------------------------------------------------------------------------------
1 | export interface GetChapterContentRequest {
2 | Bid: number
3 | SortNum: number
4 | Convert?: 't2s' | 's2t' | null | undefined
5 | }
6 |
7 | interface GetChapterEditInfoBySortNum {
8 | BookId?: number
9 | SortNum?: number
10 | }
11 |
12 | interface GetChapterEditInfoByCid {
13 | Cid?: number
14 | }
15 |
16 | export type GetChapterEditInfo = GetChapterEditInfoBySortNum | GetChapterEditInfoByCid
17 |
18 | interface EditChapterContentRequestBySortNum extends GetChapterEditInfoBySortNum {
19 | Content?: string
20 | Title?: string
21 | }
22 |
23 | interface EditChapterContentRequestByCid extends GetChapterEditInfoByCid {
24 | Content?: string
25 | Title?: string
26 | }
27 |
28 | export type EditChapterContentRequest = EditChapterContentRequestBySortNum | EditChapterContentRequestByCid
29 |
30 | export interface DeleteChapterRequest {
31 | BookId: number
32 | SortNum: number
33 | }
34 |
35 | export interface ChangeChapterSortRequest {
36 | BookId: number
37 | OldSortNum: number
38 | NewSortNum: number
39 | }
40 |
--------------------------------------------------------------------------------
/src/services/comment/index.ts:
--------------------------------------------------------------------------------
1 | import { requestWithSignalr } from 'src/services/internal/request'
2 |
3 | import type { GetComment, PostComment } from './types'
4 |
5 | import { CommentType } from './types'
6 |
7 | /** 评论 */
8 | export function postComment(req: PostComment.Request) {
9 | return requestWithSignalr('PostComment', req)
10 | }
11 |
12 | /** 回复评论 */
13 | export function replyComment(req: PostComment.Request) {
14 | return requestWithSignalr('ReplyComment', req)
15 | }
16 |
17 | /** 获取评论 */
18 | export function getComment(req: GetComment.Request) {
19 | return requestWithSignalr('GetComment', req)
20 | }
21 |
22 | /** 删除评论 */
23 | export function deleteComment(id: number) {
24 | return requestWithSignalr('DeleteComment', id)
25 | }
26 |
--------------------------------------------------------------------------------
/src/services/comment/types.ts:
--------------------------------------------------------------------------------
1 | export enum CommentType {
2 | Book = 'Book',
3 | Announcement = 'Announcement',
4 | }
5 |
6 | export namespace PostComment {
7 | export interface Request {
8 | Type: CommentType
9 | Id: number
10 | Content: string
11 | ReplyId?: number | undefined | null
12 | ParentId?: number
13 | }
14 | }
15 |
16 | export namespace GetComment {
17 | export interface Request {
18 | Type: CommentType
19 | Id: number
20 | Page: number
21 | }
22 |
23 | export interface Response {
24 | Id: number
25 | Type: CommentType
26 | Page: number
27 | TotalPages: number
28 | Users: { [key: string]: any }
29 | Commentaries: { [key: string]: any }
30 | Data: { Id: number; Reply: number[] }[]
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/services/context/index.ts:
--------------------------------------------------------------------------------
1 | import { requestWithSignalr } from 'src/services/internal/request'
2 |
3 | import type { GetAnnouncementDetail, GetAnnouncementList, OnlineInfo } from 'src/services/context/type'
4 | import type { Card } from 'src/types/collaborator'
5 |
6 | /** 获取贡献者列表 */
7 | export function getCollaboratorList() {
8 | return requestWithSignalr('GetCollaboratorList')
9 | }
10 |
11 | export function getOnlineInfo() {
12 | return requestWithSignalr('GetOnlineInfo')
13 | }
14 |
15 | export function getAnnouncementList(request: GetAnnouncementList.Request) {
16 | return requestWithSignalr('GetAnnouncementListBinary', request)
17 | }
18 |
19 | export function getAnnouncementDetail(request: GetAnnouncementDetail.Request) {
20 | return requestWithSignalr('GetAnnouncementDetail', request)
21 | }
22 |
23 | export function getBanInfoList() {
24 | return requestWithSignalr('GetBanInfoList')
25 | }
26 |
--------------------------------------------------------------------------------
/src/services/context/type.ts:
--------------------------------------------------------------------------------
1 | import type { ListResult } from 'src/services/types'
2 |
3 | export interface OnlineInfo {
4 | OnlineCount: number
5 | MaxOnline: number
6 | DayCount: number
7 | DayRegister: number
8 | }
9 |
10 | export interface Announcement {
11 | Id: number
12 | Title: string
13 | CreateTime: Date | string
14 | Content: string
15 | }
16 |
17 | export namespace GetAnnouncementList {
18 | export interface Request {
19 | Page: number
20 | Size: number
21 | }
22 | export type Response = ListResult
23 | }
24 |
25 | export namespace GetAnnouncementDetail {
26 | export interface Request {
27 | Id: number
28 | }
29 | export type Response = Announcement
30 | }
31 |
--------------------------------------------------------------------------------
/src/services/forum/index.ts:
--------------------------------------------------------------------------------
1 | import { requestWithSignalr } from 'src/services/internal/request'
2 |
3 | import type * as Types from './types'
4 |
5 | export async function getForumList(req: Types.GetForumList.Request) {
6 | return requestWithSignalr('GetForumList', req)
7 | }
8 |
9 | export async function getForumInfo(req: Types.GetForumInfo.Request) {
10 | return requestWithSignalr('GetForumInfo', req)
11 | }
12 |
--------------------------------------------------------------------------------
/src/services/forum/types.ts:
--------------------------------------------------------------------------------
1 | import type { ListResult } from '../types'
2 |
3 | export enum ForumType {
4 | Anime = 'Anime',
5 | Comic = 'Comic',
6 | Game = 'Game',
7 | Novel = 'Novel',
8 | Website = 'Website',
9 | }
10 |
11 | export namespace GetForumList {
12 | export interface Request {
13 | Page: number
14 | Size: number
15 | ForumType: ForumType
16 | }
17 | export type Response = ListResult
18 | }
19 |
20 | export namespace GetForumInfo {
21 | export interface Request {
22 | Id: number
23 | }
24 | export type Response = any
25 | }
26 |
--------------------------------------------------------------------------------
/src/services/internal/ServerError.ts:
--------------------------------------------------------------------------------
1 | export class ServerError extends Error {
2 | public override readonly name = 'ServerError'
3 |
4 | constructor(
5 | public override readonly message = '未知错误',
6 | public readonly status: number = 500,
7 | ) {
8 | super(message)
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/services/internal/readme:
--------------------------------------------------------------------------------
1 | 内部抽象
2 |
3 | 不建议业务直接使用这个文件夹内的东西
4 |
--------------------------------------------------------------------------------
/src/services/internal/request/createRequestQueue.ts:
--------------------------------------------------------------------------------
1 | import { RateLimitQueue } from 'src/utils/rateLimitQueue'
2 |
3 | /**
4 | * 速度限制队列
5 | *
6 | * @description
7 | * 服务器原始设置的最高速率时 每5秒10个请求
8 | * 考虑到浏览器的计时不稳定等可能性,这里将相关数值缩放一次,避免意外
9 | */
10 | export function createRequestQueue() {
11 | return new RateLimitQueue(10 - 1, 5 * 1000 * 1.1)
12 | // return new RateLimitQueue(1, 5 * 1000)
13 | }
14 |
15 | export const queue = createRequestQueue()
16 |
--------------------------------------------------------------------------------
/src/services/internal/request/fetch.ts:
--------------------------------------------------------------------------------
1 | import { stringifyQuery } from 'vue-router'
2 |
3 | import { getErrMsg } from 'src/utils/getErrMsg'
4 | import { sessionToken } from 'src/utils/session'
5 |
6 | import { ServerError } from 'src/services/internal/ServerError'
7 | import { RequestMethod } from 'src/services/types'
8 | import { getSessionToken } from 'src/services/utils'
9 |
10 | import type { RequestConfig } from 'src/services/types'
11 |
12 | import { queue } from './createRequestQueue'
13 | import { getVisitorId } from './getVisitorId'
14 |
15 | async function requestWithFetch(
16 | url: string,
17 | options: RequestConfig = {},
18 | ): Promise {
19 | const visitorId = await getVisitorId
20 | // 补全默认值; 项目里绝大部分接口都是POST接口所以默认post了
21 | options.method = options.method ?? RequestMethod.POST
22 |
23 | const fetchOpt: RequestInit = {
24 | method: options.method,
25 | }
26 | const headers = new Headers()
27 | headers.append('Accept', 'application/json')
28 | headers.append('id', visitorId)
29 |
30 | // 简化payload声明
31 | // get就只有param(浏览器发出请求时也会忽略get请求的body)
32 | // post就只有body(规定post请求不支持拼接参数到url上,要拼业务自己拼)
33 | switch (options.method) {
34 | case RequestMethod.GET: {
35 | // 由业务自己保证 payload 可以被序列化到url
36 | /** 请求参数 */
37 | const queryStr = stringifyQuery(options.payload as any)
38 | // 确定请求参数不为空再执行拼接操作
39 | if (queryStr) {
40 | const haveSearch = url.includes('?')
41 | if (haveSearch) {
42 | url += '&'
43 | } else {
44 | url += '?'
45 | }
46 |
47 | url += queryStr
48 | }
49 | break
50 | }
51 | case RequestMethod.POST: {
52 | if (options.payload instanceof FormData) {
53 | headers.append('Content-Type', 'multipart/form-data')
54 | fetchOpt.body = options.payload
55 | } else {
56 | headers.append('Content-Type', 'application/json')
57 | fetchOpt.body = JSON.stringify(options.payload)
58 | }
59 | break
60 | }
61 | default: {
62 | throw new Error(`unknown request method: ${options.method}`)
63 | }
64 | }
65 |
66 | if (options.signal) {
67 | fetchOpt.signal = options.signal
68 | }
69 |
70 | fetchOpt.headers = headers
71 |
72 | if (options.auth) {
73 | const token = await getSessionToken()
74 | if (token) {
75 | fetchOpt.headers.append('Authorization', `Bearer ${token}`)
76 | }
77 | }
78 |
79 | const res = await fetch(url, fetchOpt)
80 |
81 | /** 统一做json解码,非json的请求出现之后再考虑适配 */
82 | const content = await res.json()
83 |
84 | if (res.ok) {
85 | const { Success, Response, Status, Msg } = content
86 |
87 | if (Success) {
88 | return Response
89 | } else {
90 | throw new ServerError(Msg, Status)
91 | }
92 | }
93 |
94 | throw new Error(getErrMsg(content))
95 | }
96 |
97 | const requestWithFetchInRateLimit = ((...args) => {
98 | return queue.add(() => requestWithFetch(...args))
99 | }) as typeof requestWithFetch
100 |
101 | export { requestWithFetchInRateLimit as requestWithFetch }
102 |
--------------------------------------------------------------------------------
/src/services/internal/request/getVisitorId.ts:
--------------------------------------------------------------------------------
1 | import FingerprintJS from '@fingerprintjs/fingerprintjs'
2 |
3 | export const getVisitorId = new Promise((resolve, reject) => {
4 | FingerprintJS.load().then((fp) => fp.get().then((result) => resolve(result.visitorId)))
5 | })
6 |
--------------------------------------------------------------------------------
/src/services/internal/request/index.ts:
--------------------------------------------------------------------------------
1 | export { requestWithSignalr, subscribeWithSignalr, rebootSignalr } from './signalr'
2 | export { requestWithFetch } from './fetch'
3 |
--------------------------------------------------------------------------------
/src/services/internal/request/signalr/RetryPolicy.ts:
--------------------------------------------------------------------------------
1 | import type { IRetryPolicy, RetryContext } from '@microsoft/signalr/src/IRetryPolicy'
2 |
3 | export class RetryPolicy implements IRetryPolicy {
4 | nextRetryDelayInMilliseconds(retryContext: RetryContext) {
5 | switch (retryContext.previousRetryCount) {
6 | case 0:
7 | return 0
8 | case 1:
9 | return 5000
10 | case 2:
11 | return 10000
12 | case 3:
13 | return 20000
14 | default:
15 | return 30000
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/services/internal/request/signalr/cache.ts:
--------------------------------------------------------------------------------
1 | import { ref } from 'vue'
2 |
3 | import { signalrCacheDB } from 'src/utils/storage/db'
4 |
5 | /** 最后一次返回的响应,目前用于监听cache使用情况 */
6 | export const lastResponseCache = ref | null>(null)
7 |
8 | /** 查询cache返回结果 */
9 | export async function tryResponseFromCache(
10 | url: string,
11 | ...data: Data
12 | ): Promise {
13 | const key = JSON.stringify({ url, data })
14 | const val = await signalrCacheDB.get(key)
15 | if (val) {
16 | // 每次DB.get拿到的数据都是引用不相等的
17 | // await cacheDB.get('test') !== await cacheDB.get('test')
18 | // 所以直接赋值就能让外部感知值已经修改过
19 | lastResponseCache.value = val as Promise
20 | return val as Promise
21 | }
22 |
23 | return Promise.reject('no found')
24 | }
25 |
26 | /** 更新对应url的cache */
27 | export function updateResponseCache(
28 | url: string,
29 | res: Res,
30 | ...data: Data
31 | ): void {
32 | const key = JSON.stringify({ url, data })
33 | signalrCacheDB.set(key, res)
34 | }
35 |
--------------------------------------------------------------------------------
/src/services/internal/request/signalr/inspector.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | enum RecordTypeEnum {
3 | INIT = 'INIT',
4 | SENT = 'SENT',
5 | REVICE = 'REVICE',
6 | SUCCESS = 'SUCCESS',
7 | FAIL = 'FAIL',
8 | }
9 |
10 | interface RecordItemAdditionData {
11 | message?: string
12 | data?: T
13 | }
14 |
15 | interface RecordItem {
16 | type: RecordTypeEnum
17 | when: number
18 | addition?: RecordItemAdditionData
19 | }
20 |
21 | export class SignalrInspector {
22 | public readonly TYPE_ENUM = RecordTypeEnum
23 | static readonly now = () => performance.now()
24 |
25 | private records: RecordItem[] = []
26 |
27 | constructor(
28 | public readonly url: string,
29 | public readonly params: unknown[],
30 | ) {
31 | this.records.push({ type: RecordTypeEnum.INIT, when: SignalrInspector.now() })
32 | }
33 |
34 | public add = (type: RecordTypeEnum, addition?: RecordItemAdditionData) => {
35 | this.records.push({ type, when: SignalrInspector.now(), addition })
36 | }
37 |
38 | public flush = ({ clear = true }: { clear?: boolean } = {}) => {
39 | if (process.env.DEV && process.env.VUE_TRACE_SERVER) {
40 | const groupName = `signalr request data trace: '${this.url}'`
41 | console.groupCollapsed(groupName)
42 | let lastRecord: RecordItem | null = null
43 | this.records.forEach((record) => {
44 | if (lastRecord) {
45 | console.log('--->', `${record.when - lastRecord.when}ms`)
46 | }
47 |
48 | if (record.addition) {
49 | const { message, data } = record.addition
50 | const groupName = message ? `${record.type}: ${message}` : `${record.type}`
51 |
52 | console.groupCollapsed(groupName)
53 | console.log(data)
54 | console.groupEnd()
55 | } else {
56 | console.log(`${record.type}`)
57 | }
58 |
59 | lastRecord = record
60 | })
61 |
62 | if (clear) {
63 | this.records = []
64 | }
65 |
66 | console.groupEnd()
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/services/path/index.ts:
--------------------------------------------------------------------------------
1 | import { apiServer } from 'src/services/apiServer'
2 |
3 | /**
4 | * 路由表
5 | *
6 | * @public
7 | * @description
8 | * Q: 为什么要汇总这样一份全量、不带拼接的路由表?
9 | * A: 方便查看bug报告时用单子里的url直接快速反查到节点,然后一路F12找到业务调用点;
10 | * 假设经过多次拼接,则需要慢慢拆解业务,找被划分过的文件,看是谁调用的
11 | *
12 | * Q: 这么大的路由表都写在一起不就拆不开了?
13 | * A: 一般项目的路由表就算量上去了,gzip后的体积也还是不值一提;
14 | * 假设有解冲突的需求,也可以在文件夹内进行按域拆分定义,只要前提保证依然是全量书写path就可以
15 | *
16 | * Q: 定义里类似 ----- user ----- 的注释是必要的吗?用意?
17 | * A: 有意义的,用来协助git进行分区diff;大文件的共同编辑必然要考虑解冲突,
18 | * 通过定义格式相对独立、换行的注释起点和终点有助于git按照注释进行diff划分,降低冲突几率,简化解冲压力;
19 | * 当然了,如果以后通过文件来进行拆分,就可以不用这样了,
20 | * 但是现在项目还不大,不拆的话简化编辑也是个不错的选择
21 | */
22 | export const PATH = {
23 | /** ----- user ----- */
24 | get USER_LOGIN() {
25 | return `${apiServer.value}/api/user/login`
26 | },
27 | get USER_REFRESH_TOKEN() {
28 | return `${apiServer.value}/api/user/refresh_token`
29 | },
30 | get USER_SEND_RESET_EMAIL() {
31 | return `${apiServer.value}/api/user/send_reset_email`
32 | },
33 | get USER_SEND_REGISTER_EMAIL() {
34 | return `${apiServer.value}/api/user/send_register_email`
35 | },
36 | get USER_RESET_PASSWORD() {
37 | return `${apiServer.value}/api/user/reset_password`
38 | },
39 | get USER_REGISTER() {
40 | return `${apiServer.value}/api/user/register`
41 | },
42 | get USER_UPLOAD_BOOK() {
43 | return `${apiServer.value}/api/user/upload_book`
44 | },
45 | /** ----- end user ----- */
46 |
47 | /** ----- book ----- */
48 | /** ----- end book ----- */
49 | }
50 |
--------------------------------------------------------------------------------
/src/services/types.ts:
--------------------------------------------------------------------------------
1 | /** 用到的http method */
2 | export enum RequestMethod {
3 | GET = 'GET',
4 | POST = 'POST',
5 | }
6 | /** 请求选项 */
7 | export interface RequestConfig {
8 | /** 中断信号,可用来立即中断某个请求的返回(并抛错) */
9 | signal?: AbortSignal
10 | /** 请求数据;get请求会放在url上,post则json序列化放在body上 */
11 | payload?: Data
12 | /** 请求方法 @default 'POST' */
13 | method?: RequestMethod
14 | auth?: boolean
15 | }
16 |
17 | /** 列表请求 */
18 | export interface ListResult {
19 | TotalPages: number
20 | Page: number
21 | Data: T[]
22 | }
23 |
--------------------------------------------------------------------------------
/src/services/user/type.ts:
--------------------------------------------------------------------------------
1 | import type { GetBookListRequest, GetBookListRes } from 'src/services/book/types'
2 |
3 | export namespace Login {
4 | export interface Param {
5 | email: string
6 | password: string
7 | token: string
8 | }
9 |
10 | export interface Res {
11 | RefreshToken: string
12 | Token: string
13 | }
14 | }
15 |
16 | export namespace Register {
17 | export interface Param {
18 | userName: string
19 | email: string
20 | password: string
21 | code: string
22 | inviteCode: string
23 | }
24 |
25 | export interface Res {
26 | RefreshToken: string
27 | Token: string
28 | }
29 | }
30 |
31 | export namespace RefreshToken {
32 | export interface Param {
33 | token: string
34 | }
35 | export type Res = string
36 | }
37 |
38 | export namespace GetMyBooks {
39 | export type Request = GetBookListRequest
40 | export type Response = GetBookListRes
41 | }
42 |
43 | export namespace QuickCreateBook {
44 | export interface Request {
45 | Title: string
46 | Author: string
47 | // 章节数量
48 | Count: number
49 | Cover: string
50 | Introduction: string
51 | CategoryId: number
52 | }
53 | export type Response = number
54 | }
55 |
56 | export namespace UploadImage {
57 | export interface Request {
58 | FileName: string
59 | ImageData: Uint8Array
60 | }
61 | export interface Response {
62 | Url: string
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/services/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { longTermToken, sessionToken } from 'src/utils/session'
2 |
3 | import { connectState as _connectState } from 'src/services/internal/request/signalr'
4 |
5 | import { ServerError } from '../internal/ServerError'
6 | import { refreshToken } from '../user'
7 |
8 | /** 连接状态 */
9 | export const connectState = _connectState
10 |
11 | export { useCacheNotify } from './useCacheNotify'
12 | export { useServerNotify } from './useServerNotify'
13 |
14 | /** 会自动尝试刷新Token */
15 | export const getSessionToken = async () => {
16 | let token = sessionToken.get()
17 | if (!token) {
18 | // 如果没有,查询是否有 longTermToken
19 | const _token = await longTermToken.get()
20 | // 如果有, 用它来换取会话token
21 | if (_token) {
22 | try {
23 | token = await refreshToken('' + _token)
24 | } catch (error) {
25 | // -100 token失效, 404 token不存在
26 | if (error instanceof ServerError && [-100, 404].includes(error.status)) {
27 | await longTermToken.set('')
28 | }
29 | }
30 | }
31 | }
32 | return token
33 | }
34 |
--------------------------------------------------------------------------------
/src/services/utils/useCacheNotify.ts:
--------------------------------------------------------------------------------
1 | import { watch } from 'vue'
2 |
3 | import { lastResponseCache } from 'src/services/internal/request/signalr/cache'
4 |
5 | /** 监听cache使用情况 */
6 | export const useCacheNotify = (cb: (lastCache: Promise) => void) => {
7 | watch(lastResponseCache.value || {}, () => cb(Promise.resolve(lastResponseCache.value)))
8 | }
9 |
--------------------------------------------------------------------------------
/src/services/utils/useServerNotify.ts:
--------------------------------------------------------------------------------
1 | import { onUnmounted } from 'vue'
2 |
3 | import { subscribeWithSignalr } from '../internal/request/signalr'
4 |
5 | /** 订阅某个接口返回 */
6 | export async function useServerNotify(methodName: string, cb: (res: Res) => void) {
7 | const unSubscriber = subscribeWithSignalr(methodName, cb)
8 |
9 | // 卸载时自动取消订阅
10 | // 暂不考虑 onDeactivated 情况, 考虑了之后就代表还得考虑 activate 重连
11 | onUnmounted(unSubscriber)
12 |
13 | return unSubscriber
14 | }
15 |
--------------------------------------------------------------------------------
/src/stores/app.ts:
--------------------------------------------------------------------------------
1 | // store name命名约定:
2 |
3 | // 全局store以`app.`开头,紧接文件名;文件夹嵌套以`.`分隔:
4 | // @/store/user.ts => 'app.user'
5 | // @/store/demo.ts => 'app.demo'
6 | // @/store/demo/sub.ts => 'app.demo.sub'
7 |
8 | // 局部页面 store name以`page.`开头,紧接所在文件夹名称
9 | // 单文件命名`store.ts`, 多文件则以业务命名,
10 |
11 | // @/page/book/store.ts => 'page.book'
12 | // @/page/demo/store.ts => 'page.demo'
13 | // @/page/demo/store/sub1.ts => 'page.demo.sub1'
14 |
15 | import { defineStore } from 'pinia'
16 |
17 | /** @url https://pinia.esm.dev/getting-started.html */
18 |
19 | /** 全局store,命名导出的好处是可以有代码提示 */
20 | export const useAppStore = defineStore('app', {
21 | state: () => ({
22 | appName: '轻书架',
23 | user: null as any,
24 | }),
25 | getters: {
26 | doubleRepeat: (state) => state.appName.repeat(2),
27 | tripleRepeat: function (state) {
28 | return state.appName.repeat(2)
29 | },
30 | // 要取值getters,需要写成非箭头函数且标注返回值类型
31 | sum(): string {
32 | return `${this.appName} ${new Date().getFullYear()} ${this.doubleRepeat}`
33 | },
34 | userId(): number {
35 | return this.user?.Id
36 | },
37 | avatar(): string {
38 | return this.user?.Avatar
39 | },
40 | },
41 | actions: {
42 | reverse() {
43 | this.appName = this.appName.split('').reverse().join('')
44 | },
45 |
46 | async asyncReverse() {
47 | await Promise.resolve()
48 | this.reverse()
49 | },
50 | },
51 | })
52 |
--------------------------------------------------------------------------------
/src/stores/bookListData.ts:
--------------------------------------------------------------------------------
1 | import { defineStore } from 'pinia'
2 | import { toRaw } from 'vue'
3 |
4 | import { getBookListByIds } from 'src/services/book'
5 |
6 | import type { BookInList } from 'src/services/book/types'
7 |
8 | export interface BookListStore {
9 | books: Map
10 | /** 待查询id队列 */
11 | pending: Set
12 | /** 正在查询的id队列 */
13 | querying: Set
14 | /** 延时查询context */
15 | schemeContext: number
16 | }
17 |
18 | /** 初始state */
19 | const INIT: BookListStore = {
20 | books: new Map(),
21 | pending: new Set(),
22 | querying: new Set(),
23 | schemeContext: 0,
24 | }
25 |
26 | class EMPTY_BOOK implements BookInList {
27 | constructor(public readonly Id = -1) {}
28 | public readonly Title = ''
29 | // public readonly Cover = '/img/bg-paper-dark.jpeg'
30 | public readonly Cover = ''
31 | public readonly LastUpdateTime = new Date(-1)
32 | public readonly UserName = ''
33 | public readonly Level = 0
34 | public readonly InteriorLevel = 0
35 | // public readonly Placeholder = 'L06kq:ofjuoft7fRa|j@bFbGfQa}'
36 | }
37 |
38 | class INVALID_BOOK implements BookInList {
39 | constructor(public readonly Id = -1) {}
40 | public readonly Title = '无效书籍'
41 | // public readonly Cover = '/img/bg-paper-dark.jpeg'
42 | public readonly Cover = 'https://proxy.lightnovel.app/file/ddc5fbc993a81e7d25e77.png'
43 | public readonly LastUpdateTime = new Date(1)
44 | public readonly UserName = ''
45 | public readonly Level = 0
46 | public readonly InteriorLevel = 0
47 | // public readonly Placeholder = 'L06kq:ofjuoft7fRa|j@bFbGfQa}'
48 | }
49 |
50 | /**
51 | * 书籍列表数据
52 | *
53 | * @description
54 | * 用作列表查询store
55 | */
56 | export const useBookListStore = defineStore('app.bookList', {
57 | state: () => INIT,
58 | getters: {
59 | getBook() {
60 | return (id: number): BookInList => {
61 | return this.books.get(id) || new EMPTY_BOOK(id)
62 | }
63 | },
64 | isEmpty() {
65 | return (book: BookInList): boolean => {
66 | return book instanceof EMPTY_BOOK
67 | }
68 | },
69 | },
70 | actions: {
71 | /** @public 添加查询 */
72 | queryBooks(payload: { ids: number[]; force?: boolean }): void {
73 | for (const id of payload.ids) {
74 | // 如果data已经有了且不force
75 | if (!payload.force && (this.books.has(id) || this.querying.has(id))) {
76 | // 就跳过
77 | continue
78 | }
79 |
80 | // 否则add进pending
81 | this.pending.add(id)
82 | }
83 | this._schemeQueryAction()
84 | },
85 | /** @private 排期查询 */
86 | _schemeQueryAction(): void {
87 | // 如果已经有异步了,不用干别的,等就行了
88 | if (this.schemeContext) {
89 | return
90 | }
91 |
92 | this.schemeContext = requestAnimationFrame(() => this._startQuery())
93 | },
94 | /** @private 查询 */
95 | async _startQuery(): Promise {
96 | this.schemeContext = 0
97 |
98 | if (!this.pending.size) {
99 | return
100 | }
101 |
102 | // 把 pending 队列分成24个一组
103 |
104 | /** 最大值 */
105 | const MAX = getBookListByIds.MAX_CONCURRENT
106 | /** 组群 */
107 | const booksGroups: number[][] = []
108 | let cursor = 0
109 |
110 | for (const id of this.pending) {
111 | if (!booksGroups[cursor]) {
112 | booksGroups[cursor] = []
113 | } else if (booksGroups[cursor].length >= MAX) {
114 | booksGroups.push([])
115 | cursor += 1
116 | }
117 |
118 | booksGroups[cursor].push(id)
119 | }
120 |
121 | // 重置pengding队列
122 | this.querying = toRaw(this.pending)
123 | // 重置pengding队列
124 | this.pending = new Set()
125 |
126 | // 发出请求
127 | const res = await Promise.all(booksGroups.map((books) => getBookListByIds(books)))
128 | for (let i = 0; i < res.length; i++) {
129 | const ids = booksGroups[i]
130 | const books = res[i]
131 | for (let j = 0; j < ids.length; j++) {
132 | const book = books.find((b) => b.Id === ids[j]) ?? new INVALID_BOOK(ids[j])
133 | this.books.set(book.Id, book)
134 | }
135 | }
136 | },
137 | },
138 | })
139 |
--------------------------------------------------------------------------------
/src/stores/index.ts:
--------------------------------------------------------------------------------
1 | import { defineStore } from '#q-app/wrappers'
2 | import { createPinia } from 'pinia'
3 |
4 | import { createPiniaLoading } from './plugin/piniaLoading'
5 |
6 | /*
7 | * If not building with SSR mode, you can
8 | * directly export the Store instantiation;
9 | *
10 | * The function below can be async too; either use
11 | * async/await or return a Promise which resolves
12 | * with the Store instance.
13 | */
14 | export default defineStore((/* { ssrContext } */) => {
15 | const pinia = createPinia()
16 |
17 | pinia.use(createPiniaLoading())
18 |
19 | return pinia
20 | })
21 |
--------------------------------------------------------------------------------
/src/stores/setting.ts:
--------------------------------------------------------------------------------
1 | import { defineStore } from 'pinia'
2 | import { toRaw } from 'vue'
3 |
4 | import { Dark } from 'src/utils/dark'
5 | import { userSettingDB } from 'src/utils/storage/db'
6 |
7 | export const useSettingStore = defineStore('app.setting', {
8 | state: () => ({
9 | isInit: false,
10 | dark: Dark.get(), // dark 设置不保存到服务器
11 | generalSetting: {
12 | enableBlurHash: true,
13 | globalWidth: 100,
14 | ignoreJapanese: false,
15 | ignoreAI: false,
16 | },
17 | readSetting: {
18 | fontSize: 16,
19 | bgType: 'none' as 'none' | 'paper' | 'custom',
20 | customColor: '#000000',
21 | convert: null as null | 't2s' | 's2t',
22 | widthType: 'full' as 'full' | 'medium' | 'small' | 'custom',
23 | readPageWidth: 0,
24 | justify: false,
25 | showButton: true,
26 | tapToScroll: false,
27 | hideFullScreen: false,
28 | },
29 | editorSetting: {
30 | mode: 'html' as 'html' | 'markdown',
31 | },
32 | }),
33 | actions: {
34 | async init() {
35 | const p = []
36 | const keys = ['readSetting', 'editorSetting', 'generalSetting']
37 | keys.forEach((key) => {
38 | p.push(
39 | (async () => {
40 | const setting = await userSettingDB.get(key)
41 | if (setting) {
42 | Object.keys(setting).forEach((_key) => {
43 | this[key][_key] = setting[_key]
44 | })
45 | }
46 | })(),
47 | )
48 | })
49 | await Promise.all(p)
50 | this.isInit = true
51 | },
52 | async save() {
53 | const p = []
54 | const keys = ['readSetting', 'editorSetting', 'generalSetting']
55 | keys.forEach((key) => {
56 | p.push(userSettingDB.set(key, toRaw(this[key])))
57 | })
58 | await Promise.all(p)
59 | Dark.set(this.dark)
60 | },
61 | },
62 | getters: {
63 | buildReaderWidth(): string {
64 | if (this.readSetting.widthType === 'full') return '100%'
65 | if (this.readSetting.widthType === 'medium') return '75%'
66 | if (this.readSetting.widthType === 'small') return '50%'
67 | return this.readSetting.readPageWidth + 'px'
68 | },
69 | getGlobalWidth(): string {
70 | return this.generalSetting.globalWidth + '%'
71 | },
72 | },
73 | })
74 |
--------------------------------------------------------------------------------
/src/stores/store-flag.d.ts:
--------------------------------------------------------------------------------
1 | // THIS FEATURE-FLAG FILE IS AUTOGENERATED,
2 | // REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
3 | import 'quasar/dist/types/feature-flag'
4 |
5 | declare module 'quasar/dist/types/feature-flag' {
6 | interface QuasarFeatureFlags {
7 | store: true
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/types/collaborator.ts:
--------------------------------------------------------------------------------
1 | /** 贡献者卡片 */
2 | export interface Card {
3 | Id: string
4 | Job: string
5 | /** 头像 */
6 | Avatar: string
7 | /** 昵称一类的东西 */
8 | Title: string
9 | /** 简短自我介绍 */
10 | Description: string
11 | }
12 |
13 | /** 贡献者卡片大小样式 */
14 | export enum CardSize {
15 | normal = 'normal',
16 | small = 'small',
17 | nano = 'nano',
18 | }
19 |
--------------------------------------------------------------------------------
/src/types/shelf.ts:
--------------------------------------------------------------------------------
1 | /** 书架条目类型枚举 */
2 | export enum ShelfItemTypeEnum {
3 | /** 书籍 */
4 | BOOK = 'BOOK',
5 | /** 文件夹 */
6 | FOLDER = 'FOLDER',
7 | }
8 |
9 | export enum SHELF_STRUCT_VER {
10 | 'V20220211' = '20220211',
11 | /** 最新版本号,动态改变 */
12 | /* eslint-disable-next-line */
13 | LATEST = '20220211',
14 | }
15 |
16 | interface ShelfCommonItem {
17 | /** 类型 */
18 | type: ShelfItemTypeEnum
19 | /** Id,目前书籍的Id是数字,文件夹的是字符串 */
20 | id: string | number
21 | /** 次序 */
22 | index: number
23 | /** 父级文件夹ID,不在文件夹的话就空数组 */
24 | parents: string[]
25 | /** 加入/更新时间,iso格式字符串 */
26 | updateAt: string
27 | }
28 |
29 | export interface ShelfBookItem extends ShelfCommonItem {
30 | type: ShelfItemTypeEnum.BOOK
31 | id: number
32 | }
33 | export interface ShelfFolderItem extends ShelfCommonItem {
34 | type: ShelfItemTypeEnum.FOLDER
35 | id: string
36 | /** 文件夹名称 */
37 | title: string
38 | }
39 |
40 | export type ShelfItem = ShelfBookItem | ShelfFolderItem
41 |
--------------------------------------------------------------------------------
/src/types/utils.ts:
--------------------------------------------------------------------------------
1 | /** 空函数 */
2 | export type AnyVoidFunc = () => void
3 |
4 | /** 任意输入输出函数 */
5 | export type AnyFunc = (...param: Params) => Return
6 | /** 任意异步输入输出函数 */
7 | export type AnyAsyncFunc = Promise> = (
8 | ...param: Params
9 | ) => Return
10 |
--------------------------------------------------------------------------------
/src/utils/bbcode/index.ts:
--------------------------------------------------------------------------------
1 | export default class BBCode {
2 | private codes: any[]
3 |
4 | /**
5 | * @param {Object} codes
6 | */
7 | constructor(codes) {
8 | this.codes = []
9 |
10 | this.setCodes(codes)
11 | }
12 |
13 | /**
14 | * parse
15 | *
16 | * @param {String} text
17 | * @returns {String}
18 | */
19 | parse(text) {
20 | return this.codes.reduce((text, code) => text.replace(code.regexp, code.replacement), text)
21 | }
22 |
23 | /**
24 | * add bb codes
25 | *
26 | * @param {String} regex
27 | * @param {String} replacement
28 | * @returns {BBCode}
29 | */
30 | add(regex, replacement) {
31 | this.codes.push({
32 | regexp: new RegExp(regex, 'igms'),
33 | replacement: replacement,
34 | })
35 |
36 | return this
37 | }
38 |
39 | /**
40 | * set bb codes
41 | *
42 | * @param {Object} codes
43 | * @returns {BBCode}
44 | */
45 | setCodes(codes) {
46 | this.codes = Object.keys(codes).map(function (regex) {
47 | const replacement = codes[regex]
48 |
49 | return {
50 | regexp: new RegExp(regex, 'igms'),
51 | replacement: replacement,
52 | }
53 | }, this)
54 |
55 | return this
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/utils/bbcode/simple.ts:
--------------------------------------------------------------------------------
1 | import BBCode from './index'
2 |
3 | export default new BBCode({
4 | '\\[br\\]': '
',
5 |
6 | '\\[b\\](.+?)\\[/b\\]': '$1',
7 | '\\[i\\](.+?)\\[/i\\]': '$1',
8 | '\\[u\\](.+?)\\[/u\\]': '$1',
9 |
10 | '\\[h1\\](.+?)\\[/h1\\]': '$1
',
11 | '\\[h2\\](.+?)\\[/h2\\]': '$1
',
12 | '\\[h3\\](.+?)\\[/h3\\]': '$1
',
13 | '\\[h4\\](.+?)\\[/h4\\]': '$1
',
14 | '\\[h5\\](.+?)\\[/h5\\]': '$1
',
15 | '\\[h6\\](.+?)\\[/h6\\]': '$1
',
16 |
17 | '\\[p\\](.+?)\\[/p\\]': '$1
',
18 |
19 | '\\[color=(.+?)\\](.+?)\\[/color\\]': '$2',
20 | '\\[size=([0-9]+)\\](.+?)\\[/size\\]': '$2',
21 |
22 | '\\[img=(\\d+),(\\d+)\\](.*?)\\[/img\\]':
23 | '',
24 | '\\[img\\](.+?)\\[/img\\]': '',
25 | '\\[img=(.+?)\\]': '',
26 |
27 | '\\[email\\](.+?)\\[/email\\]': '$1',
28 | '\\[email=(.+?)\\](.+?)\\[/email\\]': '$2',
29 |
30 | '\\[url\\](.+?)\\[/url\\]': '$1',
31 | '\\[url=(.+?)\\|onclick\\](.+?)\\[/url\\]': '$2',
32 | '\\[url=(.+?)\\starget=(.+?)\\](.+?)\\[/url\\]': '$3',
33 | '\\[url=(.+?)\\](.+?)\\[/url\\]': '$2',
34 |
35 | '\\[a=(.+?)\\](.+?)\\[/a\\]': '$2',
36 |
37 | '\\[list\\](.+?)\\[/list\\]': '',
38 | '\\[\\*\\](.+?)\\[/\\*\\]': '$1',
39 |
40 | '\\[ruby=(.+?)\\](.+?)\\[/ruby\\]': '$2',
41 | })
42 |
--------------------------------------------------------------------------------
/src/utils/biz/unAuthenticationNotify.ts:
--------------------------------------------------------------------------------
1 | import { effectScope, ref, watch } from 'vue'
2 |
3 | import type { AnyVoidFunc } from 'src/types/utils'
4 |
5 | import { safeCall } from '../safeCall'
6 |
7 | const notifier = ref(-1)
8 |
9 | /** 接口未授权调用通知 */
10 | export const unAuthenticationNotify = {
11 | /** rxjs Subject.next */
12 | notify(): void {
13 | notifier.value += 1
14 | },
15 | /** 适合在setup外调用 @return unsubscribe */
16 | subscribe(cb: AnyVoidFunc): AnyVoidFunc {
17 | const scope = effectScope()
18 | scope.run(() => {
19 | watch(notifier, safeCall(cb))
20 | })
21 | return () => scope.stop()
22 | },
23 | /** 适合在setup内调用 @return unsubscribe */
24 | useSubscribe(cb: AnyVoidFunc): AnyVoidFunc {
25 | return watch(notifier, safeCall(cb))
26 | },
27 | }
28 |
--------------------------------------------------------------------------------
/src/utils/createDirective.ts:
--------------------------------------------------------------------------------
1 | import type { Directive } from 'vue'
2 |
3 | /**
4 | * 没逻辑作用,主要是为了 type-safe
5 | *
6 | * T是指可以用在什么元素上
7 | * V是指可以赋什么值给这个 directive
8 | *
9 | * @example
10 | * ```js
11 | * createDirective({}) // 指只能用在 HTMLElement 上, 使用时只能赋值number
12 | * createDirective({}) // 指能用在 HTMLElement 或者 SVGElement 上, 不接受任何赋值
13 | * ```
14 | */
15 | export function createDirective = Directive>(i: D): D {
16 | return i
17 | }
18 |
--------------------------------------------------------------------------------
/src/utils/dark.ts:
--------------------------------------------------------------------------------
1 | const Key: string = (process.env.VUE_APP_NAME || 'LightNovelShelf') + '_Dark'
2 |
3 | /** Dark设置 */
4 | export const Dark = {
5 | get(): 'auto' | boolean {
6 | const result = localStorage.getItem(Key)
7 | switch (result) {
8 | case 'true':
9 | return true
10 | case 'false':
11 | return false
12 | default:
13 | return 'auto'
14 | }
15 | },
16 | set(value: 'auto' | boolean) {
17 | localStorage.setItem(Key, `${value}`)
18 | },
19 | }
20 |
--------------------------------------------------------------------------------
/src/utils/debounceInFrame.ts:
--------------------------------------------------------------------------------
1 | /** 以帧为间隔防抖 */
2 | export function debounceInFrame void>(cb: T): T {
3 | let context = 0
4 | const _cb = cb.bind(this)
5 | return function (...args: any[]) {
6 | if (context) {
7 | cancelAnimationFrame(context)
8 | }
9 |
10 | context = requestAnimationFrame(() => {
11 | _cb(...args)
12 | context = 0
13 | })
14 | } as T
15 | }
16 |
--------------------------------------------------------------------------------
/src/utils/delay.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 延时
3 | *
4 | * @example
5 | * ```js
6 | * await delay()()
7 | * ```
8 | *
9 | * @example
10 | * ```js
11 | * await delay(200)({ response: {} })
12 | * ```
13 | *
14 | * @example
15 | * ```js
16 | * const wait = delay(200)
17 | * while (true) { await wait() }
18 | * ```
19 | */
20 | export function delay(ms = 1000) {
21 | return function (res?: Res) {
22 | return new Promise((r) => setTimeout(() => r(res), ms))
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/utils/getErrMsg.ts:
--------------------------------------------------------------------------------
1 | /** 涵盖项目范围内常见的错误场景消息取值 */
2 | export function getErrMsg(err: unknown, fallbackMsg = '网络错误'): string {
3 | try {
4 | // 1. 假定是无法取值的对象
5 | if (!err) {
6 | // 这里判断一次是否是无法取值的对象是为了
7 | // 防止error是 null 等时,固定对外抛出 从null取值 的错误,干扰debug
8 | return fallbackMsg
9 | }
10 | // 2. 假定是个error对象
11 | // if (err instanceof Error) {
12 | // return err.message
13 | // }
14 |
15 | // @ts-expect-error 3. 假定是个 error继生对象 或是 服务器返回的json
16 | return '' + (err.message || err.Message || err.msg || err.Msg || fallbackMsg)
17 | } catch (e) {
18 | // 以上都不是,就对新的error做取值
19 | return getErrMsg(e, fallbackMsg)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/utils/hash.ts:
--------------------------------------------------------------------------------
1 | const textEncoder = new TextEncoder()
2 |
3 | export enum HashMethod {
4 | SHA1 = 'SHA1',
5 | SHA256 = 'SHA-256',
6 | SHA384 = 'SHA-384',
7 | SHA512 = 'SHA-512',
8 | }
9 |
10 | /**
11 | * hash
12 | *
13 | * @url https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string
14 | */
15 | async function hash(message: string, method: HashMethod = HashMethod.SHA256): Promise {
16 | // dev环境下提供fallback,方便局域网调试
17 | if (process.env.DEV) {
18 | // 单独写,方便在production模式下消除deadcode
19 | if (!window.isSecureContext) {
20 | const lib = await import('hash.js')
21 | switch (method) {
22 | case HashMethod.SHA1: {
23 | return lib.sha1().update(message).digest('hex')
24 | }
25 | case HashMethod.SHA256: {
26 | return lib.sha256().update(message).digest('hex')
27 | }
28 | case HashMethod.SHA384: {
29 | return lib.sha256().update(message).digest('hex')
30 | }
31 | case HashMethod.SHA512: {
32 | return lib.sha256().update(message).digest('hex')
33 | }
34 | default: {
35 | return ''
36 | }
37 | }
38 | }
39 | }
40 |
41 | const msgUint8 = textEncoder.encode(message)
42 | const hashBuffer = await crypto.subtle.digest(method, msgUint8)
43 | const hashArray = Array.from(new Uint8Array(hashBuffer))
44 | const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
45 | return hashHex
46 | }
47 |
48 | export function sha256(message: string): Promise {
49 | return hash(message, HashMethod.SHA256)
50 | }
51 |
--------------------------------------------------------------------------------
/src/utils/migrations/shelf/struct/action.ts:
--------------------------------------------------------------------------------
1 | import { ShelfItemTypeEnum, SHELF_STRUCT_VER } from 'src/types/shelf'
2 |
3 | import type { ShelfItem } from 'src/types/shelf'
4 |
5 | import * as ShelfLegacyStruct from './types'
6 |
7 | /**
8 | * 书架数据结构版本合并逻辑
9 | *
10 | * @throws 没有 找到合适版本时会报错
11 | */
12 | export async function shelfStructMigration(
13 | input: (ShelfItem | ShelfLegacyStruct.ServerShelfItem)[],
14 | inputVer: SHELF_STRUCT_VER | null,
15 | ): Promise {
16 | if (inputVer === SHELF_STRUCT_VER.LATEST) {
17 | return input as ShelfItem[]
18 | }
19 |
20 | // 最初的一版本没有版本号概念,入参null
21 | if (inputVer === null) {
22 | const res = input as ShelfLegacyStruct.ServerShelfItem[]
23 | return res.map((item): ShelfItem => {
24 | let result: ShelfItem
25 | switch (item.type) {
26 | case ShelfLegacyStruct.ShelfItemType.BOOK: {
27 | result = {
28 | /** 类型 */
29 | type: ShelfItemTypeEnum.BOOK,
30 | /** Id,目前书籍的Id是数字,文件夹的是字符串 */
31 | id: item.value.Id,
32 | /** 次序 */
33 | index: item.index,
34 | /** 父级文件夹ID,不在文件夹的话就空数组 */
35 | parents: item.parents,
36 | /** 加入/更新时间,iso格式字符串 */
37 | updateAt: new Date().toISOString(),
38 | }
39 | break
40 | }
41 | case ShelfLegacyStruct.ShelfItemType.FOLDER: {
42 | result = {
43 | /** 类型 */
44 | type: ShelfItemTypeEnum.FOLDER,
45 | /** Id,目前书籍的Id是数字,文件夹的是字符串 */
46 | id: item.value.Id,
47 | /** 次序 */
48 | index: item.index,
49 | /** 父级文件夹ID,不在文件夹的话就空数组 */
50 | parents: item.parents,
51 | /** 加入/更新时间,iso格式字符串 */
52 | updateAt: new Date(item.value.updateAt).toISOString(),
53 | /** 文件夹名称 */
54 | title: item.value.Title,
55 | }
56 | break
57 | }
58 |
59 | default: {
60 | // eslint-disable-next-line
61 | // @ts-expect-error
62 | throw new Error(`shelfStructMigration:未知结构type: ${item.type}`)
63 | }
64 | }
65 |
66 | return result
67 | })
68 | }
69 |
70 | throw new Error(`shelfStructMigration:未知结构版本: ${inputVer}`)
71 | }
72 |
--------------------------------------------------------------------------------
/src/utils/migrations/shelf/struct/types.ts:
--------------------------------------------------------------------------------
1 | import type { BookServicesTypes } from 'src/services/book'
2 |
3 | /** 书架书籍 */
4 | interface ShelfBook extends BookServicesTypes.BookInList {}
5 |
6 | /** 书架文件夹 */
7 | interface ShelfFolder {
8 | /** 文件夹Id,尽量跟Book同名方便模板书写 */
9 | Id: string
10 | /** 文件夹名称,尽量跟Book同名方便模板书写 */
11 | Title: string
12 | /** 更改时间,iso格式字符串 */
13 | updateAt: string
14 | }
15 |
16 | /** 书架条目类型枚举 */
17 | export enum ShelfItemType {
18 | /** 书籍 */
19 | BOOK = 'BOOK',
20 | /** 文件夹 */
21 | FOLDER = 'FOLDER',
22 | }
23 |
24 | /** 书架条目共享字段 */
25 | interface ShelfCommonItem {
26 | /** 类型 */
27 | type: ShelfItemType
28 | /** Id */
29 | id: string
30 | /** 次序 */
31 | index: number
32 | /** 父级文件夹ID,不在文件夹的话就空数组 */
33 | parents: string[]
34 | /** 选中态 */
35 | selected?: boolean
36 | }
37 |
38 | /** 书架 - 书籍 */
39 | interface ShelfBookItem extends ShelfCommonItem {
40 | type: ShelfItemType.BOOK
41 | value: ShelfBook
42 | }
43 | /** 书架 - 文件夹 */
44 | interface ShelfFolderItem extends ShelfCommonItem {
45 | type: ShelfItemType.FOLDER
46 | value: ShelfFolder
47 | }
48 |
49 | // type ShelfItem = ShelfBookItem | ShelfFolderItem
50 |
51 | interface MiniShelfBookItem extends Omit {
52 | // 仅保留ID
53 | value: Pick
54 | }
55 |
56 | // 之前signal的message pack协议会自动 parse iso字符串为Date,现在改传gzip后的json字符串,没这个处理了
57 | // interface ServerShelfFolderItem extends Omit {
58 | // value: Omit & { updateAt: Date | string }
59 | // }
60 |
61 | /** 服务器裁剪过后的数据 */
62 | export type ServerShelfItem = ShelfFolderItem | MiniShelfBookItem
63 |
--------------------------------------------------------------------------------
/src/utils/rateLimitQueue.ts:
--------------------------------------------------------------------------------
1 | import type { AnyAsyncFunc, AnyVoidFunc } from 'src/types/utils'
2 | // import { sleep } from './sleep'
3 |
4 | const now = performance.now.bind(performance) || Date.now.bind(Date)
5 |
6 | /** 调用频率限制队列 */
7 | export class RateLimitQueue {
8 | constructor(
9 | /** 最多多少个请求 */
10 | private readonly max: number,
11 | /** 在多少时间的周期内 @default 1000 默认1秒 */
12 | private readonly perMs: number = 1000,
13 | ) {
14 | if (this.max <= 0) {
15 | throw new Error('max must bigger than 0')
16 | }
17 | if (this.perMs <= 100) {
18 | throw new Error('perMs must bigger than 100')
19 | }
20 |
21 | // 取平均数的一半为间隔,最低值10
22 | this.tick = Math.max(10, Math.floor(this.perMs / this.max / 2))
23 | }
24 |
25 | /** 检查间隔 */
26 | private tick = 10
27 | /** 正在排队的队列 */
28 | private pending: AnyVoidFunc[] = []
29 | /** 正在运行的队列的加入时间 */
30 | private pool: number[] = []
31 |
32 | /** 获取队列长度 */
33 | public get size() {
34 | return this.pending.length
35 | }
36 |
37 | public add = >>(fn: Fn): Promise => {
38 | return new Promise((resolve, reject) => {
39 | this.pending.push(() => {
40 | try {
41 | fn().then(resolve, reject)
42 | } catch (e) {
43 | reject(e)
44 | }
45 | })
46 | this.run()
47 | })
48 | }
49 |
50 | private lastRunCheck = 0
51 |
52 | /** 如果有空位就安排一次函数调用 */
53 | private run = () => {
54 | if (!this.pending.length) {
55 | return
56 | }
57 |
58 | const time = now()
59 |
60 | // 把符合间隔条件的都清出去
61 | while (this.pool[0] && time - this.pool[0] > this.perMs) {
62 | this.pool.shift()
63 | }
64 |
65 | // 填满pool
66 | while (this.pending.length && this.pool.length < this.max) {
67 | // shift一个函数出来并运行
68 | this.pending.shift()!()
69 |
70 | // 在这里push进去的一定要即时取值,不能用缓存的值;
71 | // 如果用缓存的值,值取早了,校验的时候就可能意外通过了更多的函数,导致频率超过限制
72 | this.pool.push(now())
73 | }
74 |
75 | // 如果填充过后还有,异步计时
76 | if (this.pending.length) {
77 | // 需要清除,防止快速add 超过 this.max 个时重复定时
78 | // cancelAnimationFrame(this.lastRunCheck)
79 | // this.lastRunCheck = requestAnimationFrame(this.run)
80 | clearTimeout(this.lastRunCheck)
81 | this.lastRunCheck = setTimeout(this.run, this.tick) as unknown as number
82 | }
83 | }
84 | }
85 |
86 | // // 校验函数;@todo 写成 .test.ts
87 | // ;(async function test() {
88 | // const [max, perMs] = [10, 5000]
89 | // const queue = new RateLimitQueue(max, perMs)
90 | // /** 异步运行的时候去这里取一次值,验证自己比10的那次已经晚了指定间隔 */
91 | // const runTimeCache = new Map()
92 |
93 | // for (const _key of new Array(max * 5).keys()) {
94 | // const key = _key + 1
95 | // console.log(`RateLimitQueue.debug.addAt: ${key}`, ~~now())
96 | // queue.add(async () => {
97 | // const time = ~~now()
98 | // // 每10个应该一块运行(尽早发出);数组的第二项理应比perMs大且(误差范围内)刚刚好
99 | // console.log(`RateLimitQueue.debug.runAt: ${key}`, [time, time - (runTimeCache.get(key - max) ?? time)])
100 | // runTimeCache.set(key, time)
101 | // await sleep(2000)
102 | // })
103 | // }
104 | // })()
105 |
--------------------------------------------------------------------------------
/src/utils/safeCall.ts:
--------------------------------------------------------------------------------
1 | import { NOOP } from 'src/const/empty'
2 |
3 | import type { AnyFunc } from 'src/types/utils'
4 |
5 | /**
6 | * catch 住所有错误的调用
7 | *
8 | * @example
9 | * ```js
10 | * // 裹住同步函数
11 | * safeCall(thrownFunc)()
12 | * ```
13 | * @example
14 | * ```js
15 | * // 裹住异步函数
16 | * safeCall(thrownAsyncFunc)()
17 | * ```
18 | * @example
19 | * ```js
20 | * // 裹住函数后丢给watch或者别的vue生命周期
21 | * watch(ref, safeCall(thrownFunc))
22 | * watch(ref, safeCall(thrownAsyncFunc))
23 | * ```
24 | */
25 | export function safeCall(cb: Func): Func {
26 | return function (...args: Parameters) {
27 | try {
28 | const result = cb(...args)
29 | if (result instanceof Promise) {
30 | return result.catch(NOOP)
31 | }
32 | return result
33 | } catch (e) {
34 | // ignore
35 | }
36 | } as Func
37 | }
38 |
--------------------------------------------------------------------------------
/src/utils/sanitizeHtml.ts:
--------------------------------------------------------------------------------
1 | import DOMPurify from 'dompurify'
2 |
3 | let sanitizer: null | { sanitizeFor: (...args: unknown[]) => any } = null
4 |
5 | try {
6 | if (window.Sanitizer) {
7 | const defaultConfig = new window.Sanitizer().getConfiguration()
8 | defaultConfig.allowElements.push('svg')
9 | sanitizer = new window.Sanitizer(defaultConfig)
10 | }
11 | } catch (e) {
12 | // ignore
13 | }
14 |
15 | export default function sanitizerHtml(content: string, tag = 'div') {
16 | if (sanitizer) {
17 | if (sanitizer.sanitizeFor) {
18 | return sanitizer.sanitizeFor(tag, content).innerHTML
19 | } else {
20 | const element = document.createElement(tag)
21 | element.setHTML(content, { sanitizer: sanitizer })
22 | return element.innerHTML
23 | }
24 | } else {
25 | return DOMPurify.sanitize(content)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/utils/session.ts:
--------------------------------------------------------------------------------
1 | import { userAuthenticationDB } from 'src/utils/storage/db'
2 |
3 | import { NOOP } from 'src/const/empty'
4 |
5 | class TokenStorage {
6 | private readonly INIT_SOURCE = ''
7 | private source = this.INIT_SOURCE
8 | private lastUpdate = 0
9 |
10 | // timeout设置为-1时代表永不过期
11 | constructor(private timeout: number) {}
12 |
13 | public get(): Readonly {
14 | if (this.timeout < 0 || Date.now() - this.lastUpdate < this.timeout) {
15 | return this.source
16 | }
17 |
18 | return this.INIT_SOURCE
19 | }
20 | public set(newValue: string) {
21 | this.lastUpdate = Date.now()
22 | this.source = newValue
23 | }
24 | }
25 |
26 | /** 会话密钥 */
27 | export const sessionToken = new TokenStorage(+process.env.VUE_SESSION_TOKEN_VALIDITY || 3000)
28 |
29 | /** 长期密钥 */
30 | export const longTermToken = {
31 | get(): Promise {
32 | return userAuthenticationDB.get('RefreshToken')
33 | },
34 | set(token: string): Promise {
35 | return userAuthenticationDB.set('RefreshToken', token).then(NOOP)
36 | },
37 | }
38 |
--------------------------------------------------------------------------------
/src/utils/sleep.ts:
--------------------------------------------------------------------------------
1 | /** 暂停指定时间,默认1秒 */
2 | export function sleep(ms = 1000): Promise {
3 | return new Promise((r) => setTimeout(r, ms))
4 | }
5 |
--------------------------------------------------------------------------------
/src/utils/storage/db/base.ts:
--------------------------------------------------------------------------------
1 | import localforage from 'localforage'
2 | import { toRaw } from 'vue'
3 |
4 | if (!window.indexedDB) {
5 | throw new Error('unsupport browser')
6 | }
7 |
8 | /** 应用版本;版本变更时会清空上一版本的数据库 */
9 | // const APP_VER: number = +VUE_APP_VER
10 | /** APP实例tag,用来方便同域名调试不同实例 */
11 | const APP_NAME: string = process.env.VUE_APP_NAME || 'LightNovelShelf'
12 |
13 | /**
14 | * 储存数据库元数据的数据库
15 | *
16 | * @description
17 | * 因为 localforage 的getItem操作是异步操作,没法在 DB 的 constructor 里完成操作
18 | * 所以这里用 localStorage 起了个简单的轮子
19 | */
20 | class MetaDB {
21 | private config: Record = {}
22 | private static NAME = APP_NAME + '__DB_META'
23 |
24 | constructor() {
25 | try {
26 | this.config = JSON.parse(localStorage.getItem(MetaDB.NAME) ?? '{}')
27 | } catch (e) {
28 | this.config = {}
29 | localStorage.setItem(MetaDB.NAME, JSON.stringify(this.config))
30 | }
31 | }
32 |
33 | // 这里使用setVer而不是setItem是因为现在场景比较简单,直接setVer简化概念与类型声明
34 | // 以后有多个key设置时再另外写具体逻辑(包括各个key的空值适配等)
35 |
36 | /** 设置版本 */
37 | public setDBVer(DB_NAME: string, ver: number): void {
38 | if (!this.config[DB_NAME]) {
39 | this.config[DB_NAME] = {}
40 | }
41 | this.config[DB_NAME].version = ver
42 | localStorage.setItem(MetaDB.NAME, JSON.stringify(this.config))
43 | }
44 | /** 读取版本 */
45 | public getDBVer(DB_NAME: string): number {
46 | return this.config[DB_NAME]?.version ?? 1
47 | }
48 |
49 | private static _instance: MetaDB | null = null
50 | /** 获取MetaDB的实例;写成这个形式的好处是懒初始化 */
51 | static getInstance(): MetaDB {
52 | if (!MetaDB._instance) {
53 | MetaDB._instance = new MetaDB()
54 | }
55 | return MetaDB._instance
56 | }
57 | }
58 |
59 | export class DB {
60 | /** 返回一个DB实例 */
61 | private static createInstance(name: string, VER: number, DB_DESC: string) {
62 | return localforage.createInstance({
63 | /** 库名, 一个应用一个库 */
64 | name: APP_NAME,
65 | /** 表名 */
66 | storeName: name,
67 | version: VER,
68 | description: DB_DESC,
69 | driver: localforage.INDEXEDDB,
70 | })
71 | }
72 |
73 | /** 当前版本 */
74 | private static readonly CURRENT_VER = 1
75 |
76 | /** db实例 */
77 | private db: LocalForage
78 |
79 | constructor(
80 | /** DB名,需要保证全局唯一 */
81 | DB_NAME: string,
82 | /** DB描述 */
83 | DB_DESC = '',
84 |
85 | /** db配置 */
86 | // config?: {}
87 | ) {
88 | // 就算相同也要set一次,保证初版应用也能记录到
89 | MetaDB.getInstance().setDBVer(DB_NAME, DB.CURRENT_VER)
90 |
91 | this.db = DB.createInstance(DB_NAME, DB.CURRENT_VER, DB_DESC)
92 | }
93 |
94 | /** 获取DB储存 */
95 | public get = (key: string): Promise => {
96 | return this.db.getItem(key)
97 | }
98 | /** 更新DB储存 */
99 | public set = async (key: string, val: T): Promise => {
100 | // 因为同步的时候经常是vue对象来的,所以这里加点便捷操作,包了toRaw操作免得忘了之后debug
101 | await this.db.setItem(key, toRaw(val))
102 | }
103 | /** 移除DB中某一项目 */
104 | public remove = (key: string): Promise => {
105 | return this.db.removeItem(key)
106 | }
107 | /** 清空DB */
108 | public clear = (): Promise => {
109 | return this.db.clear()
110 | }
111 | /** 列出DB中所有的储存项名称 */
112 | public keys = (): Promise => {
113 | return this.db.keys()
114 | }
115 | /** 迭代DB中的所有项目 @private 因为这个API没想到能直接用的场景,同时也跟 localforage 有绑定,所以暂不导出 */
116 | private iterate: (cb: (value: Value, key: string, idx: number) => void) => Promise = (cb) => {
117 | return this.db.iterate((value: any, key: string, idx: number) => {
118 | // localforage.iterate的cb会接受返回值并据此决定是否提早结束迭代
119 | // 因为这个规则不太容易记住且容易误用,故这里强制吃掉cb的任何返回
120 | cb(value, key, idx)
121 | })
122 | }
123 |
124 | /** 获取DB中所有的项目 */
125 | public length = (): Promise => {
126 | return this.db.length()
127 | }
128 |
129 | /** 获取DB中所有的项目 */
130 | public getItems = async (): Promise => {
131 | const list: any[] = []
132 |
133 | await this.iterate((item) => list.push(item))
134 |
135 | return list
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/src/utils/storage/db/index.ts:
--------------------------------------------------------------------------------
1 | import type { ShelfItem } from 'src/types/shelf'
2 |
3 | import { DB } from './base'
4 | import { MemoryDB } from './memory'
5 |
6 | export const shelfDB = new DB('USER_SHELF', '用于储存用户书架数据')
7 | /**
8 | * 书架数据结构版本
9 | *
10 | * @key 'VER' 版本
11 | */
12 | export const shelfStructVerDB = new DB('APP_SHELF_STRUCT_VER', '书架数据结构版本')
13 | export const userSettingDB = new DB>('SETTING', '设置缓存')
14 | export const signalrCacheDB = new DB('SIGNALR_CACHE', '请求缓存储存')
15 | export const userAuthenticationDB = new DB('USER_AUTHENTICATION', '用户授权信息')
16 |
17 | export const userReadPositionDB = new MemoryDB()
18 |
--------------------------------------------------------------------------------
/src/utils/storage/db/memory.ts:
--------------------------------------------------------------------------------
1 | import { toRaw } from 'vue'
2 |
3 | export class MemoryDB {
4 | /** db实例 */
5 | private db = {}
6 |
7 | /** 获取DB储存 */
8 | public get = (key: string): T | undefined => {
9 | return this.db[key]
10 | }
11 | /** 更新DB储存 */
12 | public set = (key: string, val: T): void => {
13 | // 因为同步的时候经常是vue对象来的,所以这里加点便捷操作,包了toRaw操作免得忘了之后debug
14 | this.db[key] = toRaw(val)
15 | }
16 | /** 移除DB中某一项目 */
17 | public remove = (key: string): void => {
18 | delete this.db[key]
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/utils/thresholdInFrame.ts:
--------------------------------------------------------------------------------
1 | /** 以帧为间隔节流 */
2 | export function thresholdInFrame void>(cb: T): T {
3 | const _cb = cb.bind(this) as T
4 |
5 | const context: { func: T | null } = {
6 | func: null,
7 | }
8 | return function (...args: any[]) {
9 | const scheduled = !!context.func
10 | context.func = (() => _cb(...args)) as T
11 |
12 | if (!scheduled) {
13 | requestAnimationFrame(() => {
14 | if (!context.func) {
15 | return
16 | }
17 | context.func()
18 | context.func = null
19 | })
20 | }
21 | } as T
22 | }
23 |
--------------------------------------------------------------------------------
/src/utils/time.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs'
2 |
3 | import type { Dayjs } from 'dayjs'
4 |
5 | /**
6 | * 解析时间
7 | *
8 | * @param date 接受js时间对象、ISO字符串、dayjs对象
9 | */
10 | export function parseTime(date: Date | Dayjs | string | undefined | null): Dayjs {
11 | // 字符串格式的时间戳会parse成错误的时间,但目前没有这种场景,先注释,省点
12 | // if (typeof date === 'string' && date === (+date).toString()) {
13 | // date = +date
14 | // }
15 |
16 | return dayjs(date || 0)
17 | }
18 |
19 | /**
20 | * 获取时间相对目前的文案描述
21 | *
22 | * @url https://day.js.org/docs/en/display/from-now#list-of-breakdown-range
23 | */
24 | export function toNow(
25 | date: Date | Dayjs,
26 | config: {
27 | now?: Dayjs
28 | notNegative?: boolean
29 | } = {
30 | notNegative: true,
31 | },
32 | ): string {
33 | const { now = dayjs(), notNegative } = config
34 | const dateObj = parseTime(date)
35 |
36 | if (notNegative && dateObj.isSameOrAfter(now, 'second')) {
37 | return '刚刚'
38 | }
39 |
40 | return now.to(dateObj)
41 | }
42 |
--------------------------------------------------------------------------------
/src/utils/useForwardRef.ts:
--------------------------------------------------------------------------------
1 | import type {Ref} from 'vue';
2 |
3 | /**
4 | * 用来获取vue组件内别的node元素的ref
5 | *
6 | * @example 基础用法
7 | * ```xml
8 | *
9 | *
10 | * ```
11 | * ```js
12 | * const [getEl, elRef] = useForwardRef()
13 | *
14 | * watch(elRef, el => {
15 | * if (!el) {
16 | * // 组件还没挂载等,反正就是ref还没拿到
17 | * }
18 | *
19 | * console.log('node ref:', el)
20 | * })
21 | * ```
22 | *
23 | * @example 标记类型
24 | * ```ts
25 | * // elRef.value 会拿到 `HTMLInputElement | null` 的类型
26 | * const [getEl, elRef] = useForwardRef()
27 | * ```
28 | *
29 | */
30 | export function useForwardRef() {
31 | const elRef: Ref = ref(null)
32 | function setElRef(el: Element) {
33 | elRef.value = el
34 | }
35 |
36 | return [elRef, setElRef] as const
37 | }
38 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.quasar/tsconfig.json",
3 | "compilerOptions": {
4 | "moduleResolution": "bundler",
5 | "verbatimModuleSyntax": true
6 | }
7 | }
8 |
--------------------------------------------------------------------------------