feat: 完成阶段四前端开发和阶段五部署准备

阶段四:前端开发
- 管理后台 (worklog-web): Vue 3 + Element Plus
  - 登录页面、主布局、人员管理、模板管理、工作日志
  - baseURL: /wladmin/api/v1

- 移动端 H5 (worklog-mobile): Vue 3 + Vant 4
  - 登录、首页、日志列表、新建/编辑/详情页
  - baseURL: /wlmobile/api/v1

阶段五:部署准备
- 后端打包: worklog-api-1.0.0.jar (48MB)
- 前端打包: worklog-web (1.6MB), worklog-mobile (632KB)
- 单元测试: 29个测试全部通过
- API端口调整为 8200
- Nginx配置更新

配置变更
- 后端端口: 8080 → 8200
- 前端 baseURL: /wlog → /wladmin, /wlmobile
- Nginx 代理路径更新
This commit is contained in:
zhangjf 2026-02-24 17:33:16 +08:00
parent dbcc06edbc
commit 4b4fcf2ead
55 changed files with 2843 additions and 15 deletions

2
.gitignore vendored
View File

@ -31,7 +31,7 @@ dist/
# ==================== 日志文件 ====================
logs/
*.log
log/
/log/
# ==================== 临时文件 ====================
*.tmp

View File

@ -24,9 +24,9 @@ server {
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
# 管理后台
# 管理后台前端静态资源
location /admin/ {
alias /opt/worklog/worklog-admin/dist/;
alias /opt/worklog/worklog-web/dist/;
try_files $uri $uri/ /admin/index.html;
index index.html;
@ -37,7 +37,7 @@ server {
}
}
# 移动端 H5
# 移动端 H5 前端静态资源
location /mobile/ {
alias /opt/worklog/worklog-mobile/dist/;
try_files $uri $uri/ /mobile/index.html;
@ -50,9 +50,9 @@ server {
}
}
# 后端 API 代理
location /api/ {
proxy_pass http://127.0.0.1:8080;
# 管理后台 API 代理 (/wladmin/api/v1 → /wlog/api/v1)
location /wladmin/api/ {
proxy_pass http://127.0.0.1:8200/wlog/api/;
# 代理头设置
proxy_set_header Host $host;
@ -70,16 +70,33 @@ server {
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
}
# 移动端 API 代理 (/wlmobile/api/v1 → /wlog/api/v1)
location /wlmobile/api/ {
proxy_pass http://127.0.0.1:8200/wlog/api/;
# WebSocket 支持(如需要)
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# 代理头设置
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 缓冲设置
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
}
# Swagger API 文档(生产环境建议关闭或限制访问)
location /swagger-ui.html {
proxy_pass http://127.0.0.1:8080;
location /wlog/swagger-ui.html {
proxy_pass http://127.0.0.1:8200;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -90,8 +107,8 @@ server {
}
# 健康检查接口
location /api/v1/health {
proxy_pass http://127.0.0.1:8080;
location /wlog/api/v1/health {
proxy_pass http://127.0.0.1:8200;
proxy_set_header Host $host;
access_log off; # 健康检查不记录日志
}

24
worklog-mobile/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

13
worklog-mobile/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
<title>工作日志</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,29 @@
{
"name": "worklog-mobile",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.2",
"pinia": "^2.1.7",
"vant": "^4.8.4",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@types/node": "^20.11.24",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"postcss-px-to-viewport": "^1.1.1",
"typescript": "~5.4.2",
"vite": "^5.1.5",
"vue-tsc": "^2.0.6"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,12 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
<style>
#app {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
</style>

View File

@ -0,0 +1,32 @@
// 认证相关 API
import { request } from '@/utils/request'
// 登录请求参数
export interface LoginParams {
username: string
password: string
}
// 登录响应
export interface LoginResult {
token: string
userInfo: UserInfo
}
// 用户信息
export interface UserInfo {
id: string
username: string
name: string
role: string
}
// 登录
export function login(data: LoginParams): Promise<LoginResult> {
return request.post('/auth/login', data)
}
// 登出
export function logout(): Promise<void> {
return request.post('/auth/logout')
}

View File

@ -0,0 +1,69 @@
// 日志相关 API
import { request } from '@/utils/request'
// 日志信息
export interface Log {
id: string
userId: string
logDate: string
title: string
content: string
templateId: string
createdTime: string
updatedTime: string
}
// 分页请求参数
export interface PageParams {
pageNum: number
pageSize: number
startDate?: string
endDate?: string
}
// 分页结果
export interface PageResult<T> {
pageNum: number
pageSize: number
total: number
list: T[]
}
// 创建日志参数
export interface CreateLogParams {
logDate: string
title: string
content: string
templateId?: string
}
// 更新日志参数
export interface UpdateLogParams {
title: string
content: string
}
// 分页查询我的日志
export function pageMyLogs(params: PageParams): Promise<PageResult<Log>> {
return request.get('/log/page', { params })
}
// 获取日志详情
export function getLogById(id: string): Promise<Log> {
return request.get(`/log/${id}`)
}
// 创建日志
export function createLog(data: CreateLogParams): Promise<Log> {
return request.post('/log', data)
}
// 更新日志
export function updateLog(id: string, data: UpdateLogParams): Promise<Log> {
return request.put(`/log/${id}`, data)
}
// 删除日志
export function deleteLog(id: string): Promise<void> {
return request.delete(`/log/${id}`)
}

View File

@ -0,0 +1,16 @@
// 模板相关 API
import { request } from '@/utils/request'
// 模板信息
export interface Template {
id: string
templateName: string
content: string
status: number
createdTime: string
}
// 获取启用的模板列表
export function listEnabledTemplates(): Promise<Template[]> {
return request.get('/template/enabled')
}

View File

@ -0,0 +1,23 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: #f7f8fa;
}
.van-nav-bar {
position: fixed !important;
top: 0;
left: 0;
right: 0;
z-index: 100;
}
.page-content {
padding-top: 46px;
padding-bottom: 50px;
min-height: 100vh;
}

View File

@ -0,0 +1,15 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Vant from 'vant'
import 'vant/lib/index.css'
import App from './App.vue'
import router from './router'
import './assets/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(Vant)
app.mount('#app')

View File

@ -0,0 +1,64 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { TOKEN_KEY } from '@/utils/constants'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { requiresAuth: false }
},
{
path: '/',
name: 'Home',
component: () => import('@/views/home/index.vue'),
meta: { requiresAuth: true }
},
{
path: '/log',
name: 'LogList',
component: () => import('@/views/log/index.vue'),
meta: { requiresAuth: true }
},
{
path: '/log/create',
name: 'LogCreate',
component: () => import('@/views/log/create.vue'),
meta: { requiresAuth: true }
},
{
path: '/log/edit/:id',
name: 'LogEdit',
component: () => import('@/views/log/edit.vue'),
meta: { requiresAuth: true }
},
{
path: '/log/detail/:id',
name: 'LogDetail',
component: () => import('@/views/log/detail.vue'),
meta: { requiresAuth: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem(TOKEN_KEY)
if (to.meta.requiresAuth === false) {
next()
} else if (!token && to.path !== '/login') {
next('/login')
} else if (token && to.path === '/login') {
next('/')
} else {
next()
}
})
export default router

View File

@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { login as loginApi, logout as logoutApi } from '@/api/auth'
import type { LoginParams, UserInfo } from '@/api/auth'
import { TOKEN_KEY, USER_INFO_KEY } from '@/utils/constants'
export const useUserStore = defineStore('user', () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
const userInfo = ref<UserInfo | null>(
JSON.parse(localStorage.getItem(USER_INFO_KEY) || 'null')
)
// 登录
async function login(params: LoginParams) {
const result = await loginApi(params)
token.value = result.token
userInfo.value = result.userInfo
localStorage.setItem(TOKEN_KEY, result.token)
localStorage.setItem(USER_INFO_KEY, JSON.stringify(result.userInfo))
return result
}
// 登出
async function logout() {
try {
await logoutApi()
} finally {
token.value = null
userInfo.value = null
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_INFO_KEY)
}
}
return {
token,
userInfo,
login,
logout
}
})

View File

@ -0,0 +1,3 @@
// 常量定义
export const TOKEN_KEY = 'worklog_token'
export const USER_INFO_KEY = 'worklog_user_info'

View File

@ -0,0 +1,73 @@
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { showFailToast } from 'vant'
import { TOKEN_KEY } from '@/utils/constants'
// 创建 axios 实例
const service: AxiosInstance = axios.create({
baseURL: '/wlmobile/api/v1',
timeout: 15000
})
// 请求拦截器
service.interceptors.request.use(
(config) => {
const token = localStorage.getItem(TOKEN_KEY)
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data
// 业务成功
if (res.success) {
return res.data
}
// 业务失败
showFailToast(res.message || '请求失败')
return Promise.reject(new Error(res.message || '请求失败'))
},
(error) => {
// 401 未登录或 Token 过期
if (error.response?.status === 401) {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem('worklog_user_info')
window.location.href = '/login'
return Promise.reject(error)
}
showFailToast(error.response?.data?.message || '网络错误')
return Promise.reject(error)
}
)
// 封装请求方法
export const request = {
get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.get(url, config)
},
post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, data, config)
},
put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.put(url, data, config)
},
delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, config)
}
}
export default service

View File

@ -0,0 +1,80 @@
<template>
<div class="home-page">
<van-nav-bar title="工作日志" />
<div class="page-content">
<!-- 用户信息 -->
<van-cell-group inset class="user-card">
<van-cell :title="userStore.userInfo?.name" :label="userStore.userInfo?.username">
<template #icon>
<van-icon name="user-o" size="24" style="margin-right: 8px" />
</template>
</van-cell>
</van-cell-group>
<!-- 快捷入口 -->
<van-cell-group inset title="快捷入口" class="menu-group">
<van-cell title="我的日志" is-link to="/log">
<template #icon>
<van-icon name="notes-o" size="20" style="margin-right: 8px" />
</template>
</van-cell>
<van-cell title="新建日志" is-link to="/log/create">
<template #icon>
<van-icon name="edit" size="20" style="margin-right: 8px" />
</template>
</van-cell>
</van-cell-group>
<!-- 退出登录 -->
<div class="logout-btn">
<van-button round block type="danger" @click="handleLogout">退出登录</van-button>
</div>
</div>
<!-- 底部导航 -->
<van-tabbar v-model="active" route>
<van-tabbar-item icon="home-o" to="/">首页</van-tabbar-item>
<van-tabbar-item icon="notes-o" to="/log">日志</van-tabbar-item>
</van-tabbar>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { showSuccessToast, showConfirmDialog } from 'vant'
import { useUserStore } from '@/store/user'
const router = useRouter()
const userStore = useUserStore()
const active = ref(0)
async function handleLogout() {
try {
await showConfirmDialog({
title: '提示',
message: '确定要退出登录吗?'
})
await userStore.logout()
showSuccessToast('已退出登录')
router.push('/login')
} catch {
//
}
}
</script>
<style scoped>
.user-card {
margin: 16px;
}
.menu-group {
margin: 16px;
}
.logout-btn {
margin: 32px 16px;
}
</style>

View File

@ -0,0 +1,162 @@
<template>
<div class="log-create-page">
<van-nav-bar title="新建日志" left-arrow @click-left="goBack" />
<div class="page-content">
<van-form @submit="handleSubmit">
<van-cell-group inset>
<van-field
v-model="form.logDate"
is-link
readonly
name="logDate"
label="日期"
placeholder="选择日期"
:rules="[{ required: true, message: '请选择日期' }]"
@click="showDatePicker = true"
/>
<van-field
v-model="form.title"
name="title"
label="标题"
placeholder="请输入标题"
:rules="[{ required: true, message: '请输入标题' }]"
/>
<van-field
v-model="form.templateId"
is-link
readonly
name="templateId"
label="模板"
placeholder="选择模板(可选)"
@click="showTemplatePicker = true"
/>
<van-field
v-model="form.content"
name="content"
label="内容"
type="textarea"
rows="8"
autosize
placeholder="请输入日志内容支持Markdown"
/>
</van-cell-group>
<div class="submit-btn">
<van-button round block type="primary" native-type="submit" :loading="loading">
提交
</van-button>
</div>
</van-form>
</div>
<!-- 日期选择器 -->
<van-popup v-model:show="showDatePicker" position="bottom" round>
<van-date-picker
v-model="selectedDate"
title="选择日期"
@confirm="onDateConfirm"
@cancel="showDatePicker = false"
/>
</van-popup>
<!-- 模板选择器 -->
<van-popup v-model:show="showTemplatePicker" position="bottom" round>
<van-picker
:columns="templateColumns"
title="选择模板"
@confirm="onTemplateConfirm"
@cancel="showTemplatePicker = false"
/>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showSuccessToast } from 'vant'
import { createLog } from '@/api/log'
import { listEnabledTemplates } from '@/api/template'
import type { Template } from '@/api/template'
const router = useRouter()
const loading = ref(false)
const showDatePicker = ref(false)
const showTemplatePicker = ref(false)
const selectedDate = ref(['2024', '01', '01'])
const templates = ref<Template[]>([])
const templateColumns = ref<{ text: string; value: string }[]>([])
const form = reactive({
logDate: '',
title: '',
content: '',
templateId: ''
})
onMounted(async () => {
//
const today = new Date()
const year = today.getFullYear().toString()
const month = (today.getMonth() + 1).toString().padStart(2, '0')
const day = today.getDate().toString().padStart(2, '0')
selectedDate.value = [year, month, day]
form.logDate = `${year}-${month}-${day}`
//
try {
templates.value = await listEnabledTemplates()
templateColumns.value = templates.value.map(t => ({
text: t.templateName,
value: t.id
}))
} catch {
//
}
})
function onDateConfirm() {
form.logDate = selectedDate.value.join('-')
showDatePicker.value = false
}
function onTemplateConfirm({ selectedValues }: { selectedValues: string[] }) {
const templateId = selectedValues[0]
form.templateId = templateId
//
const template = templates.value.find(t => t.id === templateId)
if (template && template.content) {
form.content = template.content
}
showTemplatePicker.value = false
}
async function handleSubmit() {
loading.value = true
try {
await createLog({
logDate: form.logDate,
title: form.title,
content: form.content,
templateId: form.templateId || undefined
})
showSuccessToast('创建成功')
router.push('/log')
} finally {
loading.value = false
}
}
function goBack() {
router.back()
}
</script>
<style scoped>
.submit-btn {
margin: 24px 16px;
}
</style>

View File

@ -0,0 +1,97 @@
<template>
<div class="log-detail-page">
<van-nav-bar title="日志详情" left-arrow @click-left="goBack">
<template #right>
<van-icon name="edit" size="18" @click="goEdit" />
</template>
</van-nav-bar>
<div class="page-content">
<van-cell-group inset>
<van-cell title="日期" :value="log.logDate" />
<van-cell title="标题" :value="log.title" />
</van-cell-group>
<van-cell-group inset title="内容">
<div class="content-box">
<pre>{{ log.content || '暂无内容' }}</pre>
</div>
</van-cell-group>
<div class="action-btn">
<van-button round block type="danger" @click="handleDelete">删除日志</van-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showSuccessToast, showConfirmDialog } from 'vant'
import { getLogById, deleteLog } from '@/api/log'
import type { Log } from '@/api/log'
const router = useRouter()
const route = useRoute()
const log = ref<Log>({
id: '',
userId: '',
logDate: '',
title: '',
content: '',
templateId: '',
createdTime: '',
updatedTime: ''
})
onMounted(async () => {
const id = route.params.id as string
try {
log.value = await getLogById(id)
} catch {
router.back()
}
})
function goBack() {
router.back()
}
function goEdit() {
router.push(`/log/edit/${log.value.id}`)
}
async function handleDelete() {
try {
await showConfirmDialog({
title: '提示',
message: '确定要删除该日志吗?'
})
await deleteLog(log.value.id)
showSuccessToast('删除成功')
router.push('/log')
} catch {
//
}
}
</script>
<style scoped>
.content-box {
padding: 12px 16px;
}
.content-box pre {
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
font-family: inherit;
font-size: 14px;
line-height: 1.6;
}
.action-btn {
margin: 24px 16px;
}
</style>

View File

@ -0,0 +1,96 @@
<template>
<div class="log-edit-page">
<van-nav-bar title="编辑日志" left-arrow @click-left="goBack" />
<div class="page-content">
<van-form @submit="handleSubmit">
<van-cell-group inset>
<van-field
v-model="form.logDate"
name="logDate"
label="日期"
readonly
disabled
/>
<van-field
v-model="form.title"
name="title"
label="标题"
placeholder="请输入标题"
:rules="[{ required: true, message: '请输入标题' }]"
/>
<van-field
v-model="form.content"
name="content"
label="内容"
type="textarea"
rows="8"
autosize
placeholder="请输入日志内容"
/>
</van-cell-group>
<div class="submit-btn">
<van-button round block type="primary" native-type="submit" :loading="loading">
保存
</van-button>
</div>
</van-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showSuccessToast, showFailToast } from 'vant'
import { getLogById, updateLog } from '@/api/log'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const form = reactive({
logDate: '',
title: '',
content: ''
})
onMounted(async () => {
const id = route.params.id as string
try {
const log = await getLogById(id)
form.logDate = log.logDate
form.title = log.title
form.content = log.content || ''
} catch {
showFailToast('加载失败')
router.back()
}
})
async function handleSubmit() {
const id = route.params.id as string
loading.value = true
try {
await updateLog(id, {
title: form.title,
content: form.content
})
showSuccessToast('保存成功')
router.push('/log')
} finally {
loading.value = false
}
}
function goBack() {
router.back()
}
</script>
<style scoped>
.submit-btn {
margin: 24px 16px;
}
</style>

View File

@ -0,0 +1,146 @@
<template>
<div class="log-list-page">
<van-nav-bar title="我的日志" />
<div class="page-content">
<!-- 搜索栏 -->
<van-cell-group inset class="search-group">
<van-field
v-model="dateRange"
is-link
readonly
label="日期范围"
placeholder="选择日期范围"
@click="showDatePicker = true"
/>
</van-cell-group>
<!-- 日志列表 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<van-cell-group inset>
<van-cell
v-for="item in list"
:key="item.id"
:title="item.title"
:label="item.logDate"
is-link
@click="goDetail(item.id)"
>
<template #value>
<span class="log-date">{{ item.logDate }}</span>
</template>
</van-cell>
</van-cell-group>
</van-list>
</van-pull-refresh>
<!-- 新建按钮 -->
<van-floating-bubble icon="plus" @click="goCreate" />
</div>
<!-- 日期选择器 -->
<van-popup v-model:show="showDatePicker" position="bottom" round>
<van-date-picker
v-model="selectedDate"
type="daterange"
title="选择日期范围"
@confirm="onDateConfirm"
@cancel="showDatePicker = false"
/>
</van-popup>
<!-- 底部导航 -->
<van-tabbar v-model="active" route>
<van-tabbar-item icon="home-o" to="/">首页</van-tabbar-item>
<van-tabbar-item icon="notes-o" to="/log">日志</van-tabbar-item>
</van-tabbar>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { pageMyLogs } from '@/api/log'
import type { Log } from '@/api/log'
const router = useRouter()
const active = ref(1)
const loading = ref(false)
const refreshing = ref(false)
const finished = ref(false)
const showDatePicker = ref(false)
const dateRange = ref('')
const selectedDate = ref<string[]>([])
const list = ref<Log[]>([])
const pageNum = ref(1)
const pageSize = ref(10)
const total = ref(0)
async function loadData() {
const result = await pageMyLogs({
pageNum: pageNum.value,
pageSize: pageSize.value
})
list.value = result.list
total.value = result.total
}
onMounted(() => {
loadData()
})
async function onLoad() {
if (list.value.length >= total.value) {
finished.value = true
return
}
pageNum.value++
const result = await pageMyLogs({
pageNum: pageNum.value,
pageSize: pageSize.value
})
list.value.push(...result.list)
loading.value = false
}
async function onRefresh() {
pageNum.value = 1
await loadData()
refreshing.value = false
finished.value = false
}
function onDateConfirm() {
showDatePicker.value = false
//
pageNum.value = 1
loadData()
}
function goDetail(id: string) {
router.push(`/log/detail/${id}`)
}
function goCreate() {
router.push('/log/create')
}
</script>
<style scoped>
.search-group {
margin: 16px;
}
.log-date {
color: #969799;
font-size: 12px;
}
</style>

View File

@ -0,0 +1,76 @@
<template>
<div class="login-page">
<van-nav-bar title="工作日志" />
<div class="login-content">
<van-form @submit="handleLogin">
<van-cell-group inset>
<van-field
v-model="form.username"
name="username"
label="用户名"
placeholder="请输入用户名"
:rules="[{ required: true, message: '请输入用户名' }]"
/>
<van-field
v-model="form.password"
type="password"
name="password"
label="密码"
placeholder="请输入密码"
:rules="[{ required: true, message: '请输入密码' }]"
/>
</van-cell-group>
<div class="login-btn">
<van-button round block type="primary" native-type="submit" :loading="loading">
登录
</van-button>
</div>
</van-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { showSuccessToast } from 'vant'
import { useUserStore } from '@/store/user'
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const form = reactive({
username: '',
password: ''
})
async function handleLogin() {
loading.value = true
try {
await userStore.login(form)
showSuccessToast('登录成功')
router.push('/')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-content {
padding: 60px 16px;
}
.login-btn {
margin: 24px 16px;
}
</style>

View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path Alias */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

2
worklog-mobile/vite.config.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

View File

@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 5174,
proxy: {
'/wlmobile': {
target: 'http://localhost:8200',
changeOrigin: true
}
}
}
});

View File

@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 5174,
proxy: {
'/wlmobile': {
target: 'http://localhost:8200',
changeOrigin: true
}
}
}
})

24
worklog-web/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
worklog-web/README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
worklog-web/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>worklog-web</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

27
worklog-web/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "worklog-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.5",
"element-plus": "^2.13.2",
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.1.5"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

14
worklog-web/src/App.vue Normal file
View File

@ -0,0 +1,14 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
<style>
html, body, #app {
height: 100%;
margin: 0;
padding: 0;
}
</style>

View File

@ -0,0 +1,32 @@
// 认证相关 API
import { request } from '@/utils/request'
// 登录请求参数
export interface LoginParams {
username: string
password: string
}
// 登录响应
export interface LoginResult {
token: string
userInfo: UserInfo
}
// 用户信息
export interface UserInfo {
id: string
username: string
name: string
role: string
}
// 登录
export function login(data: LoginParams): Promise<LoginResult> {
return request.post('/auth/login', data)
}
// 登出
export function logout(): Promise<void> {
return request.post('/auth/logout')
}

View File

@ -0,0 +1,70 @@
// 日志管理相关 API
import { request } from '@/utils/request'
import type { PageResult } from './user'
// 日志信息
export interface Log {
id: string
userId: string
logDate: string
title: string
content?: string
templateId?: string
createdTime?: string
updatedTime?: string
}
// 创建日志参数
export interface CreateLogParams {
logDate?: string
title: string
content?: string
templateId?: string
}
// 更新日志参数
export interface UpdateLogParams {
title?: string
content?: string
}
// 分页查询我的日志
export function pageMyLogs(params: {
pageNum?: number
pageSize?: number
startDate?: string
endDate?: string
}): Promise<PageResult<Log>> {
return request.get('/log/page', { params })
}
// 分页查询所有日志(管理员)
export function pageAllLogs(params: {
pageNum?: number
pageSize?: number
userId?: string
startDate?: string
endDate?: string
}): Promise<PageResult<Log>> {
return request.get('/log/page/all', { params })
}
// 获取日志详情
export function getLogById(id: string): Promise<Log> {
return request.get(`/log/${id}`)
}
// 创建日志
export function createLog(data: CreateLogParams): Promise<Log> {
return request.post('/log', data)
}
// 更新日志
export function updateLog(id: string, data: UpdateLogParams): Promise<Log> {
return request.put(`/log/${id}`, data)
}
// 删除日志
export function deleteLog(id: string): Promise<void> {
return request.delete(`/log/${id}`)
}

View File

@ -0,0 +1,59 @@
// 模板管理相关 API
import { request } from '@/utils/request'
// 模板信息
export interface Template {
id: string
templateName: string
content?: string
status: number
createdTime?: string
updatedTime?: string
}
// 创建模板参数
export interface CreateTemplateParams {
templateName: string
content?: string
}
// 更新模板参数
export interface UpdateTemplateParams {
templateName?: string
content?: string
}
// 获取启用的模板列表
export function listEnabledTemplates(): Promise<Template[]> {
return request.get('/template/list')
}
// 获取所有模板列表
export function listAllTemplates(): Promise<Template[]> {
return request.get('/template/list/all')
}
// 获取模板详情
export function getTemplateById(id: string): Promise<Template> {
return request.get(`/template/${id}`)
}
// 创建模板
export function createTemplate(data: CreateTemplateParams): Promise<Template> {
return request.post('/template', data)
}
// 更新模板
export function updateTemplate(id: string, data: UpdateTemplateParams): Promise<Template> {
return request.put(`/template/${id}`, data)
}
// 更新模板状态
export function updateTemplateStatus(id: string, status: number): Promise<void> {
return request.put(`/template/${id}/status`, { status })
}
// 删除模板
export function deleteTemplate(id: string): Promise<void> {
return request.delete(`/template/${id}`)
}

View File

@ -0,0 +1,87 @@
// 用户管理相关 API
import { request } from '@/utils/request'
// 用户信息
export interface User {
id: string
username: string
name: string
phone?: string
email?: string
position?: string
description?: string
status: number
role: string
createdTime?: string
updatedTime?: string
}
// 分页结果
export interface PageResult<T> {
pageNum: number
pageSize: number
total: number
list: T[]
}
// 创建用户参数
export interface CreateUserParams {
username: string
password: string
name: string
phone?: string
email?: string
position?: string
description?: string
role?: string
}
// 更新用户参数
export interface UpdateUserParams {
name?: string
phone?: string
email?: string
position?: string
description?: string
}
// 分页查询用户
export function pageUsers(params: {
pageNum?: number
pageSize?: number
name?: string
username?: string
status?: number
}): Promise<PageResult<User>> {
return request.get('/user/page', { params })
}
// 获取用户详情
export function getUserById(id: string): Promise<User> {
return request.get(`/user/${id}`)
}
// 创建用户
export function createUser(data: CreateUserParams): Promise<User> {
return request.post('/user', data)
}
// 更新用户
export function updateUser(id: string, data: UpdateUserParams): Promise<User> {
return request.put(`/user/${id}`, data)
}
// 更新用户状态
export function updateUserStatus(id: string, status: number): Promise<void> {
return request.put(`/user/${id}/status`, { status })
}
// 删除用户
export function deleteUser(id: string): Promise<void> {
return request.delete(`/user/${id}`)
}
// 重置密码
export function resetPassword(id: string, newPassword: string): Promise<void> {
return request.put(`/user/${id}/password`, null, { params: { newPassword } })
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

20
worklog-web/src/main.ts Normal file
View File

@ -0,0 +1,20 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import router from './router'
import pinia from './store'
import App from './App.vue'
import './styles/index.css'
const app = createApp(App)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus)
app.use(router)
app.use(pinia)
app.mount('#app')

View File

@ -0,0 +1,60 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { TOKEN_KEY } from '@/utils/constants'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { requiresAuth: false }
},
{
path: '/',
name: 'Layout',
component: () => import('@/views/layout/index.vue'),
redirect: '/log',
children: [
{
path: 'user',
name: 'User',
component: () => import('@/views/user/index.vue'),
meta: { title: '人员管理', icon: 'User' }
},
{
path: 'template',
name: 'Template',
component: () => import('@/views/template/index.vue'),
meta: { title: '模板管理', icon: 'Document' }
},
{
path: 'log',
name: 'Log',
component: () => import('@/views/log/index.vue'),
meta: { title: '工作日志', icon: 'Notebook' }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem(TOKEN_KEY)
if (to.meta.requiresAuth === false) {
next()
} else if (!token && to.path !== '/login') {
next('/login')
} else if (token && to.path === '/login') {
next('/')
} else {
next()
}
})
export default router

View File

@ -0,0 +1,5 @@
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia

View File

@ -0,0 +1,49 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { login as loginApi, logout as logoutApi } from '@/api/auth'
import type { LoginParams, UserInfo } from '@/api/auth'
import { TOKEN_KEY, USER_INFO_KEY } from '@/utils/constants'
export const useUserStore = defineStore('user', () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
const userInfo = ref<UserInfo | null>(
JSON.parse(localStorage.getItem(USER_INFO_KEY) || 'null')
)
// 登录
async function login(params: LoginParams) {
const result = await loginApi(params)
token.value = result.token
userInfo.value = result.userInfo
localStorage.setItem(TOKEN_KEY, result.token)
localStorage.setItem(USER_INFO_KEY, JSON.stringify(result.userInfo))
return result
}
// 登出
async function logout() {
try {
await logoutApi()
} finally {
token.value = null
userInfo.value = null
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_INFO_KEY)
}
}
// 是否是管理员
function isAdmin() {
return userInfo.value?.role === 'ADMIN'
}
return {
token,
userInfo,
login,
logout,
isAdmin
}
})

