diff --git a/mock/login.ts b/mock/login.ts index 55897d8..e12f775 100644 --- a/mock/login.ts +++ b/mock/login.ts @@ -6,9 +6,9 @@ export default defineFakeRoute([ url: "/login", method: "post", response: ({ body }) => { - if (body.username === "admin") { + if (body.account === "admin" || body.username === "admin") { return { - success: true, + code: 0, data: { avatar: "https://avatars.githubusercontent.com/u/44761321", username: "admin", @@ -17,23 +17,27 @@ export default defineFakeRoute([ roles: ["admin"], // 按钮级别权限 permissions: ["*:*:*"], - accessToken: "eyJhbGciOiJIUzUxMiJ9.admin", - refreshToken: "eyJhbGciOiJIUzUxMiJ9.adminRefresh", - expires: "2030/10/30 00:00:00" + accessToken: + "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.mock-admin-payload.mock-admin-signature", + refreshToken: + "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.mock-admin-refresh.mock-admin-refresh-signature", + expireAtUtc: "2030-10-30T00:00:00Z" } }; } else { return { - success: true, + code: 0, data: { avatar: "https://avatars.githubusercontent.com/u/52823142", username: "common", nickname: "小林", roles: ["common"], permissions: ["permission:btn:add", "permission:btn:edit"], - accessToken: "eyJhbGciOiJIUzUxMiJ9.common", - refreshToken: "eyJhbGciOiJIUzUxMiJ9.commonRefresh", - expires: "2030/10/30 00:00:00" + accessToken: + "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.mock-common-payload.mock-common-signature", + refreshToken: + "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.mock-common-refresh.mock-common-refresh-signature", + expireAtUtc: "2030-10-30T00:00:00Z" } }; } diff --git a/mock/refreshToken.ts b/mock/refreshToken.ts index 34d0e87..3f6398d 100644 --- a/mock/refreshToken.ts +++ b/mock/refreshToken.ts @@ -8,17 +8,20 @@ export default defineFakeRoute([ response: ({ body }) => { if (body.refreshToken) { return { - success: true, + code: 0, data: { - accessToken: "eyJhbGciOiJIUzUxMiJ9.newAdmin", - refreshToken: "eyJhbGciOiJIUzUxMiJ9.newAdminRefresh", + accessToken: + "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.mock-refresh-payload.mock-refresh-signature", + refreshToken: + "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.mock-refresh-token.mock-refresh-signature", // `expires`选择这种日期格式是为了方便调试,后端直接设置时间戳或许更方便(每次都应该递增)。如果后端返回的是时间戳格式,前端开发请来到这个目录`src/utils/auth.ts`,把第`38`行的代码换成expires = data.expires即可。 - expires: "2030/10/30 23:59:59" + expireAtUtc: "2030-10-30T23:59:59Z" } }; } else { return { - success: false, + code: 1, + message: "refresh token missing", data: {} }; } diff --git a/src/api/user.ts b/src/api/user.ts index 2e0e86d..ffb79e0 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -10,6 +10,11 @@ export interface SendLoginCodeParams { account: string; } +export interface PageParams { + page: number; + pageSize: number; +} + export interface AuthData { token?: string; accessToken?: string; @@ -24,6 +29,20 @@ export interface AuthData { permissions?: Array; } +type ResultTable = { + code: number; + data?: { + /** 列表数据 */ + list: Array; + /** 总条目数 */ + total?: number; + /** 每页显示条目个数 */ + pageSize?: number; + /** 当前页数 */ + page?: number; + }; +}; + export type UserResult = ApiResult; export type RefreshTokenResult = ApiResult; export type SendLoginCodeResult = ApiResult; @@ -43,3 +62,9 @@ export const refreshTokenApi = (data?: object) => { data }); }; + +export const getUserListApi = (data: PageParams) => { + return http.request("post", "user/list", { + data + }); +}; diff --git a/src/components/ReCountTo/README.md b/src/components/ReCountTo/README.md new file mode 100644 index 0000000..b5048f3 --- /dev/null +++ b/src/components/ReCountTo/README.md @@ -0,0 +1,2 @@ +normal 普通数字动画组件 +rebound 回弹式数字动画组件 diff --git a/src/components/ReCountTo/index.ts b/src/components/ReCountTo/index.ts new file mode 100644 index 0000000..1817218 --- /dev/null +++ b/src/components/ReCountTo/index.ts @@ -0,0 +1,11 @@ +import reNormalCountTo from "./src/normal"; +import reboundCountTo from "./src/rebound"; +import { withInstall } from "@pureadmin/utils"; + +/** 普通数字动画组件 */ +const ReNormalCountTo = withInstall(reNormalCountTo); + +/** 回弹式数字动画组件 */ +const ReboundCountTo = withInstall(reboundCountTo); + +export { ReNormalCountTo, ReboundCountTo }; diff --git a/src/components/ReCountTo/src/normal/index.tsx b/src/components/ReCountTo/src/normal/index.tsx new file mode 100644 index 0000000..538e0bb --- /dev/null +++ b/src/components/ReCountTo/src/normal/index.tsx @@ -0,0 +1,179 @@ +import { + watch, + unref, + computed, + reactive, + onMounted, + defineComponent +} from "vue"; +import { countToProps } from "./props"; +import { isNumber } from "@pureadmin/utils"; + +export default defineComponent({ + name: "ReNormalCountTo", + props: countToProps, + emits: ["mounted", "callback"], + setup(props, { emit }) { + const state = reactive<{ + localStartVal: number; + printVal: number | null; + displayValue: string; + paused: boolean; + localDuration: number | null; + startTime: number | null; + timestamp: number | null; + rAF: any; + remaining: number | null; + color: string; + fontSize: string; + }>({ + localStartVal: props.startVal, + displayValue: formatNumber(props.startVal), + printVal: null, + paused: false, + localDuration: props.duration, + startTime: null, + timestamp: null, + remaining: null, + rAF: null, + color: null, + fontSize: "16px" + }); + + const getCountDown = computed(() => { + return props.startVal > props.endVal; + }); + + watch([() => props.startVal, () => props.endVal], () => { + if (props.autoplay) { + start(); + } + }); + + function start() { + const { startVal, duration, color, fontSize } = props; + state.localStartVal = startVal; + state.startTime = null; + state.localDuration = duration; + state.paused = false; + state.color = color; + state.fontSize = fontSize; + state.rAF = requestAnimationFrame(count); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function pauseResume() { + if (state.paused) { + resume(); + state.paused = false; + } else { + pause(); + state.paused = true; + } + } + + function pause() { + cancelAnimationFrame(state.rAF); + } + + function resume() { + state.startTime = null; + state.localDuration = +(state.remaining as number); + state.localStartVal = +(state.printVal as number); + requestAnimationFrame(count); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function reset() { + state.startTime = null; + cancelAnimationFrame(state.rAF); + state.displayValue = formatNumber(props.startVal); + } + + function count(timestamp: number) { + const { useEasing, easingFn, endVal } = props; + if (!state.startTime) state.startTime = timestamp; + state.timestamp = timestamp; + const progress = timestamp - state.startTime; + state.remaining = (state.localDuration as number) - progress; + if (useEasing) { + if (unref(getCountDown)) { + state.printVal = + state.localStartVal - + easingFn( + progress, + 0, + state.localStartVal - endVal, + state.localDuration as number + ); + } else { + state.printVal = easingFn( + progress, + state.localStartVal, + endVal - state.localStartVal, + state.localDuration as number + ); + } + } else { + if (unref(getCountDown)) { + state.printVal = + state.localStartVal - + (state.localStartVal - endVal) * + (progress / (state.localDuration as number)); + } else { + state.printVal = + state.localStartVal + + (endVal - state.localStartVal) * + (progress / (state.localDuration as number)); + } + } + if (unref(getCountDown)) { + state.printVal = state.printVal < endVal ? endVal : state.printVal; + } else { + state.printVal = state.printVal > endVal ? endVal : state.printVal; + } + state.displayValue = formatNumber(state.printVal); + if (progress < (state.localDuration as number)) { + state.rAF = requestAnimationFrame(count); + } else { + emit("callback"); + } + } + + function formatNumber(num: number | string) { + const { decimals, decimal, separator, suffix, prefix } = props; + num = Number(num).toFixed(decimals); + num += ""; + const x = num.split("."); + let x1 = x[0]; + const x2 = x.length > 1 ? decimal + x[1] : ""; + const rgx = /(\d+)(\d{3})/; + if (separator && !isNumber(separator)) { + while (rgx.test(x1)) { + x1 = x1.replace(rgx, "$1" + separator + "$2"); + } + } + return prefix + x1 + x2 + suffix; + } + + onMounted(() => { + if (props.autoplay) { + start(); + } + emit("mounted"); + }); + + return () => ( + <> + + {state.displayValue} + + + ); + } +}); diff --git a/src/components/ReCountTo/src/normal/props.ts b/src/components/ReCountTo/src/normal/props.ts new file mode 100644 index 0000000..f04c2f5 --- /dev/null +++ b/src/components/ReCountTo/src/normal/props.ts @@ -0,0 +1,32 @@ +import type { PropType } from "vue"; +import propTypes from "@/utils/propTypes"; + +export const countToProps = { + startVal: propTypes.number.def(0), + endVal: propTypes.number.def(2020), + duration: propTypes.number.def(1300), + autoplay: propTypes.bool.def(true), + decimals: { + type: Number as PropType, + required: false, + default: 0, + validator(value: number) { + return value >= 0; + } + }, + color: propTypes.string.def(), + fontSize: propTypes.string.def(), + decimal: propTypes.string.def("."), + separator: propTypes.string.def(","), + prefix: propTypes.string.def(""), + suffix: propTypes.string.def(""), + useEasing: propTypes.bool.def(true), + easingFn: { + type: Function as PropType< + (t: number, b: number, c: number, d: number) => number + >, + default(t: number, b: number, c: number, d: number) { + return (c * (-Math.pow(2, (-10 * t) / d) + 1) * 1024) / 1023 + b; + } + } +}; diff --git a/src/components/ReCountTo/src/rebound/index.tsx b/src/components/ReCountTo/src/rebound/index.tsx new file mode 100644 index 0000000..ff6c224 --- /dev/null +++ b/src/components/ReCountTo/src/rebound/index.tsx @@ -0,0 +1,72 @@ +import "./rebound.css"; +import { + ref, + unref, + onBeforeMount, + defineComponent, + onBeforeUnmount +} from "vue"; +import { reboundProps } from "./props"; + +export default defineComponent({ + name: "ReboundCountTo", + props: reboundProps, + setup(props) { + const ulRef = ref(); + const timer = ref(null); + + onBeforeMount(() => { + const ua = navigator.userAgent.toLowerCase(); + const testUA = regexp => regexp.test(ua); + const isSafari = testUA(/safari/g) && !testUA(/chrome/g); + + // Safari浏览器的兼容代码 + isSafari && + (timer.value = setTimeout(() => { + ulRef.value.setAttribute( + "style", + ` + animation: none; + transform: translateY(calc(var(--i) * -9.09%)) + ` + ); + }, props.delay * 1000)); + }); + + onBeforeUnmount(() => { + clearTimeout(unref(timer)); + }); + + return () => ( + <> +
+
    +
  • 0
  • +
  • 1
  • +
  • 2
  • +
  • 3
  • +
  • 4
  • +
  • 5
  • +
  • 6
  • +
  • 7
  • +
  • 8
  • +
  • 9
  • +
  • 0
  • +
+ + + + + + +
+ + ); + } +}); diff --git a/src/components/ReCountTo/src/rebound/props.ts b/src/components/ReCountTo/src/rebound/props.ts new file mode 100644 index 0000000..8b0491a --- /dev/null +++ b/src/components/ReCountTo/src/rebound/props.ts @@ -0,0 +1,15 @@ +import type { PropType } from "vue"; +import propTypes from "@/utils/propTypes"; + +export const reboundProps = { + delay: propTypes.number.def(1), + blur: propTypes.number.def(2), + i: { + type: Number as PropType, + required: false, + default: 0, + validator(value: number) { + return value < 10 && value >= 0 && Number.isInteger(value); + } + } +}; diff --git a/src/components/ReCountTo/src/rebound/rebound.css b/src/components/ReCountTo/src/rebound/rebound.css new file mode 100644 index 0000000..9fc5932 --- /dev/null +++ b/src/components/ReCountTo/src/rebound/rebound.css @@ -0,0 +1,77 @@ +.scroll-num { + width: var(--width, 20px); + height: var(--height, calc(var(--width, 20px) * 1.8)); + color: var(--color, #333); + font-size: var(--height, calc(var(--width, 20px) * 1.1)); + line-height: var(--height, calc(var(--width, 20px) * 1.8)); + text-align: center; + overflow: hidden; + animation: enhance-bounce-in-down 1s calc(var(--delay) * 1s) forwards; +} + +ul { + animation: + move 0.3s linear infinite, + bounce-in-down 1s calc(var(--delay) * 1s) forwards; +} + +@keyframes move { + from { + transform: translateY(-90%); + filter: url(#blur); + } + + to { + transform: translateY(1%); + filter: url(#blur); + } +} + +@keyframes bounce-in-down { + from { + transform: translateY(calc(var(--i) * -9.09% - 7%)); + filter: none; + } + + 25% { + transform: translateY(calc(var(--i) * -9.09% + 3%)); + } + + 50% { + transform: translateY(calc(var(--i) * -9.09% - 1%)); + } + + 70% { + transform: translateY(calc(var(--i) * -9.09% + 0.6%)); + } + + 85% { + transform: translateY(calc(var(--i) * -9.09% - 0.3%)); + } + + to { + transform: translateY(calc(var(--i) * -9.09%)); + } +} + +@keyframes enhance-bounce-in-down { + 25% { + transform: translateY(8%); + } + + 50% { + transform: translateY(-4%); + } + + 70% { + transform: translateY(2%); + } + + 85% { + transform: translateY(-1%); + } + + to { + transform: translateY(0); + } +} diff --git a/src/components/ReFlicker/index.css b/src/components/ReFlicker/index.css new file mode 100644 index 0000000..4c40af4 --- /dev/null +++ b/src/components/ReFlicker/index.css @@ -0,0 +1,39 @@ +.point { + width: var(--point-width); + height: var(--point-height); + background: var(--point-background); + position: relative; + border-radius: var(--point-border-radius); +} + +.point-flicker:after { + background: var(--point-background); +} + +.point-flicker:before, +.point-flicker:after { + content: ""; + width: 100%; + height: 100%; + top: 0; + left: 0; + position: absolute; + border-radius: var(--point-border-radius); + animation: flicker 1.2s ease-out infinite; +} + +@keyframes flicker { + 0% { + transform: scale(0.5); + opacity: 1; + } + + 30% { + opacity: 1; + } + + 100% { + transform: scale(var(--point-scale)); + opacity: 0; + } +} diff --git a/src/components/ReFlicker/index.ts b/src/components/ReFlicker/index.ts new file mode 100644 index 0000000..b829323 --- /dev/null +++ b/src/components/ReFlicker/index.ts @@ -0,0 +1,44 @@ +import "./index.css"; +import { type Component, h, defineComponent } from "vue"; + +export interface attrsType { + width?: string; + height?: string; + borderRadius?: number | string; + background?: string; + scale?: number | string; +} + +/** + * 圆点、方形闪烁动画组件 + * @param width 可选 string 宽 + * @param height 可选 string 高 + * @param borderRadius 可选 number | string 传0为方形、传50%或者不传为圆形 + * @param background 可选 string 闪烁颜色 + * @param scale 可选 number | string 闪烁范围,默认2,值越大闪烁范围越大 + * @returns Component + */ +export function useRenderFlicker(attrs?: attrsType): Component { + return defineComponent({ + name: "ReFlicker", + render() { + return h( + "div", + { + class: "point point-flicker", + style: { + "--point-width": attrs?.width ?? "12px", + "--point-height": attrs?.height ?? "12px", + "--point-background": + attrs?.background ?? "var(--el-color-primary)", + "--point-border-radius": attrs?.borderRadius ?? "50%", + "--point-scale": attrs?.scale ?? "2" + } + }, + { + default: () => [] + } + ); + } + }); +} diff --git a/src/router/modules/activity.ts b/src/router/modules/activity.ts new file mode 100644 index 0000000..9d0df75 --- /dev/null +++ b/src/router/modules/activity.ts @@ -0,0 +1,28 @@ +export default { + path: "/activity", + redirect: "/activity/list", + meta: { + icon: "ri:gift-2-line", + // showLink: false, + title: "活动管理", + rank: 3 + }, + children: [ + { + path: "/activity/list", + name: "活动列表", + component: () => import("@/views/user/index.vue"), + meta: { + title: "玩家列表" + } + }, + { + path: "/activity/404", + name: "风险玩家", + component: () => import("@/views/user/index.vue"), + meta: { + title: "风险玩家" + } + } + ] +} satisfies RouteConfigsTable; diff --git a/src/router/modules/user.ts b/src/router/modules/user.ts new file mode 100644 index 0000000..519c50c --- /dev/null +++ b/src/router/modules/user.ts @@ -0,0 +1,44 @@ +export default { + path: "/user", + redirect: "/user/list", + meta: { + icon: "ri:account-circle-2-line", + // showLink: false, + title: "玩家管理", + rank: 2 + }, + children: [ + { + path: "/user/list", + name: "玩家列表", + component: () => import("@/views/user/index.vue"), + meta: { + title: "玩家列表" + } + }, + { + path: "/user/dangerous", + name: "风险玩家", + component: () => import("@/views/user/index.vue"), + meta: { + title: "风险玩家" + } + }, + { + path: "/user/banned", + name: "封禁列表", + component: () => import("@/views/user/index.vue"), + meta: { + title: "封禁列表" + } + }, + { + path: "/user/club", + name: "玩家公会", + component: () => import("@/views/user/index.vue"), + meta: { + title: "公会列表" + } + } + ] +} satisfies RouteConfigsTable; diff --git a/src/utils/http/index.ts b/src/utils/http/index.ts index cc27445..9cdf941 100644 --- a/src/utils/http/index.ts +++ b/src/utils/http/index.ts @@ -29,6 +29,12 @@ const defaultConfig: AxiosRequestConfig = { } }; +const whiteList = ["/refresh-token", "/login", "/send-login-code"]; + +function resolveAccessToken(tokenData: ReturnType) { + return tokenData?.accessToken || tokenData?.token || ""; +} + class PureHttp { constructor() { this.httpInterceptorsRequest(); @@ -51,6 +57,7 @@ class PureHttp { private static retryOriginalRequest(config: PureHttpRequestConfig) { return new Promise(resolve => { PureHttp.requests.push((token: string) => { + config.headers = config.headers ?? {}; config.headers["Authorization"] = formatToken(token); resolve(config); }); @@ -61,24 +68,23 @@ class PureHttp { private httpInterceptorsRequest(): void { PureHttp.axiosInstance.interceptors.request.use( async (config: PureHttpRequestConfig): Promise => { + config.headers = config.headers ?? {}; // 优先判断post/get等方法是否传入回调,否则执行初始化设置等回调 if (typeof config.beforeRequestCallback === "function") { config.beforeRequestCallback(config); - return config; } if (PureHttp.initConfig.beforeRequestCallback) { PureHttp.initConfig.beforeRequestCallback(config); - return config; } /** 请求白名单,放置一些不需要`token`的接口(通过设置请求白名单,防止`token`过期后再请求造成的死循环问题) */ - const whiteList = ["/refresh-token", "/login"]; - return whiteList.some(url => config.url.endsWith(url)) + return whiteList.some(url => config.url?.endsWith(url)) ? config : new Promise(resolve => { const data = getToken(); - if (data) { + const accessToken = resolveAccessToken(data); + if (data && accessToken) { const now = new Date().getTime(); - const expired = parseInt(data.expires) - now <= 0; + const expired = Number(data.expires) - now <= 0; if (expired) { if (!PureHttp.isRefreshing) { PureHttp.isRefreshing = true; @@ -86,7 +92,8 @@ class PureHttp { useUserStoreHook() .handRefreshToken({ refreshToken: data.refreshToken }) .then(res => { - const token = res.data.accessToken; + const token = + res.data.accessToken || res.data.token || ""; config.headers["Authorization"] = formatToken(token); PureHttp.requests.forEach(cb => cb(token)); PureHttp.requests = []; @@ -97,9 +104,7 @@ class PureHttp { } resolve(PureHttp.retryOriginalRequest(config)); } else { - config.headers["Authorization"] = formatToken( - data.accessToken - ); + config.headers["Authorization"] = formatToken(accessToken); resolve(config); } } else { diff --git a/src/views/user/data.ts b/src/views/user/data.ts new file mode 100644 index 0000000..1d5f99e --- /dev/null +++ b/src/views/user/data.ts @@ -0,0 +1,297 @@ +import dayjs from "dayjs"; +import { clone } from "@pureadmin/utils"; + +const date = dayjs(new Date()).format("YYYY-MM-DD"); + +const tableData = [ + { + date, + name: "Tom", + address: "No. 189, Grove St, Los Angeles" + }, + { + date, + name: "Jack", + address: "No. 189, Grove St, Los Angeles" + }, + { + date, + name: "Dick", + address: "No. 189, Grove St, Los Angeles" + }, + { + date, + name: "Harry", + address: "No. 189, Grove St, Los Angeles" + }, + { + date, + name: "Sam", + address: "No. 189, Grove St, Los Angeles" + }, + { + date, + name: "Lucy", + address: "No. 189, Grove St, Los Angeles" + }, + { + date, + name: "Mary", + address: "No. 189, Grove St, Los Angeles" + }, + { + date, + name: "Mike", + address: "No. 189, Grove St, Los Angeles" + } +]; + +const cloneData = clone(tableData, true); + +const tableDataMore = cloneData.map(item => + Object.assign(item, { + state: "California", + city: "Los Angeles", + "post-code": "CA 90036" + }) +); + +const tableDataImage = cloneData.map((item, index) => + Object.assign(item, { + image: `https://pure-admin.github.io/pure-admin-table/imgs/${index + 1}.jpg` + }) +); + +const tableDataSortable = cloneData.map((item, index) => + Object.assign(item, { + date: `${dayjs(new Date()).format("YYYY-MM")}-${index + 1}` + }) +); + +const tableDataExpand = [ + { + date: "2016-05-03", + name: "Tom", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114", + family: [ + { + name: "Jerry", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + }, + { + name: "Spike", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + }, + { + name: "Tyke", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + } + ] + }, + { + date: "2016-05-02", + name: "Tom", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114", + family: [ + { + name: "Jerry", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + }, + { + name: "Spike", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + }, + { + name: "Tyke", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + } + ] + }, + { + date: "2016-05-04", + name: "Tom", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114", + family: [ + { + name: "Jerry", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + }, + { + name: "Spike", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + }, + { + name: "Tyke", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + } + ] + }, + { + date: "2016-05-01", + name: "Tom", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114", + family: [ + { + name: "Jerry", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + }, + { + name: "Spike", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + }, + { + name: "Tyke", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + } + ] + }, + { + date: "2016-05-08", + name: "Tom", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114", + family: [ + { + name: "Jerry", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + }, + { + name: "Spike", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + }, + { + name: "Tyke", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + } + ] + }, + { + date: "2016-05-06", + name: "Tom", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114", + family: [ + { + name: "Jerry", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + }, + { + name: "Spike", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + }, + { + name: "Tyke", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + } + ] + }, + { + date: "2016-05-07", + name: "Tom", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114", + family: [ + { + name: "Jerry", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + }, + { + name: "Spike", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + }, + { + name: "Tyke", + state: "California", + city: "San Francisco", + address: "3650 21st St, San Francisco", + zip: "CA 94114" + } + ] + } +]; + +export { + tableData, + tableDataMore, + tableDataImage, + tableDataExpand, + tableDataSortable +}; diff --git a/src/views/user/hook.tsx b/src/views/user/hook.tsx new file mode 100644 index 0000000..3b73f3a --- /dev/null +++ b/src/views/user/hook.tsx @@ -0,0 +1,201 @@ +import dayjs from "dayjs"; +import Detail from "./detail.vue"; +import { message } from "@/utils/message"; +import userAvatar from "@/assets/user.jpg"; +import { addDialog } from "@/components/ReDialog"; +import type { PaginationProps } from "@pureadmin/table"; +import { type Ref, reactive, ref, onMounted, toRaw } from "vue"; +import { getKeyList, useCopyToClipboard } from "@pureadmin/utils"; +import { getUserListApi } from "@/api/user"; +// import { getSystemLogsList, getSystemLogsDetail } from "@/api/system"; + +export function useRole(tableRef: Ref) { + const form = reactive({ + module: "", + requestTime: "" + }); + const dataList = ref([]); + const loading = ref(true); + const selectedNum = ref(0); + const { copied, update } = useCopyToClipboard(); + + const pagination = reactive({ + total: 0, + pageSize: 10, + currentPage: 1, + background: true + }); + + const columns: TableColumnList = [ + { + label: "ID", + prop: "id" + }, + { + label: "用户头像", + prop: "avatar", + cellRenderer: ({ row }) => ( + + ), + width: 90 + }, + { + label: "昵称", + prop: "nickName" + }, + { + label: "国家", + prop: "country", + minWidth: 100 + }, + { + label: "等级", + prop: "level", + minWidth: 90 + }, + { + label: "上星", + prop: "star", + minWidth: 90 + }, + { + label: "高光", + prop: "highlight", + minWidth: 90 + }, + { + label: "VIP", + prop: "vip", + minWidth: 90 + }, + { + label: "操作", + fixed: "right", + slot: "operation" + } + ]; + + function handleOffline(row) { + message(`${row.username}已被强制下线`, { type: "success" }); + onSearch(); + } + + function handleSizeChange(val: number) { + console.log(`${val} items per page`); + } + + function handleCurrentChange(val: number) { + console.log(`current page: ${val}`); + } + + /** 当CheckBox选择项发生变化时会触发该事件 */ + function handleSelectionChange(val) { + selectedNum.value = val.length; + // 重置表格高度 + tableRef.value.setAdaptive(); + } + + /** 取消选择 */ + function onSelectionCancel() { + selectedNum.value = 0; + // 用于多选表格,清空用户的选择 + tableRef.value.getTableRef().clearSelection(); + } + + /** 拷贝请求接口,表格单元格被双击时触发 */ + function handleCellDblclick({ url }, { property }) { + if (property !== "url") return; + update(url); + copied.value + ? message(`${url} 已拷贝`, { type: "success" }) + : message("拷贝失败", { type: "warning" }); + } + + /** 批量删除 */ + function onbatchDel() { + // 返回当前选中的行 + const curSelected = tableRef.value.getTableRef().getSelectionRows(); + // 接下来根据实际业务,通过选中行的某项数据,比如下面的id,调用接口进行批量删除 + message(`已删除序号为 ${getKeyList(curSelected, "id")} 的数据`, { + type: "success" + }); + tableRef.value.getTableRef().clearSelection(); + onSearch(); + } + + /** 清空日志 */ + function clearAll() { + // 根据实际业务,调用接口删除所有日志数据 + message("已删除所有日志数据", { + type: "success" + }); + onSearch(); + } + + function onDetail(row) { + // getSystemLogsDetail({ id: row.id }).then(res => { + // addDialog({ + // title: "系统日志详情", + // fullscreen: true, + // hideFooter: true, + // contentRenderer: () => Detail, + // props: { + // data: [res] + // } + // }); + // }); + console.log(row); + } + + async function onSearch() { + loading.value = true; + + const { code, data } = await getUserListApi({ page: 1, pageSize: 10 }); + if (code === 0) { + dataList.value = data.list; + pagination.total = data.total; + pagination.pageSize = data.pageSize; + pagination.currentPage = data.page; + } + + setTimeout(() => { + loading.value = false; + }, 500); + } + + const resetForm = formEl => { + if (!formEl) return; + formEl.resetFields(); + onSearch(); + }; + + onMounted(() => { + onSearch(); + }); + + return { + form, + loading, + columns, + dataList, + pagination, + selectedNum, + onSearch, + onDetail, + clearAll, + resetForm, + onbatchDel, + handleSizeChange, + onSelectionCancel, + handleCellDblclick, + handleCurrentChange, + handleSelectionChange, + handleOffline + }; +} diff --git a/src/views/user/index.vue b/src/views/user/index.vue new file mode 100644 index 0000000..fc67e26 --- /dev/null +++ b/src/views/user/index.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/src/views/user/utils.ts b/src/views/user/utils.ts new file mode 100644 index 0000000..1350606 --- /dev/null +++ b/src/views/user/utils.ts @@ -0,0 +1,129 @@ +/** 日期、时间选择器快捷选项,常搭配 [DatePicker](https://element-plus.org/zh-CN/component/date-picker.html) 和 [DateTimePicker](https://element-plus.org/zh-CN/component/datetime-picker.html) 的`shortcuts`属性使用 */ +export const getPickerShortcuts = (): Array<{ + text: string; + value: Date | Function; +}> => { + return [ + { + text: "今天", + value: () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayEnd = new Date(); + todayEnd.setHours(23, 59, 59, 999); + return [today, todayEnd]; + } + }, + { + text: "昨天", + value: () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + const yesterdayEnd = new Date(); + yesterdayEnd.setDate(yesterdayEnd.getDate() - 1); + yesterdayEnd.setHours(23, 59, 59, 999); + return [yesterday, yesterdayEnd]; + } + }, + { + text: "前天", + value: () => { + const beforeYesterday = new Date(); + beforeYesterday.setDate(beforeYesterday.getDate() - 2); + beforeYesterday.setHours(0, 0, 0, 0); + const beforeYesterdayEnd = new Date(); + beforeYesterdayEnd.setDate(beforeYesterdayEnd.getDate() - 2); + beforeYesterdayEnd.setHours(23, 59, 59, 999); + return [beforeYesterday, beforeYesterdayEnd]; + } + }, + { + text: "本周", + value: () => { + const today = new Date(); + const startOfWeek = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - today.getDay() + (today.getDay() === 0 ? -6 : 1) + ); + startOfWeek.setHours(0, 0, 0, 0); + const endOfWeek = new Date( + startOfWeek.getTime() + + 6 * 24 * 60 * 60 * 1000 + + 23 * 60 * 60 * 1000 + + 59 * 60 * 1000 + + 59 * 1000 + + 999 + ); + return [startOfWeek, endOfWeek]; + } + }, + { + text: "上周", + value: () => { + const today = new Date(); + const startOfLastWeek = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - today.getDay() - 7 + (today.getDay() === 0 ? -6 : 1) + ); + startOfLastWeek.setHours(0, 0, 0, 0); + const endOfLastWeek = new Date( + startOfLastWeek.getTime() + + 6 * 24 * 60 * 60 * 1000 + + 23 * 60 * 60 * 1000 + + 59 * 60 * 1000 + + 59 * 1000 + + 999 + ); + return [startOfLastWeek, endOfLastWeek]; + } + }, + { + text: "本月", + value: () => { + const today = new Date(); + const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + startOfMonth.setHours(0, 0, 0, 0); + const endOfMonth = new Date( + today.getFullYear(), + today.getMonth() + 1, + 0 + ); + endOfMonth.setHours(23, 59, 59, 999); + return [startOfMonth, endOfMonth]; + } + }, + { + text: "上个月", + value: () => { + const today = new Date(); + const startOfLastMonth = new Date( + today.getFullYear(), + today.getMonth() - 1, + 1 + ); + startOfLastMonth.setHours(0, 0, 0, 0); + const endOfLastMonth = new Date( + today.getFullYear(), + today.getMonth(), + 0 + ); + endOfLastMonth.setHours(23, 59, 59, 999); + return [startOfLastMonth, endOfLastMonth]; + } + }, + { + text: "本年", + value: () => { + const today = new Date(); + const startOfYear = new Date(today.getFullYear(), 0, 1); + startOfYear.setHours(0, 0, 0, 0); + const endOfYear = new Date(today.getFullYear(), 11, 31); + endOfYear.setHours(23, 59, 59, 999); + return [startOfYear, endOfYear]; + } + } + ]; +}; diff --git a/src/views/welcome/components/charts/ChartBar.vue b/src/views/welcome/components/charts/ChartBar.vue new file mode 100644 index 0000000..6d9af15 --- /dev/null +++ b/src/views/welcome/components/charts/ChartBar.vue @@ -0,0 +1,108 @@ + + + diff --git a/src/views/welcome/components/charts/ChartLine.vue b/src/views/welcome/components/charts/ChartLine.vue new file mode 100644 index 0000000..fa72ec1 --- /dev/null +++ b/src/views/welcome/components/charts/ChartLine.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/views/welcome/components/charts/ChartRound.vue b/src/views/welcome/components/charts/ChartRound.vue new file mode 100644 index 0000000..769f2b2 --- /dev/null +++ b/src/views/welcome/components/charts/ChartRound.vue @@ -0,0 +1,73 @@ + + + diff --git a/src/views/welcome/components/charts/index.ts b/src/views/welcome/components/charts/index.ts new file mode 100644 index 0000000..fa55448 --- /dev/null +++ b/src/views/welcome/components/charts/index.ts @@ -0,0 +1,3 @@ +export { default as ChartBar } from "./ChartBar.vue"; +export { default as ChartLine } from "./ChartLine.vue"; +export { default as ChartRound } from "./ChartRound.vue"; diff --git a/src/views/welcome/components/table/columns.tsx b/src/views/welcome/components/table/columns.tsx new file mode 100644 index 0000000..2da2075 --- /dev/null +++ b/src/views/welcome/components/table/columns.tsx @@ -0,0 +1,104 @@ +import { tableData } from "../../data"; +import { delay } from "@pureadmin/utils"; +import { ref, onMounted, reactive } from "vue"; +import type { PaginationProps } from "@pureadmin/table"; +import ThumbUp from "~icons/ri/thumb-up-line"; +import Hearts from "~icons/ri/hearts-line"; +import Empty from "./empty.svg?component"; + +export function useColumns() { + const dataList = ref([]); + const loading = ref(true); + const columns: TableColumnList = [ + { + sortable: true, + label: "序号", + prop: "id" + }, + { + sortable: true, + label: "需求人数", + prop: "requiredNumber", + filterMultiple: false, + filterClassName: "pure-table-filter", + filters: [ + { text: "≥16000", value: "more" }, + { text: "<16000", value: "less" } + ], + filterMethod: (value, { requiredNumber }) => { + return value === "more" + ? requiredNumber >= 16000 + : requiredNumber < 16000; + } + }, + { + sortable: true, + label: "提问数量", + prop: "questionNumber" + }, + { + sortable: true, + label: "解决数量", + prop: "resolveNumber" + }, + { + sortable: true, + label: "用户满意度", + minWidth: 100, + prop: "satisfaction", + cellRenderer: ({ row }) => ( +
+ + {row.satisfaction}% + 98 ? Hearts : ThumbUp} + color="#e85f33" + /> + +
+ ) + }, + { + sortable: true, + label: "统计日期", + prop: "date" + }, + { + label: "操作", + fixed: "right", + slot: "operation" + } + ]; + + /** 分页配置 */ + const pagination = reactive({ + pageSize: 10, + currentPage: 1, + layout: "prev, pager, next", + total: 0, + align: "center" + }); + + function onCurrentChange(page: number) { + console.log("onCurrentChange", page); + loading.value = true; + delay(300).then(() => { + loading.value = false; + }); + } + + onMounted(() => { + dataList.value = tableData; + pagination.total = dataList.value.length; + loading.value = false; + }); + + return { + Empty, + loading, + columns, + dataList, + pagination, + onCurrentChange + }; +} diff --git a/src/views/welcome/components/table/empty.svg b/src/views/welcome/components/table/empty.svg new file mode 100644 index 0000000..5c8b211 --- /dev/null +++ b/src/views/welcome/components/table/empty.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/views/welcome/components/table/index.vue b/src/views/welcome/components/table/index.vue new file mode 100644 index 0000000..ab8c179 --- /dev/null +++ b/src/views/welcome/components/table/index.vue @@ -0,0 +1,71 @@ + + + + + + + diff --git a/src/views/welcome/data.ts b/src/views/welcome/data.ts new file mode 100644 index 0000000..86ce0f7 --- /dev/null +++ b/src/views/welcome/data.ts @@ -0,0 +1,134 @@ +import { dayjs, cloneDeep, getRandomIntBetween } from "./utils"; +import GroupLine from "~icons/ri/group-line"; +import Question from "~icons/ri/question-answer-line"; +import CheckLine from "~icons/ri/chat-check-line"; +import Smile from "~icons/ri/star-smile-line"; + +const days = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]; + +/** 需求人数、提问数量、解决数量、用户满意度 */ +const chartData = [ + { + icon: GroupLine, + bgColor: "#effaff", + color: "#41b6ff", + duration: 2200, + name: "需求人数", + value: 36000, + percent: "+88%", + data: [2101, 5288, 4239, 4962, 6752, 5208, 7450] // 平滑折线图数据 + }, + { + icon: Question, + bgColor: "#fff5f4", + color: "#e85f33", + duration: 1600, + name: "提问数量", + value: 16580, + percent: "+70%", + data: [2216, 1148, 1255, 788, 4821, 1973, 4379] + }, + { + icon: CheckLine, + bgColor: "#eff8f4", + color: "#26ce83", + duration: 1500, + name: "解决数量", + value: 16499, + percent: "+99%", + data: [861, 1002, 3195, 1715, 3666, 2415, 3645] + }, + { + icon: Smile, + bgColor: "#f6f4fe", + color: "#7846e5", + duration: 100, + name: "用户满意度", + value: 100, + percent: "+100%", + data: [100] + } +]; + +/** 分析概览 */ +const barChartData = [ + { + requireData: [2101, 5288, 4239, 4962, 6752, 5208, 7450], + questionData: [2216, 1148, 1255, 1788, 4821, 1973, 4379] + }, + { + requireData: [2101, 3280, 4400, 4962, 5752, 6889, 7600], + questionData: [2116, 3148, 3255, 3788, 4821, 4970, 5390] + } +]; + +/** 解决概率 */ +const progressData = [ + { + week: "周一", + percentage: 85, + duration: 110, + color: "#41b6ff" + }, + { + week: "周二", + percentage: 86, + duration: 105, + color: "#41b6ff" + }, + { + week: "周三", + percentage: 88, + duration: 100, + color: "#41b6ff" + }, + { + week: "周四", + percentage: 89, + duration: 95, + color: "#41b6ff" + }, + { + week: "周五", + percentage: 94, + duration: 90, + color: "#26ce83" + }, + { + week: "周六", + percentage: 96, + duration: 85, + color: "#26ce83" + }, + { + week: "周日", + percentage: 100, + duration: 80, + color: "#26ce83" + } +].reverse(); + +/** 数据统计 */ +const tableData = Array.from({ length: 30 }).map((_, index) => { + return { + id: index + 1, + requiredNumber: getRandomIntBetween(13500, 19999), + questionNumber: getRandomIntBetween(12600, 16999), + resolveNumber: getRandomIntBetween(13500, 17999), + satisfaction: getRandomIntBetween(95, 100), + date: dayjs().subtract(index, "day").format("YYYY-MM-DD") + }; +}); + +/** 最新动态 */ +const latestNewsData = cloneDeep(tableData) + .slice(0, 14) + .map((item, index) => { + return Object.assign(item, { + date: `${dayjs().subtract(index, "day").format("YYYY-MM-DD")} ${ + days[dayjs().subtract(index, "day").day()] + }` + }); + }); + +export { chartData, barChartData, progressData, tableData, latestNewsData }; diff --git a/src/views/welcome/index.vue b/src/views/welcome/index.vue index 8db10d2..965d8dc 100644 --- a/src/views/welcome/index.vue +++ b/src/views/welcome/index.vue @@ -1,9 +1,283 @@ + + diff --git a/src/views/welcome/utils.ts b/src/views/welcome/utils.ts new file mode 100644 index 0000000..7708a7e --- /dev/null +++ b/src/views/welcome/utils.ts @@ -0,0 +1,6 @@ +export { default as dayjs } from "dayjs"; +export { useDark, cloneDeep, randomGradient } from "@pureadmin/utils"; + +export function getRandomIntBetween(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min; +}