v2 refacting

This commit is contained in:
simple321vip
2026-06-28 23:02:32 +08:00
parent 4da6832b44
commit 024f306636
14 changed files with 360 additions and 90 deletions
+7 -4
View File
@@ -6,12 +6,15 @@
VITE_MODE_NAME=development
# 资源 CDN 前缀
VITE_RES_URL=http://localhost:3000/
VITE_RES_URL=http://localhost:5173/
# 后端 API 基础地址
# dev: http://localhost:8080/auth
# ⚠️ 本地必须写前端自己(http://localhost:5173),由 Vite proxy 转发
# 写 http://localhost:8080/auth 会让浏览器绕过 proxy 直连 8080
# dev: http://localhost:5173
# dev (K8s): https://dev.violin-home.cn
# prod: https://www.violin-home.cn
VITE_APP_URL=http://localhost:8080/auth
VITE_APP_URL=http://localhost:5173
# 应用元信息
VITE_APP_TITLE=violin-home
@@ -35,7 +38,7 @@ VITE_OIDC_ISSUER=https://auth.violin-work.online
VITE_OIDC_CLIENT_ID=
# 回调 URI(必须与 Authentik 后台配置的完全一致)
# dev: http://localhost:3000/auth/callback
# dev: http://localhost:5173/auth/callback
# prod: https://www.violin-home.cn/auth/callback
VITE_OIDC_REDIRECT_URI=
+1 -1
View File
@@ -14,7 +14,7 @@
"copy-to-clipboard": "^3.3.3",
"dayjs": "^1.11.13",
"echarts": "^5.5.1",
"element-plus": "^2.8.4",
"element-plus": "^2.14.1",
"js-cookie": "^3.0.5",
"lodash-es": "^4.17.21",
"md-editor-v3": "^4.20.1",
+1 -1
View File
@@ -16,7 +16,7 @@
"copy-to-clipboard": "^3.3.3",
"dayjs": "^1.11.13",
"echarts": "^5.5.1",
"element-plus": "^2.8.4",
"element-plus": "^2.14.1",
"js-cookie": "^3.0.5",
"lodash-es": "^4.17.21",
"md-editor-v3": "^4.20.1",
+35 -11
View File
@@ -1,26 +1,50 @@
import { service } from '../utils/request'
const headers = {
'Content-Type': 'application/json;charsetset=UTF-8'
}
const put_event = (data: Object) => {
/**
* 日历事件 API
* 对应后端 CalendarController: /api/v1/events
*/
const post_event = (data: {
eventDate: string
title: string
eventInfo?: string
}) => {
return service({
url: '/calendar/api/v1/event',
method: 'PUT',
data: data
url: '/calendar/api/v1/events',
method: 'POST',
data
})
}
const get_event = () => {
return service({
url: '/calendar/api/v1/event',
method: 'GET',
headers: headers
url: '/calendar/api/v1/events',
method: 'GET'
})
}
const put_event = (id: number, data: {
eventDate: string
title: string
eventInfo?: string
}) => {
return service({
url: `/calendar/api/v1/events/${id}`,
method: 'PUT',
data
})
}
const delete_event = (id: number) => {
return service({
url: `/calendar/api/v1/events/${id}`,
method: 'DELETE'
})
}
export {
get_event,
post_event,
put_event,
delete_event,
}
+54 -12
View File
@@ -12,12 +12,14 @@
<MainApp></MainApp>
</el-main>
<el-footer>
<span>
管祥玮的个人网站 ©Copyright 2022-2022
</span>
<span @click="openSiteQuery">辽ICP备2022003637号-2</span>
<div>
<el-avatar @click="openGithub" src="https://github.githubassets.com/favicons/favicon.svg" />
<div class="footer-left">
<span>{{ SITE_NAME }} {{ COPYRIGHT_TEXT }}</span>
<span class="separator">|</span>
<span class="icp-link" @click="openSiteQuery">{{ ICP_NUMBER }}</span>
</div>
<div class="footer-right">
<el-avatar :size="24" class="github-icon" @click="openGithub"
src="https://github.githubassets.com/favicons/favicon.svg" />
</div>
</el-footer>
</el-container>
@@ -30,6 +32,7 @@ import MainApp from '../layout/MainApp.vue'
import NavBar from "../layout/NavBar.vue"
import SideBar from "../layout/SideBar.vue"
import { settingsStore } from "@/store/modules/settings"
import { SITE_NAME, COPYRIGHT_TEXT, ICP_NUMBER, ICP_QUERY_URL } from "@/const/site"
// -- IMPORT --
const useSettingsStore = settingsStore()
@@ -62,8 +65,7 @@ const openGithub = () => {
}
const openSiteQuery = () => {
let href = 'https://beian.miit.gov.cn/#/Integrated/index'
window.open(href, '_blank')
window.open(ICP_QUERY_URL, '_blank')
}
/**
@@ -81,9 +83,12 @@ iniPage()
}
.el-main {
/* 让 main 区占满 header 与 footer 之间的剩余空间,
内容多了 main 内部滚动,footer 永远钉在 layout 底部 */
padding: 16px;
overflow: hidden;
height: 100%;
flex: 1;
min-height: 0; /* flex item 默认 min-height: auto 会阻止收缩 */
overflow-y: auto;
}
.el-aside {
@@ -93,13 +98,50 @@ iniPage()
}
.el-footer {
/* flex: 0 0 auto 让 footer 保持自身高度(不撑开、不收缩) */
flex: 0 0 auto;
margin-top: 20px;
display: flex;
justify-content: space-around;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
border-top: 1px solid rgba(151, 151, 151, 0.2);
font-size: 16px;
color: #666;
}
.footer-left {
display: flex;
align-items: center;
gap: 8px;
}
.footer-right {
display: flex;
align-items: center;
}
.separator {
color: #ccc;
}
.icp-link {
cursor: pointer;
user-select: none;
}
.icp-link:hover {
color: #409eff;
}
.github-icon {
/* 跟 footer 文字 baseline 对齐 */
vertical-align: middle;
cursor: pointer;
}
.main_container {
/* height: 100%; */
height: 100%;
}
.homeWrap {
+63 -6
View File
@@ -18,10 +18,15 @@
</div>
</el-card>
<div class="footer">
<span>
管祥玮的个人网站 ©Copyright 2022-2022
</span>
<span @click="openSiteQuery">辽ICP备2022003637号-2</span>
<div class="footer-left">
<span>{{ SITE_NAME }} {{ COPYRIGHT_TEXT }}</span>
<span class="separator">|</span>
<span class="icp-link" @click="openSiteQuery">{{ ICP_NUMBER }}</span>
</div>
<div class="footer-right">
<el-avatar :size="24" class="github-icon" @click="openGithub"
src="https://github.githubassets.com/favicons/favicon.svg" />
</div>
</div>
</div>
</template>
@@ -29,6 +34,7 @@
<script lang="ts" setup>
import { reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { SITE_NAME, COPYRIGHT_TEXT, ICP_NUMBER, ICP_QUERY_URL } from '@/const/site'
const handleOidcLogin = () => {
const issuer = import.meta.env.VITE_OIDC_ISSUER
@@ -49,8 +55,11 @@ const handleOidcLogin = () => {
}
const openSiteQuery = () => {
let href = 'https://beian.miit.gov.cn/#/Integrated/index'
window.open(href, '_blank')
window.open(ICP_QUERY_URL, '_blank')
}
const openGithub = () => {
window.open('https://github.com/simple321vip', '_blank')
}
const style = reactive({} as any)
@@ -67,6 +76,9 @@ style.width = window.innerWidth * 0.7 + 'px'
}
.ttt {
/* flex column + min-height 100vhfooter 永远在视口底部,
内容少时 footer 贴底,内容多时 footer 在内容之后但不漂 */
min-height: 100vh;
display: flex;
flex-direction: column;
}
@@ -79,4 +91,49 @@ style.width = window.innerWidth * 0.7 + 'px'
background: url('../../assets/have_a_nice_day.jpeg') center top no-repeat;
background-size: cover;
}
.box-card {
/* 中间内容区撑开剩余空间 */
flex: 1;
}
.footer {
flex: 0 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
border-top: 1px solid rgba(151, 151, 151, 0.2);
font-size: 16px;
color: #666;
}
.footer-left {
display: flex;
align-items: center;
gap: 8px;
}
.footer-right {
display: flex;
align-items: center;
}
.separator {
color: #ccc;
}
.icp-link {
cursor: pointer;
user-select: none;
}
.icp-link:hover {
color: #409eff;
}
.github-icon {
vertical-align: middle;
cursor: pointer;
}
</style>
+20
View File
@@ -0,0 +1,20 @@
/**
* 站点元信息常量。
*
* <p>⚠️ ICP 备案号是公网安部要求强制展示的,<b>不能修改</b>。文案变更需重新备案。</p>
*/
/** 产品名(footer / 浏览器 title / 侧边栏使用) */
export const SITE_NAME = 'Violin Home'
/** 版权年份区间(自动取当前年) */
export const COPYRIGHT_YEARS = `2022-${new Date().getFullYear()}`
/** 完整版权字符串(footer 用) */
export const COPYRIGHT_TEXT = `©Copyright ${COPYRIGHT_YEARS}`
/** ICP 备案号(⚠️ 公网安部要求原样显示,不可修改) */
export const ICP_NUMBER = '辽ICP备2022003637号-2'
/** ICP 备案查询 URL(公网安部要求链接到工信部查询页) */
export const ICP_QUERY_URL = 'https://beian.miit.gov.cn/#/Integrated/index'
+8 -4
View File
@@ -19,11 +19,15 @@ type DataTimeline = {
message: string,
}
// 与后端 CalendarEventResponse 对齐(camelCase
type Event = {
reminder_date: string,
title: string,
info: string,
type: string[],
id?: number
customerId?: string
eventDate: string
title: string
eventInfo?: string
createdAt?: string
updatedAt?: string
}
interface Wiki {
+2 -2
View File
@@ -1,4 +1,4 @@
import { createRouter, createMemoryHistory, RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
const login = () => import('@/components/login/login.vue')
const home = () => import('@/view/dashboard/index.vue')
@@ -134,7 +134,7 @@ const routes: Array<RouteRecordRaw> = [
const router = createRouter({
history: createMemoryHistory(),
history: createWebHistory(),
routes
})
+41 -5
View File
@@ -1,8 +1,8 @@
import axios from 'axios'
import router from '../router'
import { getToken, getCustomer, resetToken } from '../utils/auth'
import { ElMessage } from 'element-plus'
// violin-wiki
const service = axios.create(
{
baseURL: import.meta.env.VITE_APP_URL as string,
@@ -13,8 +13,12 @@ const service = axios.create(
service.interceptors.request.use(
config => {
if (typeof getToken() === 'string') {
(config as any).headers['Authorization'] = 'Bearer:' + getToken()
// ⚠️ Bearer 后必须有空格,否则后端 JWT 解析器会识别失败 → 401
(config as any).headers['Authorization'] = 'Bearer ' + getToken()
}
// 当前域名 → 后端按域名解析 customerlocal 自动归 devprod 必须 admin 预先注册)
;(config as any).headers['X-Customer-Domain'] = window.location.hostname
const customer = getCustomer()
if (typeof customer === 'string') {
(config as any).headers['customerId'] = JSON.parse(customer).customerId
@@ -26,16 +30,48 @@ service.interceptors.request.use(
service.interceptors.response.use(
(response) => {
const data = response.data
// 后端 ApiResponse / ErrorResponse 结构:{ code, message, ... }
// 业务 code 非 0 / AUTH_401 等都按错误处理
if (data && typeof data === 'object' && 'code' in data) {
if (data.code === 0 || data.code === '0' || data.code === 200) {
return response
}
// 401 业务码:跳登录
if (data.code === 'AUTH_401' || data.code === 'AUTH_CONTEXT_MISSING') {
ElMessage.warning('登录已过期,请重新登录')
resetToken()
router.replace('/login').catch(() => {})
return Promise.reject(new Error(data.message || '未登录'))
}
// 其他业务错误:弹错
ElMessage.error(data.message || `请求失败 (${data.code})`)
return Promise.reject(new Error(data.message || '请求失败'))
}
return response
},
(error) => {
if (error && error.response) {
if (error.response.status == 401) {
const status = error.response.status
const body = error.response.data
if (status === 401) {
// HTTP 401:登录失效
const msg = (body && body.message) || '登录已过期,请重新登录'
ElMessage.warning(msg)
resetToken()
router.resolve({ path: '/' });
return
router.replace('/login').catch(() => {})
} else if (status >= 500) {
ElMessage.error((body && body.message) || '服务器内部错误')
} else if (status >= 400) {
ElMessage.error((body && body.message) || `请求错误 (${status})`)
} else {
ElMessage.error('网络异常')
}
} else {
// 无 response:网络层 / CORS / 超时
ElMessage.error(error.message || '网络异常')
}
return Promise.reject(error)
}
)
+6 -3
View File
@@ -34,10 +34,13 @@ onMounted(async () => {
}
})
setToken(res.data.token)
// 后端 ApiResponse 结构:{ code, message, data: { token, sub, username, ... } }
// 业务字段在 data.data 下
const payload = (res.data && res.data.data) || res.data
setToken(payload.token)
setCustomer({
customerId: res.data.sub,
account: res.data.username
customerId: payload.customerId || payload.sub,
account: payload.username
})
sessionStorage.removeItem('oidc_state')
router.push('/')
+49 -31
View File
@@ -1,44 +1,47 @@
<template>
<el-calendar v-model="value" v-if="isComponentActive" v-loading="isComponentLoading"
element-loading-text="主人别着急我在努力加载中^_^">
<template #dateCell="{ data }" lo>
<div class="date-cell-holiday" v-if="Object.keys(holidays).includes(toLocalDate(data.date))">
<span style="color: red;" @click="onclick(data.date)"> {{ data.date.getDate() }}</span><br>
<span>{{ holidays[toLocalDate(data.date)] }}</span>
<template #date-cell="{ data }">
<div class="date-cell-holiday"
v-if="Object.keys(holidays).includes(data.day)"
@click="onclick(data.date)"
style="cursor: pointer;">
<span style="color: red;">{{ data.day.split('-')[2] }}</span><br>
<span>{{ holidays[data.day] }}</span>
</div>
<div class="date-cell" v-else>
<span style="color: red;" v-if="data.date.getDay() == 0 || data.date.getDay() == 6" @click="onclick(data.date)">
{{
data.date.getDate()
}}</span>
<span v-else @click="onclick(data.date)"> {{ data.date.getDate() }}</span>
<div class="date-cell" v-else @click="onclick(data.date)" style="cursor: pointer;">
<span style="color: red;" v-if="data.date.getDay() == 0 || data.date.getDay() == 6">
{{ data.day.split('-')[2] }}
</span>
<span v-else> {{ data.day.split('-')[2] }}</span>
</div>
<div class="date-cell-event" v-if="Object.keys(eventDataObject).includes(toLocalDate(data.date))">
<span>{{ eventDataObject[toLocalDate(data.date)] }}</span>
<div class="date-cell-event" v-if="Object.keys(eventDataObject).includes(data.day)">
<span>{{ eventDataObject[data.day] }}</span>
</div>
</template>
</el-calendar>
<span>待办事项</span>
<el-table :data="eventDataList">
<el-table-column property="reminder_date" label="Date" width="150" />
<el-table-column property="eventDate" label="Date" width="150" />
<el-table-column property="title" label="title" width="200" />
<el-table-column property="reminder_info" label="eventInfo" />
<el-table-column property="eventInfo" label="eventInfo" />
</el-table>
<el-dialog v-model="dialogFormVisible" title="待办事项">
<el-form :model="form">
<el-form-item label="日期" :label-width="formLabelWidth">
<span>{{ form.reminder_date }}</span>
<span>{{ form.eventDate }}</span>
</el-form-item>
<el-form-item label="title" :label-width="formLabelWidth">
<el-input v-model="form.title" autocomplete="off" />
</el-form-item>
<el-form-item label="eventInfo" :label-width="formLabelWidth">
<el-input v-model="form.reminder_info" autocomplete="off" type="textarea" />
<el-input v-model="form.eventInfo" autocomplete="off" type="textarea" />
</el-form-item>
<el-form-item label="alert" :label-width="formLabelWidth">
<el-checkbox-group v-model="checkedalerts" :min="0" :max="2">
<el-checkbox v-for="city in alerts" :key="city" :label="city">{{
<el-checkbox v-for="city in alerts" :key="city" :value="city">{{
city
}}</el-checkbox>
</el-checkbox-group>
@@ -59,24 +62,24 @@
import { nextTick, onMounted, reactive, ref } from 'vue'
import { get_holiday } from '@/service/calendar'
import { toLocalDate } from '@/utils/date'
import { get_event, put_event } from "@/api/calendar"
import { Event } from "@/entity/index"
import { settingsStore } from "@/store/modules/settings";
import { get_event, post_event, put_event, delete_event } from '@/api/calendar'
import { Event } from '@/entity/index'
import { settingsStore } from '@/store/modules/settings'
// -- IMPORT --
const useSettingsStore = settingsStore()
// -- REACTIVE OBJECT --
const holidays = reactive<any>({})
const holidays = reactive<Record<string, string>>({})
const eventDataList = reactive<Event[]>([])
const eventDataObject = reactive<any>({})
const eventDataObject = reactive<Record<string, string>>({})
const form = reactive({
reminder_date: '',
// 与后端 CalendarEventRequest 对齐(camelCase
eventDate: '',
title: '',
reminder_info: '',
eventInfo: '',
})
// -- REF OBJECT --
const value = ref(new Date())
@@ -90,13 +93,15 @@ const alerts = ['email', 'messageBox', '手机短信', '微信通知']
// -- EVENT DEFINITION
const onclick = (date: Date) => {
form.reminder_date = toLocalDate(date)
form.eventDate = toLocalDate(date)
dialogFormVisible.value = true
}
const confirm = async () => {
await put_event(form).then(() => {
await post_event({
eventDate: form.eventDate,
title: form.title,
eventInfo: form.eventInfo,
})
dialogFormVisible.value = false
handleCommand('refresh')
@@ -121,19 +126,32 @@ const handleCommand = (command: string) => {
* AUTO INVOKE FUNCTION
*/
onMounted(async () => {
// 先显示日历(避免数据加载失败阻塞日历渲染)
isComponentActive.value = true
await get_holiday().then((resp) => {
// holidays-jp.github.io 直接返回 JSON 对象,不是 ApiResponse 结构
Object.keys(resp.data).forEach(element => {
holidays[element] = resp.data[element]
})
}).catch(() => {
// 节假日数据失败不影响日历显示
})
await get_event().then((response) => {
// 后端 ApiResponse 结构:{code, message, data: [...]}
const list = response.data?.data ?? response.data
eventDataList.length = 0
response.data.forEach((record: Event) => {
if (Array.isArray(list)) {
list.forEach((record: Event) => {
eventDataList.push(record)
eventDataObject[record.reminder_date] = record.title
eventDataObject[record.eventDate] = record.title
})
}
}).catch(() => {
// 业务异常已经被 axios 拦截器 toast 了
})
isComponentActive.value = true
isComponentLoading.value = false
})
+67 -4
View File
@@ -7,16 +7,19 @@ import { fileURLToPath, URL } from 'url'
// https://vitejs.dev/guide/env-and-mode.html#env-files
export default defineConfig(({ mode, command }) => {
const env = loadEnv(mode, process.cwd())
// mock 开关:仅 servedev)模式 + .env.local 里 VITE_USE_MOCK=true
// 关掉时直接不注册 viteMockServe 插件(避免 mock routes 被注册)
const useMock = command === 'serve' && env.VITE_USE_MOCK === 'true'
return {
plugins: [
vue(),
// gzip 压缩
viteCompression(),
// 本地 mock 服务(仅 dev 启用,prod 不引入)
viteMockServe({
// 本地 mock 服务(由 VITE_USE_MOCK 控制;prod / 关 mock 时不引入)
...(useMock ? [viteMockServe({
mockPath: 'mock',
localEnabled: command === 'serve',
}),
localEnabled: true,
})] : []),
],
base: env.VITE_RES_URL,
resolve: {
@@ -31,6 +34,66 @@ export default defineConfig(({ mode, command }) => {
}
}
},
// ============================================================
// dev server proxy —— 本地多后端联调
// ============================================================
// ⚠️ 关键:proxy key 必须用 /api/ 后缀,避免拦截 SPA 路由
// 例如 /bookmark/api/v1/... → 8086(后端 API
// 但 /bookmark → 保留在前端(SPA 路由,由 history fallback 处理)
//
// 调试建议:浏览器开 Network,看 Server 响应头确认走了哪个后端
//
// 端口对齐:
// violin-auth: 8080 (context-path: /auth)
// violin-calendar: 8085 (context-path: /calendar)
// violin-bookmark: 8086 (context-path: /bookmark, 待你确认)
// violin-onenote: 8087 (context-path: /onenote, 待你确认)
// ... 新增服务时在这里加一行
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
// auth: 业务 API 在 /api/v1/auth/...,没有 /auth/api 前缀
// 用 rewrite 把 /api/v1/auth/* 转成 /auth/api/v1/auth/*
// 后端 context-path 是 /auth,所以请求路径需要带 /auth 前缀
'^/api/v1/auth/.*': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/v1\/auth/, '/auth/api/v1/auth'),
},
// 其他服务:path 自带服务名前缀(/calendar/api/...
'^/calendar/api/.*': {
target: 'http://localhost:8085',
changeOrigin: true,
},
'^/bookmark/api/.*': {
target: 'http://localhost:8086',
changeOrigin: true,
},
'^/onenote/api/.*': {
target: 'http://localhost:8087',
changeOrigin: true,
},
'^/trader/api/.*': {
target: 'http://localhost:8088',
changeOrigin: true,
},
'^/traderCalender/api/.*': {
target: 'http://localhost:8088',
changeOrigin: true,
},
'^/cloud/api/.*': {
target: 'http://localhost:8089',
changeOrigin: true,
},
'^/wiki/api/.*': {
target: 'http://localhost:8090',
changeOrigin: true,
},
},
},
build: {
// 拆 chunk,减少首屏加载
rollupOptions: {
+1 -1
View File
@@ -1114,7 +1114,7 @@ ee-first@1.1.1:
resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz"
integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
element-plus@^2.8.4:
element-plus@^2.14.1:
version "2.14.2"
resolved "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.2.tgz"
integrity sha512-eNH9uP3wQoNqieEIHXiNvIVv+zO5sZDU0CAZq5b0zqSN06DD0/V9xIq1R/qm3rw5k3nBTM1JvpxhCfRbaFLzDQ==