View File

@ -0,0 +1,15 @@
/* 全局样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
#app {
height: 100%;
}

View File

@ -0,0 +1,17 @@
// API 基础配置
export const API_BASE_URL = '/wlog/api/v1'
// Token 存储 Key
export const TOKEN_KEY = 'worklog_token'
// 用户信息存储 Key
export const USER_INFO_KEY = 'worklog_user_info'
// 路由配置
export const ROUTES = {
LOGIN: '/login',
HOME: '/',
USER: '/user',
TEMPLATE: '/template',
LOG: '/log'
}

View File

@ -0,0 +1,73 @@
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
import { TOKEN_KEY } from '@/utils/constants'
// 创建 axios 实例
const service: AxiosInstance = axios.create({
baseURL: '/wladmin/api/v1',
timeout: 15000
})
// 请求拦截器
service.interceptors.request.use(
(config) => {
const token = localStorage.getItem(TOKEN_KEY)
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data
// 业务成功
if (res.success) {
return res.data
}
// 业务失败
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message || '请求失败'))
},
(error) => {
// 401 未登录或 Token 过期
if (error.response?.status === 401) {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem('worklog_user_info')
window.location.href = '/login'
return Promise.reject(error)
}
ElMessage.error(error.response?.data?.message || '网络错误')
return Promise.reject(error)
}
)
// 封装请求方法
export const request = {
get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.get(url, config)
},
post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, data, config)
},
put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.put(url, data, config)
},
delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, config)
}
}
export default service

View File

@ -0,0 +1,127 @@
<template>
<el-container class="layout-container">
<!-- 侧边栏 -->
<el-aside width="200px" class="aside">
<div class="logo">
<h1>工作日志</h1>
</div>
<el-menu
:default-active="currentRoute"
router
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409EFF"
>
<el-menu-item index="/log">
<el-icon><Notebook /></el-icon>
<span>工作日志</span>
</el-menu-item>
<el-menu-item index="/template">
<el-icon><Document /></el-icon>
<span>模板管理</span>
</el-menu-item>
<el-menu-item index="/user" v-if="userStore.isAdmin()">
<el-icon><User /></el-icon>
<span>人员管理</span>
</el-menu-item>
</el-menu>
</el-aside>
<!-- 主体区域 -->
<el-container>
<!-- 顶栏 -->
<el-header class="header">
<div class="header-right">
<span class="username">{{ userStore.userInfo?.name }}</span>
<el-dropdown @command="handleCommand">
<el-avatar :size="32" class="avatar">
{{ userStore.userInfo?.name?.charAt(0) }}
</el-avatar>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 内容区域 -->
<el-main class="main">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Notebook, Document, User } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const currentRoute = computed(() => route.path)
async function handleCommand(command: string) {
if (command === 'logout') {
await userStore.logout()
router.push('/login')
}
}
</script>
<style scoped>
.layout-container {
height: 100vh;
}
.aside {
background-color: #304156;
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.logo h1 {
margin: 0;
font-size: 18px;
}
.header {
background-color: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 20px;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.username {
color: #606266;
}
.avatar {
background-color: #409EFF;
cursor: pointer;
}
.main {
background-color: #f0f2f5;
padding: 20px;
}
</style>

View File

@ -0,0 +1,309 @@
<template>
<div class="log-page">
<el-card>
<!-- 搜索栏 -->
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="日期范围">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
@change="handleDateChange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 工具栏 -->
<el-row class="toolbar">
<el-button type="primary" @click="handleAdd">新建日志</el-button>
</el-row>
<!-- 表格 -->
<el-table :data="tableData" v-loading="loading" stripe>
<el-table-column prop="logDate" label="日期" width="120" />
<el-table-column prop="title" label="标题" width="200" />
<el-table-column prop="content" label="内容" show-overflow-tooltip />
<el-table-column prop="createdTime" label="创建时间" width="180" />
<el-table-column prop="updatedTime" label="更新时间" width="180" />
<el-table-column label="操作" fixed="right" width="150">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="primary" link @click="handleView(row)">查看</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pageNum"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="loadData"
@current-change="loadData"
class="pagination"
/>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="700px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="日期" prop="logDate">
<el-date-picker
v-model="form.logDate"
type="date"
placeholder="请选择日期"
value-format="YYYY-MM-DD"
:disabled="!!form.id"
/>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="选择模板">
<el-select v-model="form.templateId" placeholder="请选择模板" clearable @change="handleTemplateChange">
<el-option
v-for="item in templateList"
:key="item.id"
:label="item.templateName"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="内容">
<el-input
v-model="form.content"
type="textarea"
:rows="12"
placeholder="请输入日志内容支持Markdown格式"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
</template>
</el-dialog>
<!-- 查看详情对话框 -->
<el-dialog v-model="viewDialogVisible" title="日志详情" width="700px">
<el-descriptions :column="2" border>
<el-descriptions-item label="日期">{{ viewData.logDate }}</el-descriptions-item>
<el-descriptions-item label="标题">{{ viewData.title }}</el-descriptions-item>
<el-descriptions-item label="内容" :span="2">
<pre class="content-pre">{{ viewData.content }}</pre>
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { pageMyLogs, createLog, updateLog, deleteLog } from '@/api/log'
import type { Log } from '@/api/log'
import { listEnabledTemplates } from '@/api/template'
import type { Template } from '@/api/template'
import type { FormInstance, FormRules } from 'element-plus'
const loading = ref(false)
const submitLoading = ref(false)
const tableData = ref<Log[]>([])
const templateList = ref<Template[]>([])
const total = ref(0)
const pageNum = ref(1)
const pageSize = ref(10)
const searchForm = reactive({
startDate: '',
endDate: ''
})
const dateRange = ref<string[]>([])
function handleDateChange(val: string[] | null) {
if (val && val.length >= 2) {
searchForm.startDate = val[0] || ''
searchForm.endDate = val[1] || ''
} else {
searchForm.startDate = ''
searchForm.endDate = ''
}
}
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const form = reactive({
id: '',
logDate: '',
title: '',
content: '',
templateId: ''
})
const dialogTitle = computed(() => form.id ? '编辑日志' : '新建日志')
const rules: FormRules = {
logDate: [{ required: true, message: '请选择日期', trigger: 'change' }],
title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
}
//
const viewDialogVisible = ref(false)
const viewData = reactive({
logDate: '',
title: '',
content: ''
})
//
async function loadData() {
loading.value = true
try {
const result = await pageMyLogs({
pageNum: pageNum.value,
pageSize: pageSize.value,
startDate: searchForm.startDate || undefined,
endDate: searchForm.endDate || undefined
})
tableData.value = result.list
total.value = result.total
} finally {
loading.value = false
}
}
//
async function loadTemplates() {
templateList.value = await listEnabledTemplates()
}
//
function handleSearch() {
pageNum.value = 1
loadData()
}
//
function handleReset() {
dateRange.value = []
searchForm.startDate = ''
searchForm.endDate = ''
handleSearch()
}
//
function handleAdd() {
form.id = ''
form.logDate = new Date().toISOString().split('T')[0] || ''
form.title = ''
form.content = ''
form.templateId = ''
dialogVisible.value = true
}
//
async function handleEdit(row: Log) {
form.id = row.id
form.logDate = row.logDate
form.title = row.title
form.content = row.content || ''
form.templateId = row.templateId || ''
dialogVisible.value = true
}
//
function handleView(row: Log) {
viewData.logDate = row.logDate
viewData.title = row.title
viewData.content = row.content || ''
viewDialogVisible.value = true
}
//
function handleTemplateChange(templateId: string) {
if (templateId) {
const template = templateList.value.find(t => t.id === templateId)
if (template && template.content) {
form.content = template.content
}
}
}
//
async function handleSubmit() {
const valid = await formRef.value?.validate()
if (!valid) return
submitLoading.value = true
try {
if (form.id) {
await updateLog(form.id, {
title: form.title,
content: form.content
})
ElMessage.success('更新成功')
} else {
await createLog({
logDate: form.logDate,
title: form.title,
content: form.content,
templateId: form.templateId || undefined
})
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadData()
} finally {
submitLoading.value = false
}
}
//
async function handleDelete(row: Log) {
await ElMessageBox.confirm('确定要删除该日志吗?', '提示', { type: 'warning' })
await deleteLog(row.id)
ElMessage.success('删除成功')
loadData()
}
//
loadData()
loadTemplates()
</script>
<style scoped>
.log-page {
height: 100%;
}
.search-form {
margin-bottom: 16px;
}
.toolbar {
margin-bottom: 16px;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
.content-pre {
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
font-family: inherit;
}
</style>

View File

@ -0,0 +1,104 @@
<template>
<div class="login-container">
<el-card class="login-card">
<template #header>
<h2 class="login-title">工作日志服务平台</h2>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="0">
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="请输入用户名"
prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
prefix-icon="Lock"
size="large"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
style="width: 100%"
:loading="loading"
@click="handleLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
import type { FormInstance, FormRules } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({
username: '',
password: ''
})
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
async function handleLogin() {
const valid = await formRef.value?.validate()
if (!valid) return
loading.value = true
try {
await userStore.login(form)
ElMessage.success('登录成功')
router.push('/')
} catch (error) {
// request
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
}
.login-title {
text-align: center;
margin: 0;
color: #303133;
}
</style>

View File

@ -0,0 +1,167 @@
<template>
<div class="template-page">
<el-card>
<!-- 工具栏 -->
<el-row class="toolbar">
<el-button type="primary" @click="handleAdd">新增模板</el-button>
</el-row>
<!-- 表格 -->
<el-table :data="tableData" v-loading="loading" stripe>
<el-table-column prop="templateName" label="模板名称" width="200" />
<el-table-column prop="content" label="内容" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdTime" label="创建时间" width="180" />
<el-table-column label="操作" fixed="right" width="200">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="primary" link @click="handleStatus(row)">
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="模板名称" prop="templateName">
<el-input v-model="form.templateName" placeholder="请输入模板名称" />
</el-form-item>
<el-form-item label="模板内容">
<el-input
v-model="form.content"
type="textarea"
:rows="10"
placeholder="请输入模板内容支持Markdown格式"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listAllTemplates, createTemplate, updateTemplate, updateTemplateStatus, deleteTemplate } from '@/api/template'
import type { Template } from '@/api/template'
import type { FormInstance, FormRules } from 'element-plus'
const loading = ref(false)
const submitLoading = ref(false)
const tableData = ref<Template[]>([])
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const form = reactive({
id: '',
templateName: '',
content: ''
})
const dialogTitle = computed(() => form.id ? '编辑模板' : '新增模板')
const rules: FormRules = {
templateName: [{ required: true, message: '请输入模板名称', trigger: 'blur' }]
}
//
async function loadData() {
loading.value = true
try {
tableData.value = await listAllTemplates()
} finally {
loading.value = false
}
}
//
function handleAdd() {
form.id = ''
form.templateName = ''
form.content = ''
dialogVisible.value = true
}
//
function handleEdit(row: Template) {
form.id = row.id
form.templateName = row.templateName
form.content = row.content || ''
dialogVisible.value = true
}
//
async function handleSubmit() {
const valid = await formRef.value?.validate()
if (!valid) return
submitLoading.value = true
try {
if (form.id) {
await updateTemplate(form.id, {
templateName: form.templateName,
content: form.content
})
ElMessage.success('更新成功')
} else {
await createTemplate({
templateName: form.templateName,
content: form.content
})
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadData()
} finally {
submitLoading.value = false
}
}
//
async function handleStatus(row: Template) {
const newStatus = row.status === 1 ? 0 : 1
await ElMessageBox.confirm(
`确定要${newStatus === 1 ? '启用' : '禁用'}该模板吗?`,
'提示',
{ type: 'warning' }
)
await updateTemplateStatus(row.id, newStatus)
ElMessage.success('操作成功')
loadData()
}
//
async function handleDelete(row: Template) {
await ElMessageBox.confirm('确定要删除该模板吗?', '提示', { type: 'warning' })
await deleteTemplate(row.id)
ElMessage.success('删除成功')
loadData()
}
//
loadData()
</script>
<style scoped>
.template-page {
height: 100%;
}
.toolbar {
margin-bottom: 16px;
}
</style>

View File

@ -0,0 +1,298 @@
<template>
<div class="user-page">
<el-card>
<!-- 搜索栏 -->
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="姓名">
<el-input v-model="searchForm.name" placeholder="请输入姓名" clearable />
</el-form-item>
<el-form-item label="账号">
<el-input v-model="searchForm.username" placeholder="请输入账号" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 工具栏 -->
<el-row class="toolbar" justify="space-between">
<el-col :span="12">
<el-button type="primary" @click="handleAdd">新增用户</el-button>
</el-col>
</el-row>
<!-- 表格 -->
<el-table :data="tableData" v-loading="loading" stripe>
<el-table-column prop="username" label="账号" width="120" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="phone" label="联系方式" width="130" />
<el-table-column prop="email" label="邮箱" width="180" />
<el-table-column prop="position" label="职位" width="120" />
<el-table-column prop="role" label="角色" width="100">
<template #default="{ row }">
<el-tag :type="row.role === 'ADMIN' ? 'danger' : 'info'">
{{ row.role === 'ADMIN' ? '管理员' : '普通用户' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdTime" label="创建时间" width="180" />
<el-table-column label="操作" fixed="right" width="200">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button
type="primary"
link
@click="handleStatus(row)"
v-if="row.role !== 'ADMIN'"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
<el-button
type="danger"
link
@click="handleDelete(row)"
v-if="row.role !== 'ADMIN'"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pageNum"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="handleSearch"
@current-change="handleSearch"
class="pagination"
/>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="账号" prop="username" v-if="!form.id">
<el-input v-model="form.username" placeholder="请输入账号" />
</el-form-item>
<el-form-item label="密码" prop="password" v-if="!form.id">
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="联系方式">
<el-input v-model="form.phone" placeholder="请输入联系方式" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="职位">
<el-input v-model="form.position" placeholder="请输入职位" />
</el-form-item>
<el-form-item label="角色" v-if="!form.id">
<el-select v-model="form.role" placeholder="请选择角色">
<el-option label="普通用户" value="USER" />
<el-option label="管理员" value="ADMIN" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { pageUsers, createUser, updateUser, updateUserStatus, deleteUser } from '@/api/user'
import type { User } from '@/api/user'
import type { FormInstance, FormRules } from 'element-plus'
const loading = ref(false)
const submitLoading = ref(false)
const tableData = ref<User[]>([])
const total = ref(0)
const pageNum = ref(1)
const pageSize = ref(10)
const searchForm = reactive({
name: '',
username: '',
status: undefined as number | undefined
})
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const form = reactive({
id: '',
username: '',
password: '',
name: '',
phone: '',
email: '',
position: '',
role: 'USER'
})
const dialogTitle = computed(() => form.id ? '编辑用户' : '新增用户')
const rules: FormRules = {
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }]
}
//
async function loadData() {
loading.value = true
try {
const result = await pageUsers({
pageNum: pageNum.value,
pageSize: pageSize.value,
...searchForm
})
tableData.value = result.list
total.value = result.total
} finally {
loading.value = false
}
}
//
function handleSearch() {
pageNum.value = 1
loadData()
}
//
function handleReset() {
searchForm.name = ''
searchForm.username = ''
searchForm.status = undefined
handleSearch()
}
//
function handleAdd() {
form.id = ''
form.username = ''
form.password = ''
form.name = ''
form.phone = ''
form.email = ''
form.position = ''
form.role = 'USER'
dialogVisible.value = true
}
//
function handleEdit(row: User) {
form.id = row.id
form.username = row.username
form.password = ''
form.name = row.name
form.phone = row.phone || ''
form.email = row.email || ''
form.position = row.position || ''
form.role = row.role
dialogVisible.value = true
}
//
async function handleSubmit() {
const valid = await formRef.value?.validate()
if (!valid) return
submitLoading.value = true
try {
if (form.id) {
await updateUser(form.id, {
name: form.name,
phone: form.phone,
email: form.email,
position: form.position
})
ElMessage.success('更新成功')
} else {
await createUser({
username: form.username,
password: form.password,
name: form.name,
phone: form.phone,
email: form.email,
position: form.position,
role: form.role
})
ElMessage.success('创建成功')
}
dialogVisible.value = false
loadData()
} finally {
submitLoading.value = false
}
}
//
async function handleStatus(row: User) {
const newStatus = row.status === 1 ? 0 : 1
await ElMessageBox.confirm(
`确定要${newStatus === 1 ? '启用' : '禁用'}该用户吗?`,
'提示',
{ type: 'warning' }
)
await updateUserStatus(row.id, newStatus)
ElMessage.success('操作成功')
loadData()
}
//
async function handleDelete(row: User) {
await ElMessageBox.confirm('确定要删除该用户吗?', '提示', { type: 'warning' })
await deleteUser(row.id)
ElMessage.success('删除成功')
loadData()
}
//
loadData()
</script>
<style scoped>
.user-page {
height: 100%;
}
.search-form {
margin-bottom: 16px;
}
.toolbar {
margin-bottom: 16px;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
</style>

View File

@ -0,0 +1,17 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"verbatimModuleSyntax": false,
/* Linting */
"strict": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
server: {
port: 3000,
proxy: {
'/wladmin': {
target: 'http://localhost:8200',
changeOrigin: true
}
}
}
})