Commit 15d3f72c by chunhong.mu

feat(*): 新增管理后台项目,完善基础初始化与适配

- 新增 qqqf-admin 管理后台完整项目结构
- 新增 Web 平台 Axios 适配器适配 Web 环境
- 修复 common sdk 中验证码、文件操作、更新管理器等适配问题
- 补充 commitlint 配置项,新增 qqqf-admin 作用域
- 为 common sdk 补充类型定义与导出优化
parent fc794e28
export default { export default {
extends: ['@commitlint/config-conventional'], extends: ['@commitlint/config-conventional'],
rules: { rules: {
'scope-enum': [2, 'always', ["root", "*", "@common/sdk", "@common/utils", "@common/vue-kit", "official-site-web", "qqqf-mp"]], 'scope-enum': [2, 'always', ["root", "*", "@common/sdk", "@common/utils", "@common/vue-kit", "official-site-web", "qqqf-admin", "qqqf-mp"]],
'scope-empty': [2, 'never'], 'scope-empty': [2, 'never'],
}, },
} }
...@@ -7,5 +7,8 @@ ...@@ -7,5 +7,8 @@
"axios": "^0.27.2", "axios": "^0.27.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"uniplat-sdk": "^0.1.734-private" "uniplat-sdk": "^0.1.734-private"
},
"devDependencies": {
"@types/lodash": "^4.17.24"
} }
} }
...@@ -88,7 +88,7 @@ export function previewDocument(path: string): Promise<void> { ...@@ -88,7 +88,7 @@ export function previewDocument(path: string): Promise<void> {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (isVideo) { if (isVideo) {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
wx.previewMedia({ ;(globalThis as any).wx?.previewMedia({
sources: urls.map(e => ({ url: e, type: "video" })), sources: urls.map(e => ({ url: e, type: "video" })),
success: () => resolve(), success: () => resolve(),
fail: () => reject(), fail: () => reject(),
......
...@@ -17,7 +17,7 @@ export function buildImage( ...@@ -17,7 +17,7 @@ export function buildImage(
w = 0 w = 0
h = 0 h = 0
} }
const imageConfig = sdk.global.imageConfig const imageConfig = (sdk.global as any).imageConfig
if (imageConfig) { if (imageConfig) {
const { domain, protocol } = imageConfig const { domain, protocol } = imageConfig
const width = w !== undefined ? w : imageConfig.defaultWidth const width = w !== undefined ? w : imageConfig.defaultWidth
...@@ -39,7 +39,7 @@ export function buildFilePath( ...@@ -39,7 +39,7 @@ export function buildFilePath(
notForceDownload?: boolean, notForceDownload?: boolean,
): string { ): string {
if (!url) return "" if (!url) return ""
const fileConfig = sdk.global.fileConfig const fileConfig = (sdk.global as any).fileConfig
if (fileConfig && !notForceDownload) { if (fileConfig && !notForceDownload) {
const { domain, protocol } = fileConfig const { domain, protocol } = fileConfig
return `${protocol}://${domain}/download/${url}` return `${protocol}://${domain}/download/${url}`
......
...@@ -65,13 +65,14 @@ export { ClientId, Environment, UICore, wxDistributerUrlConfig } from "./ui-core ...@@ -65,13 +65,14 @@ export { ClientId, Environment, UICore, wxDistributerUrlConfig } from "./ui-core
export { uniAdapter } from "./uni-adapter" export { uniAdapter } from "./uni-adapter"
// 小程序版本更新 // 小程序版本更新
export { updateManager } from "./update-manager" export { updateManager } from "./update-manager"
// 验证码服务 // 验证码服务
export { verifyService } from "./verify-service" export { verifyService } from "./verify-service"
export type { VerifyImageResult } from "./verify-service" export type { VerifyImageResult } from "./verify-service"
export { webAdapter } from "./web-adapter"
// 微信登录服务 // 微信登录服务
export { wechatLoginService } from "./wechat-login-service" export { wechatLoginService } from "./wechat-login-service"
export type { RegisterChannel, WechatLoginResult } from "./wechat-login-service" export type { WechatLoginResult } from "./wechat-login-service"
...@@ -12,7 +12,6 @@ import { ...@@ -12,7 +12,6 @@ import {
} from "uniplat-sdk" } from "uniplat-sdk"
import { Tree } from "uniplat-sdk/build/main/model/tree/tree" import { Tree } from "uniplat-sdk/build/main/model/tree/tree"
import { buildFilePath, buildImage } from "./image-builder" import { buildFilePath, buildImage } from "./image-builder"
import { uniAdapter } from "./uni-adapter"
export class SdkCore { export class SdkCore {
private uniplatSdk!: UniplatSdk private uniplatSdk!: UniplatSdk
...@@ -20,22 +19,17 @@ export class SdkCore { ...@@ -20,22 +19,17 @@ export class SdkCore {
private token!: string private token!: string
public orgId!: number public orgId!: number
constructor(config?: SdkConfig) { constructor(config?: SdkConfig, adapter?: AxiosAdapter) {
if (config) { if (config) {
this.init(config) this.init(config, adapter)
} }
} }
public init(config: SdkConfig) { public init(config: SdkConfig, adapter?: AxiosAdapter) {
const baseUrl = config.uniplatApi const baseUrl = config.uniplatApi
this.uniplatSdk = new UniplatSdk({ sse: false }) this.uniplatSdk = new UniplatSdk({ sse: false })
this.uniplatSdk.global.baseUrl = baseUrl this.uniplatSdk.global.baseUrl = baseUrl
let adapter: AxiosAdapter | undefined
// #ifdef MP
adapter = uniAdapter
// #endif
this.uniplatSdk.connect({ this.uniplatSdk.connect({
baseUrl, baseUrl,
axiosAdapter: adapter, axiosAdapter: adapter,
...@@ -43,9 +37,7 @@ export class SdkCore { ...@@ -43,9 +37,7 @@ export class SdkCore {
refreshInterval: 10 * 60 * 1000, refreshInterval: 10 * 60 * 1000,
}) })
// #ifndef MP
this.uniplatSdk.getAxios().defaults.timeout = 10e3 this.uniplatSdk.getAxios().defaults.timeout = 10e3
// #endif
this.uniplatSdk.global.rootEntrance = config.rootEntrance this.uniplatSdk.global.rootEntrance = config.rootEntrance
} }
...@@ -136,4 +128,11 @@ export class SdkCore { ...@@ -136,4 +128,11 @@ export class SdkCore {
public decodeToken<T>(token: string) { public decodeToken<T>(token: string) {
return decodeJwt<T>(token) return decodeJwt<T>(token)
} }
/**
* 获取 Axios 实例(用于 Web 项目直接调用)
*/
public getAxios() {
return this.uniplatSdk.getAxios()
}
} }
import { last } from "lodash"
import { ref } from "vue" import { ref } from "vue"
const defaultShareData = { const defaultShareData = {
...@@ -23,10 +22,12 @@ export function setShareData(shareData: { ...@@ -23,10 +22,12 @@ export function setShareData(shareData: {
path?: string path?: string
imageUrl?: string imageUrl?: string
}) { }) {
const p = last(getCurrentPages()) const pages = getCurrentPages()
const p = pages[pages.length - 1]
const route = (p as any).route || ""
finalShareData.value = { finalShareData.value = {
...finalShareData.value, ...finalShareData.value,
[p!.route]: { [route]: {
...defaultShareData, ...defaultShareData,
...shareData, ...shareData,
}, },
...@@ -39,8 +40,10 @@ export function setShareData(shareData: { ...@@ -39,8 +40,10 @@ export function setShareData(shareData: {
export function createShareMixin() { export function createShareMixin() {
return { return {
onShareAppMessage() { onShareAppMessage() {
const p = last(getCurrentPages()) const pages = getCurrentPages()
return finalShareData.value[p!.route] || defaultShareData const p = pages[pages.length - 1]
const route = (p as any).route || ""
return finalShareData.value[route] || defaultShareData
}, },
} }
} }
...@@ -65,7 +65,7 @@ function transformError( ...@@ -65,7 +65,7 @@ function transformError(
), ),
) )
} else { } else {
reject(new AxiosError("Network Error", null, config, "")) reject(new AxiosError("Network Error", undefined, config, ""))
} }
} }
...@@ -86,7 +86,7 @@ export function uniAdapter(config: AxiosRequestConfig): AxiosPromise { ...@@ -86,7 +86,7 @@ export function uniAdapter(config: AxiosRequestConfig): AxiosPromise {
), ),
} as UniApp.RequestOptions } as UniApp.RequestOptions
const uniHeader: Record<string, string> = {} const uniHeader: Record<string, string> = {}
forEach(config.headers, (val, key) => { forEach(config.headers, (val: any, key: string) => {
const _header = key.toLowerCase() const _header = key.toLowerCase()
if ( if (
(typeof requestData === "undefined" && (typeof requestData === "undefined" &&
...@@ -94,7 +94,7 @@ export function uniAdapter(config: AxiosRequestConfig): AxiosPromise { ...@@ -94,7 +94,7 @@ export function uniAdapter(config: AxiosRequestConfig): AxiosPromise {
_header === "referer" _header === "referer"
) { ) {
} else { } else {
uniHeader[key] = val uniHeader[key] = String(val)
} }
}) })
uniConfig.header = uniHeader uniConfig.header = uniHeader
......
...@@ -5,14 +5,16 @@ import { ActionInvoker } from "./action-invoker" ...@@ -5,14 +5,16 @@ import { ActionInvoker } from "./action-invoker"
*/ */
export function updateManager(contentText: string) { export function updateManager(contentText: string) {
ActionInvoker.executeMp(() => { ActionInvoker.executeMp(() => {
const wx = (globalThis as any).wx
if (!wx) return
const updateManager = wx.getUpdateManager() const updateManager = wx.getUpdateManager()
updateManager.onCheckForUpdate((res) => { updateManager.onCheckForUpdate((res: any) => {
if (res.hasUpdate) { if (res.hasUpdate) {
updateManager.onUpdateReady(() => { updateManager.onUpdateReady(() => {
wx.showModal({ wx.showModal({
title: "更新提示", title: "更新提示",
content: contentText || "", content: contentText || "",
success(res) { success(res: any) {
if (res.confirm) { if (res.confirm) {
updateManager.applyUpdate() updateManager.applyUpdate()
} }
......
...@@ -23,7 +23,11 @@ export class VerifyService { ...@@ -23,7 +23,11 @@ export class VerifyService {
* 生成图形验证码 * 生成图形验证码
*/ */
public generateImage(): VerifyImageResult { public generateImage(): VerifyImageResult {
return UICore.core.getVerifyImageAndSeed() const result = UICore.core.getVerifyImageAndSeed() as any
return {
img: result.img,
seed: String(result.seed),
}
} }
/** /**
......
import type { AxiosPromise, AxiosRequestConfig, AxiosResponse } from "axios"
import { AxiosError } from "axios"
// @ts-ignore
import buildFullPath from "axios/lib/core/buildFullPath"
// @ts-ignore
import settle from "axios/lib/core/settle"
// @ts-ignore
import buildURL from "axios/lib/helpers/buildURL"
/**
* Web 平台 Axios 适配器
* 用于 Web 项目替代 uniAdapter
*/
export function webAdapter(config: AxiosRequestConfig): AxiosPromise {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const { method = "GET", data, headers, timeout } = config
const fullPath = buildURL(
buildFullPath(config.baseURL, config.url),
config.params,
config.paramsSerializer,
)
xhr.open(method, fullPath, true)
xhr.timeout = timeout || 10000
xhr.responseType = config.responseType as XMLHttpRequestResponseType || "json"
// 设置请求头
if (headers) {
Object.entries(headers).forEach(([key, value]) => {
if (value !== undefined) {
xhr.setRequestHeader(key, String(value))
}
})
}
xhr.onload = () => {
const responseHeaders: Record<string, string> = {}
xhr.getAllResponseHeaders().split("\r\n").forEach((line) => {
const [key, ...rest] = line.split(": ")
if (key) {
responseHeaders[key] = rest.join(": ")
}
})
const response: AxiosResponse = {
data: xhr.response,
status: xhr.status,
statusText: xhr.statusText,
headers: responseHeaders as any,
config,
request: xhr,
}
settle(resolve, reject, response)
}
xhr.onerror = () => {
reject(new AxiosError("Network Error", undefined, config, xhr))
}
xhr.ontimeout = () => {
reject(new AxiosError(`timeout of ${timeout}ms exceeded`, "ECONNABORTED", config, xhr))
}
xhr.send(data)
})
}
APP_ENV=dev
NODE_ENV=development
VITE_APP_ENV=dev
VITE_APP_ID=dev_app_id
VITE_DEBUG=true
VITE_APP_PAY_URL=https://payment-api.teammix.com
VITE_APP_WWW_WORK_APP_URL=http://106.120.107.150:5000
VITE_APP_API_WORK_APP_URL=http://106.120.107.150:8090
VITE_APP_API_WORK_ORG_URL=http://106.120.107.150:7771
VITE_APP_USER_CENTER=http://106.120.107.150:8080
VITE_APP_UNIPLAT=http://hro.test-api.qqxb.jinsehuaqin.com:8800
VITE_APP_QQXB=http://test-qqxb-h5.hrs100.com
VITE_APP_H5_URL=https://static.qinqinxiaobao.com/flb-mp
VITE_APP_LAND_PAGE=https://static.qinqinxiaobao.com/flb-mp
VITE_APP_BJRSY_COLLECTION=https://bjcjtest.e-tecsun.com
VITE_APP_XB_URL=http://test-qqxb-h5.hrs100.com
VITE_APP_CLIENT_ID=qqqf-admin-web
VITE_APP_UNIPLAT_WEBSOCKET_URI=ws://hro.channel.jinsehuaqin.com:8080/ws
VITE_APP_CLIENT_SECRET=123456
VITE_APP_LOG_ENV=0
APP_ENV=production
NODE_ENV=production
VITE_APP_ENV=prod
VITE_DEBUG=false
VITE_APP_PAY_URL="https://payment-api.teammix.com"
VITE_APP_WWW_WORK_APP_URL = "https://passport.teammix.com"
VITE_APP_API_WORK_APP_URL = "https://userapi.teammix.com"
VITE_APP_USER_CENTER = "http://tmxlogin.teammix.com"
VITE_APP_UNIPLAT = "https://api-hro.qinqinxiaobao.com"
VITE_APP_QQXB = "http://test-qqxb-h5.hrs100.com"
VITE_APP_H5_URL = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_LAND_PAGE = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_UNIPLAT_WEBSOCKET_URI = "wss://channel.qinqinxiaobao.com/ws"
VITE_APP_XB_URL="https://qqxb-h5.qinqinxiaobao.com"
VITE_APP_CLIENT_ID = "qqqf-admin-web"
VITE_APP_CLIENT_SECRET = "qqxb#teammix#2019"
VITE_APP_LOG_ENV = 1
APP_ENV=staging
NODE_ENV=production
VITE_APP_ENV=pre
VITE_APP_ID=pre_app_id
VITE_DEBUG=true
VITE_APP_PAY_URL="https://payment-api.teammix.com"
VITE_APP_WWW_WORK_APP_URL = "https://pre-passport.teammix.com"
VITE_APP_API_WORK_APP_URL = "https://pre-userapi.teammix.com"
VITE_APP_API_WORK_ORG_URL = "http://106.120.107.150:7771"
VITE_APP_USER_CENTER = "https://pre-user.teammix.com"
VITE_APP_UNIPLAT = "https://pre-api-hro.qinqinxiaobao.com"
VITE_APP_QQXB = "http://test-qqxb-h5.hrs100.com"
VITE_APP_H5_URL = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_LAND_PAGE = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_XB_URL="https://pre-qqxb-h5.hrs100.com"
VITE_APP_FLB_URL = "http://pre-flb-h5.hrs100.com"
VITE_APP_CLIENT_ID = "qqqf-admin-web"
VITE_APP_UNIPLAT_WEBSOCKET_URI = "wss://pre-channel.qinqinxiaobao.com/ws"
VITE_APP_CLIENT_SECRET = "qqxb#teammix#2019"
VITE_APP_LOG_ENV = 3
APP_ENV=test
NODE_ENV=production
VITE_APP_ENV=test
VITE_APP_ID=test_app_id
VITE_DEBUG=true
VITE_APP_PAY_URL="https://payment-api.teammix.com"
VITE_APP_WWW_WORK_APP_URL = "http://106.120.107.150:5000"
VITE_APP_API_WORK_APP_URL = "http://106.120.107.150:8090"
VITE_APP_API_WORK_ORG_URL = "http://106.120.107.150:7771"
VITE_APP_USER_CENTER = "http://106.120.107.150:8080"
VITE_APP_UNIPLAT = "http://hro.test-api.qqxb.jinsehuaqin.com:8800"
VITE_APP_QQXB = "http://test-qqxb-h5.hrs100.com"
VITE_APP_H5_URL = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_LAND_PAGE = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_BJRSY_COLLECTION = "https://bjcjtest.e-tecsun.com"
VITE_APP_XB_URL="http://test-qqxb-h5.hrs100.com"
VITE_APP_CLIENT_ID = "qqqf-admin-web"
VITE_APP_UNIPLAT_WEBSOCKET_URI = "ws://hro.channel.jinsehuaqin.com:8080/ws"
VITE_APP_CLIENT_SECRET = "123456"
VITE_APP_LOG_ENV = 0
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_ENV: string
readonly VITE_APP_ID: string
readonly VITE_APP_UNIPLAT: string
readonly VITE_APP_CLIENT_ID: string
readonly VITE_APP_CLIENT_SECRET: string
readonly VITE_APP_LOG_ENV: string
readonly VITE_APP_UNIPLAT_WEBSOCKET_URI: string
readonly VITE_DEBUG: string
readonly VITE_APP_PAY_URL: string
readonly VITE_APP_WWW_WORK_APP_URL: string
readonly VITE_APP_API_WORK_APP_URL: string
readonly VITE_APP_API_WORK_ORG_URL: string
readonly VITE_APP_USER_CENTER: string
readonly VITE_APP_QQXB: string
readonly VITE_APP_H5_URL: string
readonly VITE_APP_LAND_PAGE: string
readonly VITE_APP_XB_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>亲亲企服管理后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
{
"name": "qqqf-admin",
"type": "module",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"ant-design-vue": "^4.2.6",
"axios": "^1.7.9",
"pinia": "^2.1.7",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"typescript": "^5.6.3",
"unocss": "^0.58.0",
"vite": "^5.4.0",
"vue-tsc": "^2.1.0"
}
}
<template>
<div id="app">
<RouterView />
</div>
</template>
import type { LoginRegisterResult, VerifyImageResult } from "@common/sdk"
import { loginService, verifyService } from "@common/sdk"
export type { LoginRegisterResult, VerifyImageResult }
/**
* 获取图形验证码
*/
export function getVerifyImage(): VerifyImageResult {
return verifyService.generateImage()
}
/**
* 发送短信验证码
*/
export function sendSmsCode(mobile: string, seed: string, verifycode: string) {
return verifyService.sendVerifyCode(mobile, seed, verifycode)
}
/**
* 验证码登录
*/
export function smsLogin(mobile: string, verifycode: string) {
return loginService.verifyCodeLogin(mobile, verifycode)
}
/**
* 密码登录
*/
export function passwordLogin(username: string, password: string) {
return loginService.login(username, password)
}
import type { SdkConfig } from '@common/sdk'
import { ClientId, PassportTokenController, SdkCore, UICore, webAdapter } from '@common/sdk'
/**
* qqqf-admin SDK 配置
*/
const sdkConfig: SdkConfig = {
uniplatApi: import.meta.env.VITE_APP_UNIPLAT,
rootEntrance: 'qqqf-admin',
logEnv: import.meta.env.VITE_APP_LOG_ENV,
clientId: import.meta.env.VITE_APP_CLIENT_ID,
domainService: {
subProjectName: 'qqqf',
serviceName: 'api',
},
}
/**
* 创建并初始化 SDK
*/
export const sdk = new SdkCore(sdkConfig, webAdapter)
/**
* 注册 Web 适配器到 UICore
*/
UICore.setupSdk(sdk.core, {
client: ClientId.None,
config: webAdapter,
})
/**
* 初始化 Token(如果有)
*/
export function initToken() {
const token = PassportTokenController.hasToken()
if (token) {
sdk.core.loginByToken({
token: token as string,
})
}
return token
}
/**
* 获取 SDK 实例
*/
export function getSdk() {
return sdk
}
/**
* 获取 UICore 实例
*/
export function getUICore() {
return UICore
}
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import { initToken } from './api/request'
import App from './App.vue'
import router from './router'
// 初始化 SDK 和 Token(已在 api/request.ts 中封装)
initToken()
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
<template>
<div class="home-container">
<h1>qqqf-admin</h1>
<button class="test-btn" @click="testApiCall">测试 SDK 请求(携带 Token)</button>
<div v-if="result" class="result">
<h3>请求结果:</h3>
<pre>{{ result }}</pre>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { sdk } from '@/src/api/request'
const result = ref<any>(null)
async function testApiCall() {
try {
// 调用一个匿名接口测试
const res = await sdk.core
.domainService('qqqf', 'anonymous/api', 'test')
.get()
result.value = res
}
catch (e: any) {
result.value = { error: e?.message || '请求失败' }
}
}
</script>
<style scoped>
.home-container {
padding: 20px;
}
.test-btn {
padding: 12px 24px;
background: #667eea;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.result {
margin-top: 20px;
padding: 16px;
background: #f5f5f5;
border-radius: 4px;
}
.result pre {
white-space: pre-wrap;
word-break: break-all;
}
</style>
<template>
<div class="login-container">
<div class="login-card">
<h1 class="login-title">亲亲企服管理后台</h1>
<!-- 登录方式切换 -->
<div class="login-tabs">
<div
class="tab-item"
:class="{ active: loginType === 'sms' }"
@click="loginType = 'sms'"
>
验证码登录
</div>
<div
class="tab-item"
:class="{ active: loginType === 'password' }"
@click="loginType = 'password'"
>
密码登录
</div>
</div>
<!-- 验证码登录表单 -->
<form v-if="loginType === 'sms'" class="login-form" @submit.prevent="handleSmsLogin">
<div class="form-item">
<input
v-model="smsForm.mobile"
class="form-input"
type="tel"
placeholder="请输入手机号"
maxlength="11"
/>
</div>
<div class="form-item form-item-row">
<input
v-model="smsForm.verifycode"
class="form-input flex-1"
type="text"
placeholder="请输入图形验证码"
/>
<img
v-if="verifyImage.img"
:src="verifyImage.img"
class="verify-img"
alt="验证码"
@click="refreshVerifyImage"
/>
</div>
<div class="form-item form-item-row">
<input
v-model="smsForm.smsCode"
class="form-input flex-1"
type="text"
placeholder="请输入短信验证码"
maxlength="6"
/>
<button
class="sms-btn"
type="button"
:disabled="countdown > 0 || !smsForm.mobile"
@click="sendSms"
>
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</button>
</div>
<button class="submit-btn" type="submit" :disabled="smsLoading">
{{ smsLoading ? '登录中...' : '登录' }}
</button>
</form>
<!-- 密码登录表单 -->
<form v-else class="login-form" @submit.prevent="handlePasswordLogin">
<div class="form-item">
<input
v-model="passwordForm.username"
class="form-input"
type="text"
placeholder="请输入用户名/手机号"
/>
</div>
<div class="form-item">
<input
v-model="passwordForm.password"
class="form-input"
type="password"
placeholder="请输入密码"
/>
</div>
<button class="submit-btn" type="submit" :disabled="passwordLoading">
{{ passwordLoading ? '登录中...' : '登录' }}
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getVerifyImage, passwordLogin, sendSmsCode, smsLogin } from '@/src/api/auth'
import { useAuthStore } from '@/src/stores/auth'
import { Toast } from '@/src/utils/toast'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const loginType = ref<'sms' | 'password'>('sms')
// 验证码登录表单
const smsForm = ref({
mobile: '',
verifycode: '',
smsCode: '',
})
// 密码登录表单
const passwordForm = ref({
username: '',
password: '',
})
const smsLoading = ref(false)
const passwordLoading = ref(false)
const countdown = ref(0)
const verifyImage = ref({ img: '', seed: '' })
let timer: ReturnType<typeof setInterval> | null = null
// 获取图形验证码
async function refreshVerifyImage() {
try {
const res = await getVerifyImage()
verifyImage.value = res as any
}
catch (e) {
Toast.error('获取图形验证码失败')
}
}
// 发送短信验证码
async function sendSms() {
if (!smsForm.value.mobile) {
Toast.warning('请输入手机号')
return
}
if (!/^1\d{10}$/.test(smsForm.value.mobile)) {
Toast.warning('手机号格式不正确')
return
}
if (!smsForm.value.verifycode) {
Toast.warning('请输入图形验证码')
return
}
try {
await sendSmsCode(
smsForm.value.mobile,
verifyImage.value.seed,
smsForm.value.verifycode,
)
Toast.success('验证码已发送')
// 开始倒计时
countdown.value = 60
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0 && timer) {
clearInterval(timer)
timer = null
}
}, 1000)
}
catch (e: any) {
Toast.error(e?.message || '发送失败')
}
}
// 验证码登录
async function handleSmsLogin() {
if (!smsForm.value.mobile) {
Toast.warning('请输入手机号')
return
}
if (!smsForm.value.smsCode) {
Toast.warning('请输入验证码')
return
}
smsLoading.value = true
try {
const res = await smsLogin(smsForm.value.mobile, smsForm.value.smsCode)
const result = res as any
authStore.setToken(result.jwt)
Toast.success('登录成功')
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
}
catch (e: any) {
Toast.error(e?.message || '登录失败')
}
finally {
smsLoading.value = false
}
}
// 密码登录
async function handlePasswordLogin() {
if (!passwordForm.value.username) {
Toast.warning('请输入用户名')
return
}
if (!passwordForm.value.password) {
Toast.warning('请输入密码')
return
}
passwordLoading.value = true
try {
const res = await passwordLogin(
passwordForm.value.username,
passwordForm.value.password,
)
const result = res as any
authStore.setToken(result.jwt)
Toast.success('登录成功')
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
}
catch (e: any) {
Toast.error(e?.message || '登录失败')
}
finally {
passwordLoading.value = false
}
}
// 初始化图形验证码
refreshVerifyImage()
</script>
<style scoped>
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
padding: 40px;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.login-title {
text-align: center;
font-size: 24px;
font-weight: 600;
color: #333;
margin: 0 0 30px;
}
.login-tabs {
display: flex;
margin-bottom: 24px;
border-bottom: 1px solid #eee;
}
.tab-item {
flex: 1;
text-align: center;
padding: 12px 0;
font-size: 16px;
color: #666;
cursor: pointer;
transition: all 0.3s;
}
.tab-item.active {
color: #667eea;
border-bottom: 2px solid #667eea;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-item {
width: 100%;
}
.form-item-row {
display: flex;
gap: 12px;
align-items: center;
}
.form-input {
width: 100%;
height: 44px;
padding: 0 16px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
outline: none;
transition: border-color 0.3s;
box-sizing: border-box;
}
.form-input:focus {
border-color: #667eea;
}
.flex-1 {
flex: 1;
}
.verify-img {
width: 120px;
height: 44px;
cursor: pointer;
border-radius: 4px;
}
.sms-btn {
width: 110px;
height: 44px;
border: 1px solid #667eea;
border-radius: 4px;
background: #fff;
color: #667eea;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
white-space: nowrap;
}
.sms-btn:disabled {
border-color: #ccc;
color: #ccc;
cursor: not-allowed;
}
.submit-btn {
width: 100%;
height: 44px;
border: none;
border-radius: 4px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
font-size: 16px;
cursor: pointer;
transition: opacity 0.3s;
margin-top: 8px;
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
import type { RouteRecordRaw } from 'vue-router'
import { PassportTokenController } from '@common/sdk'
import { createRouter, createWebHistory } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('../pages/login/index.vue'),
meta: { title: '登录', requiresAuth: false },
},
{
path: '/',
name: 'Home',
component: () => import('../pages/index.vue'),
meta: { title: '首页', requiresAuth: true },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, _from, next) => {
document.title = (to.meta.title as string) || '亲亲企服管理后台'
const token = PassportTokenController.hasToken()
if (to.meta.requiresAuth && !token) {
next({ path: '/login', query: { redirect: to.fullPath } })
}
else {
next()
}
})
export default router
import type { UserInfo } from '../types/auth'
import { PassportTokenController } from '@common/sdk'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string>(PassportTokenController.hasToken() || '')
const userInfo = ref<UserInfo | null>(null)
function setToken(newToken: string) {
token.value = newToken
PassportTokenController.saveToken2Storage(newToken)
}
function setUserInfo(info: UserInfo) {
userInfo.value = info
}
function logout() {
token.value = ''
userInfo.value = null
PassportTokenController.clearToken()
}
return { token, userInfo, setToken, setUserInfo, logout }
})
export interface UserInfo {
id: string
username: string
mobile?: string
avatar?: string
[key: string]: any
}
/**
* Toast 提示工具
* 替代 alert 弹窗,提供优雅的 Web Toast 提示
*/
interface ToastOptions {
message: string
type?: 'success' | 'error' | 'warning' | 'info'
duration?: number
}
let toastContainer: HTMLElement | null = null
let toastTimer: ReturnType<typeof setTimeout> | null = null
function getContainer() {
if (!toastContainer) {
toastContainer = document.createElement('div')
toastContainer.className = 'toast-container'
document.body.appendChild(toastContainer)
}
return toastContainer
}
function showToast(options: ToastOptions) {
const { message, type = 'info', duration = 2000 } = options
const container = getContainer()
// 清除之前的 toast
if (toastTimer) {
clearTimeout(toastTimer)
}
container.innerHTML = ''
const toast = document.createElement('div')
toast.className = `toast toast-${type}`
toast.textContent = message
container.appendChild(toast)
// 触发显示动画
requestAnimationFrame(() => {
toast.classList.add('toast-show')
})
// 自动隐藏
toastTimer = setTimeout(() => {
toast.classList.remove('toast-show')
setTimeout(() => {
container?.removeChild(toast)
}, 300)
}, duration)
}
export const Toast = {
success(message: string, duration?: number) {
showToast({ message, type: 'success', duration })
},
error(message: string, duration?: number) {
showToast({ message, type: 'error', duration })
},
warning(message: string, duration?: number) {
showToast({ message, type: 'warning', duration })
},
info(message: string, duration?: number) {
showToast({ message, type: 'info', duration })
},
}
// 注入全局样式
if (typeof document !== 'undefined' && !document.getElementById('toast-styles')) {
const style = document.createElement('style')
style.id = 'toast-styles'
style.textContent = `
.toast-container {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
pointer-events: none;
}
.toast {
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
color: #fff;
background: rgba(0, 0, 0, 0.8);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s ease;
white-space: nowrap;
max-width: 80vw;
overflow: hidden;
text-overflow: ellipsis;
}
.toast-show {
opacity: 1;
transform: translateY(0);
}
.toast-success {
background: #52c41a;
}
.toast-error {
background: #ff4d4f;
}
.toast-warning {
background: #faad14;
}
.toast-info {
background: #1890ff;
}
`
document.head.appendChild(style)
}
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": [
"ESNext",
"DOM"
],
"module": "ESNext",
"moduleResolution": "bundler",
"paths": {
"@/*": [
"./*"
]
},
"resolveJsonModule": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts",
"src/**/*.vue",
"src/**/*.tsx",
"env.d.ts"
],
"exclude": [
"node_modules",
"dist",
"**/*.js"
]
}
import { resolve } from 'node:path'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, '.'),
},
},
build: {
outDir: 'dist',
},
})
import { SdkCore } from "@common/sdk" import { SdkCore, uniAdapter } from "@common/sdk"
import { config } from "@/config" import { config } from "@/config"
class Sdk extends SdkCore { class Sdk extends SdkCore {
constructor() { constructor() {
super(config) super(config, uniAdapter)
} }
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment