Compare commits
10 Commits
cf54ea2352
...
da7a5b703e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da7a5b703e | ||
|
|
348221d198 | ||
|
|
3746198f19 | ||
|
|
64bbb0a987 | ||
|
|
ff0e1dcd27 | ||
|
|
5de298a956 | ||
|
|
ce45d93ce5 | ||
|
|
d37af1b73e | ||
|
|
eff4f8b314 | ||
|
|
e1bc973034 |
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build:prod
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
12
components.d.ts
vendored
12
components.d.ts
vendored
@ -13,11 +13,13 @@ export {}
|
|||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
ChangePasswordForm: typeof import('./src/components/change-password-form.vue')['default']
|
ChangePasswordForm: typeof import('./src/components/change-password-form.vue')['default']
|
||||||
|
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
|
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
@ -35,8 +37,10 @@ declare module 'vue' {
|
|||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
|
ElResult: typeof import('element-plus/es')['ElResult']
|
||||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSlider: typeof import('element-plus/es')['ElSlider']
|
ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||||
@ -57,12 +61,14 @@ declare module 'vue' {
|
|||||||
TyForm: typeof import('./src/components/ty-form.vue')['default']
|
TyForm: typeof import('./src/components/ty-form.vue')['default']
|
||||||
TyHeader: typeof import('./src/components/ty-header.vue')['default']
|
TyHeader: typeof import('./src/components/ty-header.vue')['default']
|
||||||
TyMixbar: typeof import('./src/components/ty-mixbar.vue')['default']
|
TyMixbar: typeof import('./src/components/ty-mixbar.vue')['default']
|
||||||
|
TyNotice: typeof import('./src/components/ty-notice.vue')['default']
|
||||||
TySearchForm: typeof import('./src/components/ty-search-form.vue')['default']
|
TySearchForm: typeof import('./src/components/ty-search-form.vue')['default']
|
||||||
TySetting: typeof import('./src/components/ty-setting.vue')['default']
|
TySetting: typeof import('./src/components/ty-setting.vue')['default']
|
||||||
TySettingPanel: typeof import('./src/components/ty-setting-panel.vue')['default']
|
TySettingPanel: typeof import('./src/components/ty-setting-panel.vue')['default']
|
||||||
TySidebar: typeof import('./src/components/ty-sidebar.vue')['default']
|
TySidebar: typeof import('./src/components/ty-sidebar.vue')['default']
|
||||||
TyTable: typeof import('./src/components/ty-table.vue')['default']
|
TyTable: typeof import('./src/components/ty-table.vue')['default']
|
||||||
TyTabs: typeof import('./src/components/ty-tabs.vue')['default']
|
TyTabs: typeof import('./src/components/ty-tabs.vue')['default']
|
||||||
|
TyTenantSwitch: typeof import('./src/components/ty-tenant-switch.vue')['default']
|
||||||
}
|
}
|
||||||
export interface GlobalDirectives {
|
export interface GlobalDirectives {
|
||||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
@ -72,11 +78,13 @@ declare module 'vue' {
|
|||||||
// For TSX support
|
// For TSX support
|
||||||
declare global {
|
declare global {
|
||||||
const ChangePasswordForm: typeof import('./src/components/change-password-form.vue')['default']
|
const ChangePasswordForm: typeof import('./src/components/change-password-form.vue')['default']
|
||||||
|
const ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||||
const ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
const ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||||
const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||||
const ElButton: typeof import('element-plus/es')['ElButton']
|
const ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
const ElCard: typeof import('element-plus/es')['ElCard']
|
const ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
const ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
const ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
|
const ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||||
const ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
const ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||||
const ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
const ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||||
const ElDialog: typeof import('element-plus/es')['ElDialog']
|
const ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
@ -94,8 +102,10 @@ declare global {
|
|||||||
const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
const ElOption: typeof import('element-plus/es')['ElOption']
|
const ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
const ElPagination: typeof import('element-plus/es')['ElPagination']
|
const ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
|
const ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
const ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
const ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
const ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
const ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
|
const ElResult: typeof import('element-plus/es')['ElResult']
|
||||||
const ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
const ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||||
const ElSelect: typeof import('element-plus/es')['ElSelect']
|
const ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
const ElSlider: typeof import('element-plus/es')['ElSlider']
|
const ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||||
@ -116,10 +126,12 @@ declare global {
|
|||||||
const TyForm: typeof import('./src/components/ty-form.vue')['default']
|
const TyForm: typeof import('./src/components/ty-form.vue')['default']
|
||||||
const TyHeader: typeof import('./src/components/ty-header.vue')['default']
|
const TyHeader: typeof import('./src/components/ty-header.vue')['default']
|
||||||
const TyMixbar: typeof import('./src/components/ty-mixbar.vue')['default']
|
const TyMixbar: typeof import('./src/components/ty-mixbar.vue')['default']
|
||||||
|
const TyNotice: typeof import('./src/components/ty-notice.vue')['default']
|
||||||
const TySearchForm: typeof import('./src/components/ty-search-form.vue')['default']
|
const TySearchForm: typeof import('./src/components/ty-search-form.vue')['default']
|
||||||
const TySetting: typeof import('./src/components/ty-setting.vue')['default']
|
const TySetting: typeof import('./src/components/ty-setting.vue')['default']
|
||||||
const TySettingPanel: typeof import('./src/components/ty-setting-panel.vue')['default']
|
const TySettingPanel: typeof import('./src/components/ty-setting-panel.vue')['default']
|
||||||
const TySidebar: typeof import('./src/components/ty-sidebar.vue')['default']
|
const TySidebar: typeof import('./src/components/ty-sidebar.vue')['default']
|
||||||
const TyTable: typeof import('./src/components/ty-table.vue')['default']
|
const TyTable: typeof import('./src/components/ty-table.vue')['default']
|
||||||
const TyTabs: typeof import('./src/components/ty-tabs.vue')['default']
|
const TyTabs: typeof import('./src/components/ty-tabs.vue')['default']
|
||||||
|
const TyTenantSwitch: typeof import('./src/components/ty-tenant-switch.vue')['default']
|
||||||
}
|
}
|
||||||
@ -5,6 +5,7 @@ export interface LoginSuccess {
|
|||||||
accessToken: string
|
accessToken: string
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
expiresIn: number
|
expiresIn: number
|
||||||
|
mustChangePassword: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginNeedTenant {
|
export interface LoginNeedTenant {
|
||||||
@ -44,3 +45,29 @@ export function register(data: {
|
|||||||
export function changePassword(oldPassword: string, newPassword: string): Promise<unknown> {
|
export function changePassword(oldPassword: string, newPassword: string): Promise<unknown> {
|
||||||
return http.patch('/auth/me/password', { oldPassword, newPassword })
|
return http.patch('/auth/me/password', { oldPassword, newPassword })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TenantBrand {
|
||||||
|
name: string
|
||||||
|
brandName: string | null
|
||||||
|
logoUrl: string | null
|
||||||
|
themeColor: string | null
|
||||||
|
copyright: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前租户品牌。 */
|
||||||
|
export function fetchBrand(signal?: AbortSignal): Promise<TenantBrand> {
|
||||||
|
return http.get<TenantBrand>('/auth/brand', { signal })
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwitchTenantResult {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
expiresIn: number
|
||||||
|
tenantCode: string
|
||||||
|
tenantName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台超管切换租户。 */
|
||||||
|
export function switchTenant(tenantCode: string): Promise<SwitchTenantResult> {
|
||||||
|
return http.post<SwitchTenantResult>('/auth/switch-tenant', { tenantCode })
|
||||||
|
}
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export function listDicts(signal?: AbortSignal): Promise<{ list: DictEntry[] }>
|
|||||||
|
|
||||||
// ---- 字典类型 ----
|
// ---- 字典类型 ----
|
||||||
|
|
||||||
export function listDictTypes(page = 1, pageSize = 1000, signal?: AbortSignal): Promise<PageResult<DictType>> {
|
export function listDictTypes(page = 1, pageSize = 200, signal?: AbortSignal): Promise<PageResult<DictType>> {
|
||||||
return http.get<PageResult<DictType>>('/dict-types', { params: { page, pageSize }, signal })
|
return http.get<PageResult<DictType>>('/dict-types', { params: { page, pageSize }, signal })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
89
src/api/notice.ts
Normal file
89
src/api/notice.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { http } from '@/api/http'
|
||||||
|
|
||||||
|
export type NoticeStatus = 'enabled' | 'disabled'
|
||||||
|
|
||||||
|
export interface Notice {
|
||||||
|
id: string
|
||||||
|
tenantCode: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
status: NoticeStatus
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyNotice {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
status: string
|
||||||
|
createdAt: number
|
||||||
|
readAt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticeListQuery {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
title?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticeListResult {
|
||||||
|
list: Notice[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyNoticeListResult {
|
||||||
|
list: MyNotice[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticeUpsertData {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知分页(管理侧)。 */
|
||||||
|
export function listNotices(query: NoticeListQuery, signal?: AbortSignal): Promise<NoticeListResult> {
|
||||||
|
const params: Record<string, unknown> = { page: query.page, pageSize: query.pageSize }
|
||||||
|
if (query.title) params.title = query.title
|
||||||
|
if (query.status) params.status = query.status
|
||||||
|
return http.get<NoticeListResult>('/notices', { params, signal })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNotice(data: NoticeUpsertData): Promise<Notice> {
|
||||||
|
return http.post<Notice>('/notices', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateNotice(id: string, data: NoticeUpsertData): Promise<Notice> {
|
||||||
|
return http.patch<Notice>(`/notices/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteNotice(id: string): Promise<unknown> {
|
||||||
|
return http.delete(`/notices/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前用户可见通知(含已读状态)。 */
|
||||||
|
export function listMyNotices(query: { page: number; pageSize: number }, signal?: AbortSignal): Promise<MyNoticeListResult> {
|
||||||
|
return http.get<MyNoticeListResult>('/notices/mine', { params: query, signal })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未读通知数。 */
|
||||||
|
export function getUnreadCount(signal?: AbortSignal): Promise<number> {
|
||||||
|
return http.get<number>('/notices/unread-count', { signal })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记单条已读。 */
|
||||||
|
export function markRead(id: string): Promise<unknown> {
|
||||||
|
return http.post(`/notices/${id}/read`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记全部已读。 */
|
||||||
|
export function markAllRead(): Promise<unknown> {
|
||||||
|
return http.post('/notices/read-all')
|
||||||
|
}
|
||||||
@ -37,3 +37,8 @@ export function updateOrg(
|
|||||||
export function deleteOrg(id: string): Promise<unknown> {
|
export function deleteOrg(id: string): Promise<unknown> {
|
||||||
return http.delete(`/orgs/${id}`)
|
return http.delete(`/orgs/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 批量删除组织。 */
|
||||||
|
export function batchDeleteOrgs(ids: string[]): Promise<{ total: number; success: number; failed: string[] }> {
|
||||||
|
return http.post('/orgs/batch-delete', { ids })
|
||||||
|
}
|
||||||
|
|||||||
@ -49,3 +49,8 @@ export function updateResource(id: string, data: ResourceUpsertData): Promise<Re
|
|||||||
export function deleteResource(id: string): Promise<unknown> {
|
export function deleteResource(id: string): Promise<unknown> {
|
||||||
return http.delete(`/resources/${id}`)
|
return http.delete(`/resources/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 批量删除资源。 */
|
||||||
|
export function batchDeleteResources(ids: string[]): Promise<{ total: number; success: number; failed: string[] }> {
|
||||||
|
return http.post('/resources/batch-delete', { ids })
|
||||||
|
}
|
||||||
|
|||||||
@ -60,3 +60,13 @@ export function listAssignedResources(roleCode: string, signal?: AbortSignal): P
|
|||||||
export function assignResources(roleCode: string, resourceCodes: string[]): Promise<unknown> {
|
export function assignResources(roleCode: string, resourceCodes: string[]): Promise<unknown> {
|
||||||
return http.put(`/roles/${roleCode}/resources`, { resourceCodes })
|
return http.put(`/roles/${roleCode}/resources`, { resourceCodes })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 角色已分配的数据权限部门 code 列表(data_scope=custom)。 */
|
||||||
|
export function listAssignedOrgs(roleCode: string, signal?: AbortSignal): Promise<string[]> {
|
||||||
|
return http.get<string[]>(`/roles/${roleCode}/orgs`, { signal })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分配数据权限部门。 */
|
||||||
|
export function assignOrgs(roleCode: string, orgCodes: string[]): Promise<unknown> {
|
||||||
|
return http.put(`/roles/${roleCode}/orgs`, { orgCodes })
|
||||||
|
}
|
||||||
|
|||||||
39
src/api/session.ts
Normal file
39
src/api/session.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { http } from '@/api/http'
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
sid: string | null
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
nickname: string | null
|
||||||
|
ip: string | null
|
||||||
|
userAgent: string | null
|
||||||
|
loginAt: number
|
||||||
|
expireAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionListQuery {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
username?: string
|
||||||
|
ip?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionListResult {
|
||||||
|
list: Session[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 活跃会话分页列表。 */
|
||||||
|
export function listSessions(query: SessionListQuery, signal?: AbortSignal): Promise<SessionListResult> {
|
||||||
|
const params: Record<string, unknown> = { page: query.page, pageSize: query.pageSize }
|
||||||
|
if (query.username) params.username = query.username
|
||||||
|
if (query.ip) params.ip = query.ip
|
||||||
|
return http.get<SessionListResult>('/sessions', { params, signal })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单会话强退。 */
|
||||||
|
export function kickSession(sid: string): Promise<unknown> {
|
||||||
|
return http.post(`/sessions/${sid}/kick`)
|
||||||
|
}
|
||||||
@ -6,6 +6,10 @@ export interface Tenant {
|
|||||||
name: string
|
name: string
|
||||||
status: 'enabled' | 'disabled'
|
status: 'enabled' | 'disabled'
|
||||||
remark: string | null
|
remark: string | null
|
||||||
|
brandName: string | null
|
||||||
|
logoUrl: string | null
|
||||||
|
themeColor: string | null
|
||||||
|
copyright: string | null
|
||||||
createdAt: number
|
createdAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,11 +36,23 @@ export function listTenants(query: TenantListQuery, signal?: AbortSignal): Promi
|
|||||||
return http.get<TenantListResult>('/tenants', { params, signal })
|
return http.get<TenantListResult>('/tenants', { params, signal })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTenant(data: { code: string; name: string; status?: string; remark?: string }): Promise<Tenant> {
|
export function createTenant(data: {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
status?: string
|
||||||
|
remark?: string
|
||||||
|
brandName?: string
|
||||||
|
logoUrl?: string
|
||||||
|
themeColor?: string
|
||||||
|
copyright?: string
|
||||||
|
}): Promise<Tenant> {
|
||||||
return http.post<Tenant>('/tenants', data)
|
return http.post<Tenant>('/tenants', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateTenant(id: string, data: { name?: string; status?: string; remark?: string }): Promise<Tenant> {
|
export function updateTenant(
|
||||||
|
id: string,
|
||||||
|
data: { name?: string; status?: string; remark?: string; brandName?: string; logoUrl?: string; themeColor?: string; copyright?: string },
|
||||||
|
): Promise<Tenant> {
|
||||||
return http.patch<Tenant>(`/tenants/${id}`, data)
|
return http.patch<Tenant>(`/tenants/${id}`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { http } from '@/api/http'
|
import { http } from '@/api/http'
|
||||||
|
|
||||||
export type UserStatus = 'enabled' | 'disabled' | 'locked'
|
export type UserStatus = 'enabled' | 'disabled'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
@ -11,6 +11,8 @@ export interface User {
|
|||||||
phone: string | null
|
phone: string | null
|
||||||
avatar: string | null
|
avatar: string | null
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
|
lockedAt: number | null
|
||||||
|
lockReason: string | null
|
||||||
orgCode: string | null
|
orgCode: string | null
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
@ -40,6 +42,7 @@ export interface UserUpsertData {
|
|||||||
phone?: string
|
phone?: string
|
||||||
orgCode?: string | null
|
orgCode?: string | null
|
||||||
status?: UserStatus
|
status?: UserStatus
|
||||||
|
mustChangePassword?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户分页列表。 */
|
/** 用户分页列表。 */
|
||||||
@ -67,6 +70,11 @@ export function deleteUser(id: string): Promise<unknown> {
|
|||||||
return http.delete(`/users/${id}`)
|
return http.delete(`/users/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 批量删除用户。 */
|
||||||
|
export function batchDeleteUsers(ids: string[]): Promise<{ total: number; success: number; failed: string[] }> {
|
||||||
|
return http.post('/users/batch-delete', { ids })
|
||||||
|
}
|
||||||
|
|
||||||
/** 重置密码。 */
|
/** 重置密码。 */
|
||||||
export function resetPassword(id: string, password: string): Promise<unknown> {
|
export function resetPassword(id: string, password: string): Promise<unknown> {
|
||||||
return http.patch(`/users/${id}/password`, { password })
|
return http.patch(`/users/${id}/password`, { password })
|
||||||
@ -81,3 +89,18 @@ export function listUserRoles(userId: string, signal?: AbortSignal): Promise<str
|
|||||||
export function assignUserRoles(userId: string, roleCodes: string[]): Promise<unknown> {
|
export function assignUserRoles(userId: string, roleCodes: string[]): Promise<unknown> {
|
||||||
return http.put(`/users/${userId}/roles`, { roleCodes })
|
return http.put(`/users/${userId}/roles`, { roleCodes })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 踢出用户全部会话。 */
|
||||||
|
export function kickSessions(userId: string): Promise<unknown> {
|
||||||
|
return http.post(`/users/${userId}/kick-sessions`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 锁定用户(可附原因)。 */
|
||||||
|
export function lockUser(userId: string, reason?: string): Promise<User> {
|
||||||
|
return http.post<User>(`/users/${userId}/lock`, { reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解锁用户。 */
|
||||||
|
export function unlockUser(userId: string): Promise<User> {
|
||||||
|
return http.post<User>(`/users/${userId}/unlock`)
|
||||||
|
}
|
||||||
|
|||||||
@ -49,6 +49,8 @@
|
|||||||
<button class="ty-header__action" type="button" title="布局设置" @click="emit('open-settings')">
|
<button class="ty-header__action" type="button" title="布局设置" @click="emit('open-settings')">
|
||||||
<icon type="iconify" icon="mdi:cog-outline" />
|
<icon type="iconify" icon="mdi:cog-outline" />
|
||||||
</button>
|
</button>
|
||||||
|
<ty-notice />
|
||||||
|
<ty-tenant-switch />
|
||||||
<el-dropdown trigger="click" @command="onUserCommand">
|
<el-dropdown trigger="click" @command="onUserCommand">
|
||||||
<span class="ty-header__user">
|
<span class="ty-header__user">
|
||||||
<icon type="iconify" icon="mdi:account-circle" />
|
<icon type="iconify" icon="mdi:account-circle" />
|
||||||
|
|||||||
128
src/components/ty-notice.vue
Normal file
128
src/components/ty-notice.vue
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<el-popover placement="bottom" :width="340" trigger="click" @show="onOpen">
|
||||||
|
<template #reference>
|
||||||
|
<el-badge :value="unreadCount" :hidden="unreadCount === 0" :max="99" class="ty-notice__badge">
|
||||||
|
<button class="ty-notice__btn" type="button" title="通知">
|
||||||
|
<icon type="iconify" icon="mdi:bell-outline" />
|
||||||
|
</button>
|
||||||
|
</el-badge>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="ty-notice">
|
||||||
|
<div class="ty-notice__head">
|
||||||
|
<span class="ty-notice__head-title">通知</span>
|
||||||
|
<el-button v-if="unreadCount > 0" link type="primary" @click="onReadAll">全部已读</el-button>
|
||||||
|
</div>
|
||||||
|
<el-scrollbar max-height="320px">
|
||||||
|
<div v-if="list.length === 0" class="ty-notice__empty">暂无通知</div>
|
||||||
|
<div
|
||||||
|
v-for="n in list"
|
||||||
|
:key="n.id"
|
||||||
|
class="ty-notice__item"
|
||||||
|
:class="{ 'is-unread': n.readAt == null }"
|
||||||
|
@click="onRead(n)"
|
||||||
|
>
|
||||||
|
<div class="ty-notice__item-title">
|
||||||
|
<span v-if="n.readAt == null" class="ty-notice__dot" />
|
||||||
|
{{ n.title }}
|
||||||
|
</div>
|
||||||
|
<div class="ty-notice__item-content">{{ n.content }}</div>
|
||||||
|
<div class="ty-notice__item-time">{{ formatTime(n.createdAt) }}</div>
|
||||||
|
</div>
|
||||||
|
</el-scrollbar>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useNoticeStore } from '@/stores/modules/notice'
|
||||||
|
import type { MyNotice } from '@/api/notice'
|
||||||
|
|
||||||
|
defineOptions({ name: 'TyNotice' })
|
||||||
|
|
||||||
|
const noticeStore = useNoticeStore()
|
||||||
|
const unreadCount = computed(() => noticeStore.unreadCount)
|
||||||
|
const list = computed(() => noticeStore.list)
|
||||||
|
|
||||||
|
function formatTime(v: number): string {
|
||||||
|
const d = new Date(v)
|
||||||
|
const pad = (x: number) => String(x).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpen(): void {
|
||||||
|
void noticeStore.fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRead(n: MyNotice): Promise<void> {
|
||||||
|
if (n.readAt == null) await noticeStore.readOne(n.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onReadAll(): Promise<void> {
|
||||||
|
await noticeStore.readAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void noticeStore.fetchUnread()
|
||||||
|
noticeStore.startPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
noticeStore.stopPolling()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ty-notice__badge {
|
||||||
|
@apply inline-flex items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ty-notice__btn {
|
||||||
|
@apply inline-flex items-center justify-center w-[34px] h-[34px] border-none rounded-base bg-transparent text-[18px] text-text-regular cursor-pointer transition-colors duration-200;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-bg-page;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ty-notice__head {
|
||||||
|
@apply flex items-center justify-between px-3 py-2 border-b border-solid;
|
||||||
|
border-color: var(--wm-border-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ty-notice__head-title {
|
||||||
|
@apply font-semibold text-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ty-notice__empty {
|
||||||
|
@apply py-8 text-center text-sm text-text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ty-notice__item {
|
||||||
|
@apply px-3 py-2.5 cursor-pointer transition-colors duration-150;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-bg-page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ty-notice__item-title {
|
||||||
|
@apply flex items-center gap-1.5 text-sm text-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ty-notice__item-content {
|
||||||
|
@apply mt-1 truncate text-xs text-text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ty-notice__item-time {
|
||||||
|
@apply mt-1 text-xs;
|
||||||
|
color: var(--wm-text-color-placeholder, #909399);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ty-notice__dot {
|
||||||
|
@apply inline-block w-2 h-2 shrink-0 rounded-full;
|
||||||
|
background: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
src/components/ty-tenant-switch.vue
Normal file
64
src/components/ty-tenant-switch.vue
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<el-dropdown v-if="permissionStore.isSuper" trigger="click" @command="onCommand">
|
||||||
|
<button class="ty-tenant-switch" type="button" title="切换租户">
|
||||||
|
<icon type="iconify" icon="mdi:domain" />
|
||||||
|
</button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item v-for="t in tenants" :key="t.code" :command="t.code" :disabled="t.code === currentTenantCode">
|
||||||
|
{{ t.name }}({{ t.code }})
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/modules/auth'
|
||||||
|
import { usePermissionStore } from '@/stores/modules/permission'
|
||||||
|
import { listTenants, type Tenant } from '@/api/tenant'
|
||||||
|
|
||||||
|
defineOptions({ name: 'TyTenantSwitch' })
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const permissionStore = usePermissionStore()
|
||||||
|
const tenants = ref<Tenant[]>([])
|
||||||
|
|
||||||
|
const currentTenantCode = computed(() => decodeTenantCode(auth.accessToken))
|
||||||
|
|
||||||
|
function decodeTenantCode(token: string | null): string {
|
||||||
|
if (!token) return ''
|
||||||
|
try {
|
||||||
|
const payload = token.split('.')[1]
|
||||||
|
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=')
|
||||||
|
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
|
||||||
|
const json = JSON.parse(new TextDecoder().decode(bytes))
|
||||||
|
return (json.tenantCode as string) ?? ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCommand(tenantCode: string): Promise<void> {
|
||||||
|
if (!tenantCode || tenantCode === currentTenantCode.value) return
|
||||||
|
await auth.switchTenant(tenantCode)
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const res = await listTenants({ page: 1, pageSize: 200 })
|
||||||
|
tenants.value = res.list
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ty-tenant-switch {
|
||||||
|
@apply inline-flex items-center justify-center w-[34px] h-[34px] border-none rounded-base bg-transparent text-[18px] text-text-regular cursor-pointer transition-colors duration-200;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-bg-page;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -18,8 +18,6 @@ export interface LoginConfig {
|
|||||||
layout: LoginLayout
|
layout: LoginLayout
|
||||||
/** 背景(图 URL 或 CSS 渐变/颜色)。 */
|
/** 背景(图 URL 或 CSS 渐变/颜色)。 */
|
||||||
background?: string
|
background?: string
|
||||||
/** 登录提交的租户编码(页面不展示租户输入框,统一用此默认租户)。 */
|
|
||||||
tenantCode: string
|
|
||||||
/** 登录字段(JSON Schema,字段名须与后端 DTO 一致)。 */
|
/** 登录字段(JSON Schema,字段名须与后端 DTO 一致)。 */
|
||||||
fields: JsonSchema
|
fields: JsonSchema
|
||||||
/** 是否显示「记住我」。 */
|
/** 是否显示「记住我」。 */
|
||||||
@ -40,7 +38,6 @@ export const defaultLoginConfig: LoginConfig = {
|
|||||||
subtitle: '通用系统管理脚手架',
|
subtitle: '通用系统管理脚手架',
|
||||||
copyright: '云南投云科技有限公司',
|
copyright: '云南投云科技有限公司',
|
||||||
layout: 'split',
|
layout: 'split',
|
||||||
tenantCode: 'touyun',
|
|
||||||
fields: {
|
fields: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@ -57,9 +57,13 @@
|
|||||||
@close-all="handleCloseAll"
|
@close-all="handleCloseAll"
|
||||||
/>
|
/>
|
||||||
<div class="app-main">
|
<div class="app-main">
|
||||||
<router-view />
|
<router-view v-slot="{ Component, route }">
|
||||||
|
<keep-alive :include="cachedNames">
|
||||||
|
<component :is="Component" :key="route.path" />
|
||||||
|
</keep-alive>
|
||||||
|
</router-view>
|
||||||
</div>
|
</div>
|
||||||
<ty-footer :fixed="app.footerFixed" />
|
<ty-footer :fixed="app.footerFixed" :copyright="copyright" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -72,12 +76,12 @@ import { computed, onMounted, ref, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useAppStore } from '@/stores/modules/app'
|
import { useAppStore } from '@/stores/modules/app'
|
||||||
import { HOME_PATH, useTabsStore } from '@/stores/modules/tabs'
|
import { HOME_PATH, pathToName, useTabsStore } from '@/stores/modules/tabs'
|
||||||
import { useAuthStore } from '@/stores/modules/auth'
|
import { useAuthStore } from '@/stores/modules/auth'
|
||||||
import { useMenuStore } from '@/stores/modules/menu'
|
import { useMenuStore } from '@/stores/modules/menu'
|
||||||
import { useDictStore } from '@/stores/modules/dict'
|
import { useDictStore } from '@/stores/modules/dict'
|
||||||
import { usePermissionStore } from '@/stores/modules/permission'
|
import { usePermissionStore } from '@/stores/modules/permission'
|
||||||
import logo from '@/assets/images/logo.png'
|
import defaultLogo from '@/assets/images/logo.png'
|
||||||
import type { LayoutSettings } from '@/config/layout'
|
import type { LayoutSettings } from '@/config/layout'
|
||||||
import type { ResourceNode } from '@/types/resource'
|
import type { ResourceNode } from '@/types/resource'
|
||||||
import type { BreadcrumbItem } from '@/components/ty-breadcrumb.vue'
|
import type { BreadcrumbItem } from '@/components/ty-breadcrumb.vue'
|
||||||
@ -96,20 +100,31 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
|
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
|
||||||
|
|
||||||
const title = '投云科技'
|
const title = computed(() => auth.brand?.brandName || auth.brand?.name || '投云科技')
|
||||||
|
const logo = computed(() => auth.brand?.logoUrl || defaultLogo)
|
||||||
|
const copyright = computed(() => auth.brand?.copyright || '云南投云科技有限公司')
|
||||||
const HEADER_HEIGHT = 56
|
const HEADER_HEIGHT = 56
|
||||||
const menus = computed(() => menuStore.menus)
|
const menus = computed(() => menuStore.menus)
|
||||||
const settingsOpen = ref(false)
|
const settingsOpen = ref(false)
|
||||||
|
|
||||||
// 登录后拉取菜单、字典与权限码
|
// 登录后拉取菜单、字典、权限码与租户品牌
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
menuStore.fetch()
|
menuStore.fetch()
|
||||||
dictStore.fetch()
|
dictStore.fetch()
|
||||||
permissionStore.fetch()
|
permissionStore.fetch()
|
||||||
|
try {
|
||||||
|
await auth.fetchBrandState()
|
||||||
|
if (auth.brand?.themeColor) app.themeColor = auth.brand.themeColor
|
||||||
|
} catch {
|
||||||
|
// 品牌拉取失败则用默认
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const activePath = computed(() => route.path)
|
const activePath = computed(() => route.path)
|
||||||
|
|
||||||
|
/** keep-alive 缓存的组件名集合:打开且未关闭缓存的页签(keepAlive !== false 以兼容旧持久化数据)。 */
|
||||||
|
const cachedNames = computed(() => tabsStore.tabs.filter((t) => t.keepAlive !== false).map((t) => pathToName(t.path)))
|
||||||
|
|
||||||
/** 当前路由对应的顶级菜单(及其叶子)。 */
|
/** 当前路由对应的顶级菜单(及其叶子)。 */
|
||||||
const activeTop = computed<ResourceNode | null>(() => {
|
const activeTop = computed<ResourceNode | null>(() => {
|
||||||
for (const m of menus.value) {
|
for (const m of menus.value) {
|
||||||
@ -297,7 +312,7 @@ watch(
|
|||||||
const top = activeTop.value
|
const top = activeTop.value
|
||||||
const leaf = top?.children?.find((c) => (c.path ?? c.code) === p)
|
const leaf = top?.children?.find((c) => (c.path ?? c.code) === p)
|
||||||
const title = leaf?.name ?? top?.name ?? p
|
const title = leaf?.name ?? top?.name ?? p
|
||||||
tabsStore.addTab({ path: p, title, closable: p !== '/' })
|
tabsStore.addTab({ path: p, title, closable: p !== '/', keepAlive: route.meta.keepAlive !== false })
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|||||||
10
src/main.ts
10
src/main.ts
@ -1,4 +1,5 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
@ -36,4 +37,13 @@ setAuthExpiredHandler(() => {
|
|||||||
installI18n(app)
|
installI18n(app)
|
||||||
installAssets()
|
installAssets()
|
||||||
|
|
||||||
|
// 全局兜底:未捕获的组件错误 / Promise 拒绝,统一弹出错误提示(避免静默失败)
|
||||||
|
app.config.errorHandler = (err) => {
|
||||||
|
ElMessage.error(err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
window.addEventListener('unhandledrejection', (e) => {
|
||||||
|
const reason = e.reason
|
||||||
|
ElMessage.error(reason instanceof Error ? reason.message : String(reason))
|
||||||
|
})
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
78
src/pages/force-change-password.vue
Normal file
78
src/pages/force-change-password.vue
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<route lang="yaml">
|
||||||
|
meta:
|
||||||
|
layout: 'blank'
|
||||||
|
freeAccess: true
|
||||||
|
</route>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="force-pwd">
|
||||||
|
<el-card class="force-pwd__card" shadow="never">
|
||||||
|
<h2 class="force-pwd__title">修改初始密码</h2>
|
||||||
|
<p class="force-pwd__tip">首次登录请先修改初始密码</p>
|
||||||
|
<change-password-form ref="formRef" />
|
||||||
|
<el-button type="primary" class="force-pwd__submit" :loading="loading" @click="onSubmit">
|
||||||
|
确认修改
|
||||||
|
</el-button>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAuthStore } from '@/stores/modules/auth'
|
||||||
|
import { changePassword } from '@/api/auth'
|
||||||
|
import ChangePasswordForm from '@/components/change-password-form.vue'
|
||||||
|
|
||||||
|
interface FormApi {
|
||||||
|
submit: () => Promise<{ oldPassword: string; newPassword: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const formRef = ref<FormApi>()
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function onSubmit(): Promise<void> {
|
||||||
|
let result: { oldPassword: string; newPassword: string } | undefined
|
||||||
|
try {
|
||||||
|
result = await formRef.value?.submit()
|
||||||
|
} catch {
|
||||||
|
return // 校验失败
|
||||||
|
}
|
||||||
|
if (result == null) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await changePassword(result.oldPassword, result.newPassword)
|
||||||
|
ElMessage.success('密码已修改,请重新登录')
|
||||||
|
auth.reset()
|
||||||
|
router.push('/login')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.force-pwd {
|
||||||
|
@apply flex min-h-screen items-center justify-center;
|
||||||
|
background: var(--wm-bg-color-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.force-pwd__card {
|
||||||
|
@apply w-[420px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.force-pwd__title {
|
||||||
|
@apply text-center text-lg font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.force-pwd__tip {
|
||||||
|
@apply mb-md text-center text-sm text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.force-pwd__submit {
|
||||||
|
@apply mt-md w-full;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -35,6 +35,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
defineOptions({ name: 'dashboard' })
|
||||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/modules/auth'
|
import { useAuthStore } from '@/stores/modules/auth'
|
||||||
|
|||||||
@ -208,11 +208,14 @@ const { loading, run: doLogin } = useRequest(
|
|||||||
|
|
||||||
async function selectTenant(tenantCode: string): Promise<void> {
|
async function selectTenant(tenantCode: string): Promise<void> {
|
||||||
tenantDialogVisible.value = false
|
tenantDialogVisible.value = false
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await auth.login(model.username, model.password, tenantCode)
|
await auth.login(model.username, model.password, tenantCode)
|
||||||
router.push(config.homePath)
|
router.push(config.homePath)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = (e as Error).message
|
error.value = (e as Error).message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
defineOptions({ name: 'audit' })
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import type { JsonSchema, TableColumn } from '@/types/crud'
|
import type { JsonSchema, TableColumn } from '@/types/crud'
|
||||||
import { useRequest } from '@/composables/useRequest'
|
import { useRequest } from '@/composables/useRequest'
|
||||||
|
|||||||
@ -20,6 +20,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
defineOptions({ name: 'login-log' })
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import type { JsonSchema, TableColumn } from '@/types/crud'
|
import type { JsonSchema, TableColumn } from '@/types/crud'
|
||||||
import { useRequest } from '@/composables/useRequest'
|
import { useRequest } from '@/composables/useRequest'
|
||||||
|
|||||||
130
src/pages/monitor/session/index.vue
Normal file
130
src/pages/monitor/session/index.vue
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="session-page">
|
||||||
|
<el-card class="session-page__search" shadow="never">
|
||||||
|
<ty-search-form :schema="searchSchema" :model-value="searchQuery" @search="handleSearch" @reset="handleReset" />
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="session-page__table" shadow="never">
|
||||||
|
<div class="session-page__table-body">
|
||||||
|
<ty-table
|
||||||
|
:columns="columns"
|
||||||
|
:data="list"
|
||||||
|
:loading="loading"
|
||||||
|
:total="total"
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:actions-width="150"
|
||||||
|
>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<el-button link type="danger" :disabled="!row.sid" @click="kick(row)">强退</el-button>
|
||||||
|
<el-button link type="warning" @click="kickAll(row)">踢全部</el-button>
|
||||||
|
</template>
|
||||||
|
</ty-table>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
defineOptions({ name: 'session' })
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import type { JsonSchema, TableColumn } from '@/types/crud'
|
||||||
|
import { useRequest } from '@/composables/useRequest'
|
||||||
|
import { kickSession, listSessions } from '@/api/session'
|
||||||
|
import { kickSessions } from '@/api/user'
|
||||||
|
|
||||||
|
const columns: TableColumn[] = [
|
||||||
|
{ prop: 'username', label: '用户名', width: 120 },
|
||||||
|
{ prop: 'nickname', label: '昵称', width: 110 },
|
||||||
|
{ prop: 'ip', label: 'IP', width: 130 },
|
||||||
|
{ prop: 'userAgent', label: 'UA', minWidth: 200 },
|
||||||
|
{ prop: 'loginAt', label: '登录时间', width: 160, type: 'datetime' },
|
||||||
|
{ prop: 'expireAt', label: '过期时间', width: 160, type: 'datetime' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const searchSchema: JsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
username: { type: 'string', title: '用户名', placeholder: '请输入用户名' },
|
||||||
|
ip: { type: 'string', title: 'IP', placeholder: '请输入设备 IP' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const searchQuery = reactive({ username: '', ip: '' })
|
||||||
|
const appliedQuery = reactive({ username: '', ip: '' })
|
||||||
|
|
||||||
|
const { data, loading, run } = useRequest(
|
||||||
|
(signal: AbortSignal) => listSessions({ page: page.value, pageSize: pageSize.value, ...appliedQuery }, signal),
|
||||||
|
{ manual: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const list = computed(() => data.value?.list ?? [])
|
||||||
|
const total = computed(() => data.value?.total ?? 0)
|
||||||
|
|
||||||
|
function handleSearch(): void {
|
||||||
|
appliedQuery.username = searchQuery.username
|
||||||
|
appliedQuery.ip = searchQuery.ip
|
||||||
|
page.value = 1
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset(): void {
|
||||||
|
appliedQuery.username = ''
|
||||||
|
appliedQuery.ip = ''
|
||||||
|
page.value = 1
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kick(row: Record<string, any>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认强退「${row.username}」的这个会话?`, '提示', { type: 'warning' })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await kickSession(String(row.sid))
|
||||||
|
ElMessage.success('已强退')
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kickAll(row: Record<string, any>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认踢出「${row.username}」的全部会话?`, '提示', { type: 'warning' })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await kickSessions(String(row.userId))
|
||||||
|
ElMessage.success('已踢出全部会话')
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(run)
|
||||||
|
watch([page, pageSize], () => {
|
||||||
|
run()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.session-page {
|
||||||
|
@apply flex h-full flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-page__search {
|
||||||
|
@apply mb-md flex-shrink-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-page__table {
|
||||||
|
@apply flex min-h-0 flex-1 flex-col;
|
||||||
|
|
||||||
|
:deep(.el-card__body) {
|
||||||
|
@apply flex min-h-0 flex-1 flex-col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-page__table-body {
|
||||||
|
@apply min-h-0 flex-1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -14,6 +14,11 @@ meta:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-form ref="formRef" class="auth__form" :model="model" :rules="rules" size="large" @keyup.enter="onSubmit">
|
<el-form ref="formRef" class="auth__form" :model="model" :rules="rules" size="large" @keyup.enter="onSubmit">
|
||||||
|
<el-form-item prop="tenantCode">
|
||||||
|
<el-input v-model="model.tenantCode" placeholder="租户编码">
|
||||||
|
<template #prefix><icon type="iconify" icon="mdi:domain" /></template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item prop="username">
|
<el-form-item prop="username">
|
||||||
<el-input v-model="model.username" placeholder="用户名">
|
<el-input v-model="model.username" placeholder="用户名">
|
||||||
<template #prefix><icon type="iconify" icon="mdi:account" /></template>
|
<template #prefix><icon type="iconify" icon="mdi:account" /></template>
|
||||||
@ -71,9 +76,10 @@ applyTheme(app.themeColor, app.isDark)
|
|||||||
|
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const model = reactive({ username: '', password: '', confirmPassword: '', email: '', phone: '' })
|
const model = reactive({ tenantCode: '', username: '', password: '', confirmPassword: '', email: '', phone: '' })
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
|
tenantCode: [{ required: true, message: '请输入租户编码', trigger: 'blur' }],
|
||||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||||
password: [
|
password: [
|
||||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
@ -105,7 +111,7 @@ async function onSubmit(): Promise<void> {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await register({
|
await register({
|
||||||
tenantCode: config.tenantCode,
|
tenantCode: model.tenantCode,
|
||||||
username: model.username,
|
username: model.username,
|
||||||
password: model.password,
|
password: model.password,
|
||||||
email: model.email,
|
email: model.email,
|
||||||
|
|||||||
@ -15,6 +15,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
defineOptions({ name: 'config' })
|
||||||
import type { JsonSchema, PageQuery, TableColumn } from '@/types/crud'
|
import type { JsonSchema, PageQuery, TableColumn } from '@/types/crud'
|
||||||
import { createConfig, deleteConfig, listConfigs, updateConfig, type ConfigListQuery } from '@/api/config'
|
import { createConfig, deleteConfig, listConfigs, updateConfig, type ConfigListQuery } from '@/api/config'
|
||||||
|
|
||||||
|
|||||||
@ -63,6 +63,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
defineOptions({ name: 'dict' })
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { CrudAction, JsonSchema, PageQuery, TableColumn } from '@/types/crud'
|
import type { CrudAction, JsonSchema, PageQuery, TableColumn } from '@/types/crud'
|
||||||
@ -96,7 +98,7 @@ const COLOR_LABELS: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- 字典类型(左) ----
|
// ---- 字典类型(左) ----
|
||||||
const { data: typePage, refresh: refreshTypes } = useRequest((signal: AbortSignal) => listDictTypes(1, 1000, signal))
|
const { data: typePage, refresh: refreshTypes } = useRequest((signal: AbortSignal) => listDictTypes(1, 200, signal))
|
||||||
const types = computed<DictType[]>(() => typePage.value?.list ?? [])
|
const types = computed<DictType[]>(() => typePage.value?.list ?? [])
|
||||||
|
|
||||||
const typeKeyword = ref('')
|
const typeKeyword = ref('')
|
||||||
|
|||||||
63
src/pages/system/notice/index.vue
Normal file
63
src/pages/system/notice/index.vue
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<ty-crud
|
||||||
|
entity-name="通知"
|
||||||
|
:columns="columns"
|
||||||
|
:search-schema="searchSchema"
|
||||||
|
:form-schema="formSchema"
|
||||||
|
:list="list"
|
||||||
|
:create="create"
|
||||||
|
:update="update"
|
||||||
|
:remove="remove"
|
||||||
|
id-key="id"
|
||||||
|
name-key="title"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { JsonSchema, PageQuery, TableColumn } from '@/types/crud'
|
||||||
|
import { createNotice, deleteNotice, listNotices, updateNotice, type NoticeListQuery } from '@/api/notice'
|
||||||
|
|
||||||
|
defineOptions({ name: 'notice' })
|
||||||
|
|
||||||
|
const columns: TableColumn[] = [
|
||||||
|
{ prop: 'title', label: '标题', minWidth: 200 },
|
||||||
|
{ prop: 'content', label: '内容', minWidth: 300 },
|
||||||
|
{ prop: 'status', label: '状态', width: 90, align: 'center', type: 'tag', dictCode: 'common_status' },
|
||||||
|
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const searchSchema: JsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', title: '标题', placeholder: '请输入标题' },
|
||||||
|
status: { type: 'string', title: '状态', dictCode: 'common_status', placeholder: '全部' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const formSchema: JsonSchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', title: '标题', placeholder: '请输入标题', minLength: 2 },
|
||||||
|
content: { type: 'string', title: '内容', format: 'textarea', placeholder: '请输入通知内容' },
|
||||||
|
status: { type: 'string', title: '状态', dictCode: 'common_status', default: 'enabled' },
|
||||||
|
},
|
||||||
|
required: ['title', 'content'],
|
||||||
|
}
|
||||||
|
|
||||||
|
function toListQuery(query: PageQuery): NoticeListQuery {
|
||||||
|
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
|
||||||
|
return {
|
||||||
|
page: query.page,
|
||||||
|
pageSize: query.pageSize,
|
||||||
|
title: str(query.title),
|
||||||
|
status: str(query.status),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = (query: PageQuery, signal?: AbortSignal) => listNotices(toListQuery(query), signal)
|
||||||
|
const create = (data: Record<string, any>) =>
|
||||||
|
createNotice({ title: data.title, content: data.content, status: data.status })
|
||||||
|
const update = (id: string, data: Record<string, any>) =>
|
||||||
|
updateNotice(id, { title: data.title, content: data.content, status: data.status })
|
||||||
|
const remove = (id: string) => deleteNotice(id)
|
||||||
|
</script>
|
||||||
@ -39,6 +39,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
defineOptions({ name: 'org' })
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { JsonSchema, TableColumn } from '@/types/crud'
|
import type { JsonSchema, TableColumn } from '@/types/crud'
|
||||||
@ -46,7 +48,7 @@ import TyForm from '@/components/ty-form.vue'
|
|||||||
import { showDialog } from '@/utils/dialog'
|
import { showDialog } from '@/utils/dialog'
|
||||||
import { useRequest } from '@/composables/useRequest'
|
import { useRequest } from '@/composables/useRequest'
|
||||||
import { buildTree, collectDescendants, filterTree, type TreeNode } from '@/utils/tree'
|
import { buildTree, collectDescendants, filterTree, type TreeNode } from '@/utils/tree'
|
||||||
import { createOrg, deleteOrg, listOrgs, updateOrg, type Org } from '@/api/org'
|
import { batchDeleteOrgs, createOrg, deleteOrg, listOrgs, updateOrg, type Org } from '@/api/org'
|
||||||
|
|
||||||
type OrgNode = TreeNode<Org>
|
type OrgNode = TreeNode<Org>
|
||||||
|
|
||||||
@ -238,10 +240,14 @@ async function batchDelete(rows: Record<string, any>[]): Promise<void> {
|
|||||||
codes.add(String(r.code))
|
codes.add(String(r.code))
|
||||||
for (const c of collectDescendants(orgs.value, String(r.code))) codes.add(c)
|
for (const c of collectDescendants(orgs.value, String(r.code))) codes.add(c)
|
||||||
}
|
}
|
||||||
const targets = orgs.value.filter((o) => codes.has(o.code))
|
const ids = orgs.value.filter((o) => codes.has(o.code)).map((o) => o.id)
|
||||||
for (const t of targets) await deleteOrg(t.id)
|
const result = await batchDeleteOrgs(ids)
|
||||||
selectedRows.value = []
|
if (result.failed.length > 0) {
|
||||||
|
ElMessage.warning(`删除完成:成功 ${result.success} 个,失败 ${result.failed.length} 个`)
|
||||||
|
} else {
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
|
}
|
||||||
|
selectedRows.value = []
|
||||||
await refreshOrgs()
|
await refreshOrgs()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -39,6 +39,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
defineOptions({ name: 'resource' })
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { JsonSchema, TableColumn } from '@/types/crud'
|
import type { JsonSchema, TableColumn } from '@/types/crud'
|
||||||
@ -46,7 +48,7 @@ import TyForm from '@/components/ty-form.vue'
|
|||||||
import { showDialog } from '@/utils/dialog'
|
import { showDialog } from '@/utils/dialog'
|
||||||
import { useRequest } from '@/composables/useRequest'
|
import { useRequest } from '@/composables/useRequest'
|
||||||
import { buildTree, collectDescendants, filterTree, type TreeNode } from '@/utils/tree'
|
import { buildTree, collectDescendants, filterTree, type TreeNode } from '@/utils/tree'
|
||||||
import { createResource, deleteResource, listResources, updateResource, type Resource, type ResourceUpsertData } from '@/api/resource'
|
import { batchDeleteResources, createResource, deleteResource, listResources, updateResource, type Resource, type ResourceUpsertData } from '@/api/resource'
|
||||||
|
|
||||||
type ResourceNode = TreeNode<Resource>
|
type ResourceNode = TreeNode<Resource>
|
||||||
|
|
||||||
@ -288,10 +290,14 @@ async function batchDelete(rows: Record<string, any>[]): Promise<void> {
|
|||||||
codes.add(String(r.code))
|
codes.add(String(r.code))
|
||||||
for (const c of collectDescendants(resources.value, String(r.code))) codes.add(c)
|
for (const c of collectDescendants(resources.value, String(r.code))) codes.add(c)
|
||||||
}
|
}
|
||||||
const targets = resources.value.filter((o) => codes.has(o.code))
|
const ids = resources.value.filter((o) => codes.has(o.code)).map((o) => o.id)
|
||||||
for (const t of targets) await deleteResource(t.id)
|
const result = await batchDeleteResources(ids)
|
||||||
selectedRows.value = []
|
if (result.failed.length > 0) {
|
||||||
|
ElMessage.warning(`删除完成:成功 ${result.success} 个,失败 ${result.failed.length} 个`)
|
||||||
|
} else {
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
|
}
|
||||||
|
selectedRows.value = []
|
||||||
await refreshResources()
|
await refreshResources()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
57
src/pages/system/role/assign-orgs-form.vue
Normal file
57
src/pages/system/role/assign-orgs-form.vue
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<el-tree
|
||||||
|
v-if="ready"
|
||||||
|
ref="treeRef"
|
||||||
|
class="assign-tree"
|
||||||
|
:data="tree"
|
||||||
|
node-key="code"
|
||||||
|
show-checkbox
|
||||||
|
:props="{ label: 'name', children: 'children' }"
|
||||||
|
default-expand-all
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import { useRequest } from '@/composables/useRequest'
|
||||||
|
import { listOrgs } from '@/api/org'
|
||||||
|
import { listAssignedOrgs } from '@/api/role'
|
||||||
|
import { buildTree, type TreeNode } from '@/utils/tree'
|
||||||
|
import type { Org } from '@/api/org'
|
||||||
|
|
||||||
|
defineOptions({ name: 'AssignOrgsForm' })
|
||||||
|
|
||||||
|
const props = defineProps<{ roleCode: string }>()
|
||||||
|
|
||||||
|
interface TreeApi {
|
||||||
|
getCheckedKeys: (leafOnly?: boolean) => (string | number)[]
|
||||||
|
setCheckedKeys: (keys: (string | number)[], leafOnly?: boolean) => void
|
||||||
|
}
|
||||||
|
const treeRef = ref<TreeApi>()
|
||||||
|
|
||||||
|
const { data: orgList } = useRequest((signal: AbortSignal) => listOrgs(signal))
|
||||||
|
const { data: assigned } = useRequest((signal: AbortSignal) => listAssignedOrgs(props.roleCode, signal))
|
||||||
|
|
||||||
|
const tree = computed(() => buildTree(orgList.value ?? []) as TreeNode<Org>[])
|
||||||
|
const ready = computed(() => orgList.value !== undefined && assigned.value !== undefined)
|
||||||
|
|
||||||
|
watch(ready, async (r) => {
|
||||||
|
if (r) {
|
||||||
|
await nextTick()
|
||||||
|
treeRef.value?.setCheckedKeys(assigned.value ?? [], true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function submit(): { orgCodes: string[] } {
|
||||||
|
const checked = treeRef.value?.getCheckedKeys(true) ?? []
|
||||||
|
return { orgCodes: [...new Set(checked.map(String))] }
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ submit })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.assign-tree {
|
||||||
|
@apply max-h-[420px] overflow-y-auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -16,11 +16,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
defineOptions({ name: 'role' })
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import type { CrudAction, JsonSchema, PageQuery, TableColumn } from '@/types/crud'
|
import type { CrudAction, JsonSchema, PageQuery, TableColumn } from '@/types/crud'
|
||||||
import { showDialog } from '@/utils/dialog'
|
import { showDialog } from '@/utils/dialog'
|
||||||
import { assignResources, createRole, deleteRole, listRoles, updateRole, type RoleListQuery } from '@/api/role'
|
import { assignOrgs, assignResources, createRole, deleteRole, listRoles, updateRole, type RoleListQuery } from '@/api/role'
|
||||||
import AssignResourcesForm from './assign-resources-form.vue'
|
import AssignResourcesForm from './assign-resources-form.vue'
|
||||||
|
import AssignOrgsForm from './assign-orgs-form.vue'
|
||||||
|
|
||||||
const columns: TableColumn[] = [
|
const columns: TableColumn[] = [
|
||||||
{ prop: 'name', label: '角色名称', minWidth: 140 },
|
{ prop: 'name', label: '角色名称', minWidth: 140 },
|
||||||
@ -72,6 +75,7 @@ const actions: CrudAction[] = [
|
|||||||
'edit',
|
'edit',
|
||||||
'delete',
|
'delete',
|
||||||
{ label: '分配权限', type: 'warning', onClick: (row) => assignAction(row) },
|
{ label: '分配权限', type: 'warning', onClick: (row) => assignAction(row) },
|
||||||
|
{ label: '分配部门', type: 'primary', show: (row) => row.dataScope === 'custom', onClick: (row) => assignOrgsAction(row) },
|
||||||
]
|
]
|
||||||
|
|
||||||
function toListQuery(query: PageQuery): RoleListQuery {
|
function toListQuery(query: PageQuery): RoleListQuery {
|
||||||
@ -103,4 +107,16 @@ async function assignAction(row: Record<string, any>): Promise<void> {
|
|||||||
await assignResources(String(row.code), result.resourceCodes)
|
await assignResources(String(row.code), result.resourceCodes)
|
||||||
ElMessage.success('权限已保存')
|
ElMessage.success('权限已保存')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assignOrgsAction(row: Record<string, any>): Promise<void> {
|
||||||
|
const result = await showDialog({
|
||||||
|
component: AssignOrgsForm,
|
||||||
|
props: { roleCode: String(row.code) },
|
||||||
|
title: `分配数据权限部门(${row.name})`,
|
||||||
|
width: '520px',
|
||||||
|
})
|
||||||
|
if (result == null) return
|
||||||
|
await assignOrgs(String(row.code), result.orgCodes)
|
||||||
|
ElMessage.success('部门已保存')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -15,12 +15,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
defineOptions({ name: 'tenant' })
|
||||||
import type { JsonSchema, PageQuery, TableColumn } from '@/types/crud'
|
import type { JsonSchema, PageQuery, TableColumn } from '@/types/crud'
|
||||||
import { createTenant, deleteTenant, listTenants, updateTenant, type TenantListQuery } from '@/api/tenant'
|
import { createTenant, deleteTenant, listTenants, updateTenant, type TenantListQuery } from '@/api/tenant'
|
||||||
|
|
||||||
const columns: TableColumn[] = [
|
const columns: TableColumn[] = [
|
||||||
{ prop: 'name', label: '租户名称', minWidth: 140 },
|
{ prop: 'name', label: '租户名称', minWidth: 140 },
|
||||||
{ prop: 'code', label: '租户编码', minWidth: 140 },
|
{ prop: 'code', label: '租户编码', minWidth: 140 },
|
||||||
|
{ prop: 'brandName', label: '品牌名', minWidth: 120 },
|
||||||
{ prop: 'status', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'common_status' },
|
{ prop: 'status', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'common_status' },
|
||||||
{ prop: 'remark', label: '备注', minWidth: 160 },
|
{ prop: 'remark', label: '备注', minWidth: 160 },
|
||||||
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
|
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
|
||||||
@ -52,6 +55,10 @@ const formSchema: JsonSchema = {
|
|||||||
default: 'enabled',
|
default: 'enabled',
|
||||||
},
|
},
|
||||||
remark: { type: 'string', title: '备注', format: 'textarea', placeholder: '请输入备注' },
|
remark: { type: 'string', title: '备注', format: 'textarea', placeholder: '请输入备注' },
|
||||||
|
brandName: { type: 'string', title: '品牌名', placeholder: '如 投云科技' },
|
||||||
|
logoUrl: { type: 'string', title: 'Logo 地址', placeholder: '请输入 Logo URL' },
|
||||||
|
themeColor: { type: 'string', title: '主色', placeholder: '如 #2E7FA3' },
|
||||||
|
copyright: { type: 'string', title: '版权文案', placeholder: '如 云南投云科技有限公司' },
|
||||||
},
|
},
|
||||||
required: ['code', 'name'],
|
required: ['code', 'name'],
|
||||||
}
|
}
|
||||||
@ -69,8 +76,25 @@ function toListQuery(query: PageQuery): TenantListQuery {
|
|||||||
|
|
||||||
const list = (query: PageQuery, signal?: AbortSignal) => listTenants(toListQuery(query), signal)
|
const list = (query: PageQuery, signal?: AbortSignal) => listTenants(toListQuery(query), signal)
|
||||||
const create = (data: Record<string, any>) =>
|
const create = (data: Record<string, any>) =>
|
||||||
createTenant({ code: data.code, name: data.name, status: data.status, remark: data.remark })
|
createTenant({
|
||||||
|
code: data.code,
|
||||||
|
name: data.name,
|
||||||
|
status: data.status,
|
||||||
|
remark: data.remark,
|
||||||
|
brandName: data.brandName,
|
||||||
|
logoUrl: data.logoUrl,
|
||||||
|
themeColor: data.themeColor,
|
||||||
|
copyright: data.copyright,
|
||||||
|
})
|
||||||
const update = (id: string, data: Record<string, any>) =>
|
const update = (id: string, data: Record<string, any>) =>
|
||||||
updateTenant(id, { name: data.name, status: data.status, remark: data.remark })
|
updateTenant(id, {
|
||||||
|
name: data.name,
|
||||||
|
status: data.status,
|
||||||
|
remark: data.remark,
|
||||||
|
brandName: data.brandName,
|
||||||
|
logoUrl: data.logoUrl,
|
||||||
|
themeColor: data.themeColor,
|
||||||
|
copyright: data.copyright,
|
||||||
|
})
|
||||||
const remove = (id: string) => deleteTenant(id)
|
const remove = (id: string) => deleteTenant(id)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -20,7 +20,7 @@ defineOptions({ name: 'AssignRolesForm' })
|
|||||||
|
|
||||||
const props = defineProps<{ userId: string }>()
|
const props = defineProps<{ userId: string }>()
|
||||||
|
|
||||||
const { data: rolePage } = useRequest((signal: AbortSignal) => listRoles({ page: 1, pageSize: 1000 }, signal))
|
const { data: rolePage } = useRequest((signal: AbortSignal) => listRoles({ page: 1, pageSize: 200 }, signal))
|
||||||
const { data: assigned } = useRequest((signal: AbortSignal) => listUserRoles(props.userId, signal))
|
const { data: assigned } = useRequest((signal: AbortSignal) => listUserRoles(props.userId, signal))
|
||||||
|
|
||||||
const roles = computed(() => rolePage.value?.list ?? [])
|
const roles = computed(() => rolePage.value?.list ?? [])
|
||||||
|
|||||||
@ -32,6 +32,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
defineOptions({ name: 'user' })
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { CrudAction, JsonSchema, PageQuery, TableColumn } from '@/types/crud'
|
import type { CrudAction, JsonSchema, PageQuery, TableColumn } from '@/types/crud'
|
||||||
@ -39,10 +41,13 @@ import { showDialog } from '@/utils/dialog'
|
|||||||
import { useRequest } from '@/composables/useRequest'
|
import { useRequest } from '@/composables/useRequest'
|
||||||
import {
|
import {
|
||||||
assignUserRoles,
|
assignUserRoles,
|
||||||
|
batchDeleteUsers,
|
||||||
createUser,
|
createUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
listUsers,
|
listUsers,
|
||||||
|
lockUser,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
|
unlockUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
type UserListQuery,
|
type UserListQuery,
|
||||||
type UserStatus,
|
type UserStatus,
|
||||||
@ -62,7 +67,7 @@ const columns = computed<TableColumn[]>(() => {
|
|||||||
{ prop: 'nickname', label: '昵称', minWidth: 100 },
|
{ prop: 'nickname', label: '昵称', minWidth: 100 },
|
||||||
{ prop: 'email', label: '邮箱', minWidth: 160 },
|
{ prop: 'email', label: '邮箱', minWidth: 160 },
|
||||||
{ prop: 'phone', label: '手机', minWidth: 120 },
|
{ prop: 'phone', label: '手机', minWidth: 120 },
|
||||||
{ prop: 'status', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'user_status' },
|
{ prop: 'displayStatus', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'user_status' },
|
||||||
{ prop: 'orgCode', label: '所属组织', minWidth: 110, formatter: (v) => orgName(v) },
|
{ prop: 'orgCode', label: '所属组织', minWidth: 110, formatter: (v) => orgName(v) },
|
||||||
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
|
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
|
||||||
]
|
]
|
||||||
@ -77,7 +82,7 @@ const searchSchema: JsonSchema = {
|
|||||||
status: {
|
status: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dictCode: 'user_status',
|
dictCode: 'common_status',
|
||||||
placeholder: '全部',
|
placeholder: '全部',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -95,13 +100,19 @@ const formSchema = computed<JsonSchema>(() => ({
|
|||||||
minLength: 6,
|
minLength: 6,
|
||||||
showOn: ['create'],
|
showOn: ['create'],
|
||||||
},
|
},
|
||||||
|
mustChangePassword: {
|
||||||
|
type: 'boolean',
|
||||||
|
title: '首登强制改密',
|
||||||
|
default: true,
|
||||||
|
showOn: ['create'],
|
||||||
|
},
|
||||||
nickname: { type: 'string', title: '昵称', placeholder: '请输入昵称' },
|
nickname: { type: 'string', title: '昵称', placeholder: '请输入昵称' },
|
||||||
email: { type: 'string', title: '邮箱', format: 'email', placeholder: '请输入邮箱' },
|
email: { type: 'string', title: '邮箱', format: 'email', placeholder: '请输入邮箱' },
|
||||||
phone: { type: 'string', title: '手机', placeholder: '请输入手机号' },
|
phone: { type: 'string', title: '手机', placeholder: '请输入手机号' },
|
||||||
status: {
|
status: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dictCode: 'user_status',
|
dictCode: 'common_status',
|
||||||
default: 'enabled',
|
default: 'enabled',
|
||||||
},
|
},
|
||||||
orgCode: {
|
orgCode: {
|
||||||
@ -120,6 +131,8 @@ const actions: CrudAction[] = [
|
|||||||
'delete',
|
'delete',
|
||||||
{ label: '分配角色', type: 'primary', permission: 'user:assign-role', onClick: (row) => assignRolesAction(row) },
|
{ label: '分配角色', type: 'primary', permission: 'user:assign-role', onClick: (row) => assignRolesAction(row) },
|
||||||
{ label: '重置密码', type: 'warning', permission: 'user:reset-pwd', onClick: (row) => resetPwd(row) },
|
{ label: '重置密码', type: 'warning', permission: 'user:reset-pwd', onClick: (row) => resetPwd(row) },
|
||||||
|
{ label: '锁定', type: 'danger', permission: 'user:lock', show: (row) => !row.lockedAt, onClick: (row) => lockAction(row) },
|
||||||
|
{ label: '解锁', type: 'success', permission: 'user:unlock', show: (row) => !!row.lockedAt, onClick: (row) => unlockAction(row) },
|
||||||
]
|
]
|
||||||
|
|
||||||
function toListQuery(query: PageQuery): UserListQuery {
|
function toListQuery(query: PageQuery): UserListQuery {
|
||||||
@ -134,7 +147,13 @@ function toListQuery(query: PageQuery): UserListQuery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = (query: PageQuery, signal?: AbortSignal) => listUsers(toListQuery(query), signal)
|
const list = async (query: PageQuery, signal?: AbortSignal) => {
|
||||||
|
const result = await listUsers(toListQuery(query), signal)
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
list: result.list.map((u) => ({ ...u, displayStatus: u.lockedAt ? 'locked' : u.status })),
|
||||||
|
}
|
||||||
|
}
|
||||||
const create = (data: Record<string, any>) => createUser(data)
|
const create = (data: Record<string, any>) => createUser(data)
|
||||||
const update = (id: string, data: Record<string, any>) => updateUser(id, data)
|
const update = (id: string, data: Record<string, any>) => updateUser(id, data)
|
||||||
const remove = (id: string) => deleteUser(id)
|
const remove = (id: string) => deleteUser(id)
|
||||||
@ -165,12 +184,42 @@ async function assignRolesAction(row: Record<string, any>): Promise<void> {
|
|||||||
|
|
||||||
const crudRef = ref()
|
const crudRef = ref()
|
||||||
|
|
||||||
|
async function lockAction(row: Record<string, any>): Promise<void> {
|
||||||
|
let reason: string | undefined
|
||||||
|
try {
|
||||||
|
const res = await ElMessageBox.prompt(`锁定用户「${row.username}」`, '锁定用户', {
|
||||||
|
inputPlaceholder: '锁定原因(可选)',
|
||||||
|
confirmButtonText: '锁定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
reason = res.value || undefined
|
||||||
|
} catch {
|
||||||
|
return // 用户取消
|
||||||
|
}
|
||||||
|
await lockUser(String(row.id), reason)
|
||||||
|
ElMessage.success('已锁定')
|
||||||
|
crudRef.value?.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlockAction(row: Record<string, any>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认解锁用户「${row.username}」?`, '提示', { type: 'warning' })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await unlockUser(String(row.id))
|
||||||
|
ElMessage.success('已解锁')
|
||||||
|
crudRef.value?.reload()
|
||||||
|
}
|
||||||
|
|
||||||
async function batchDelete(rows: Record<string, any>[]): Promise<void> {
|
async function batchDelete(rows: Record<string, any>[]): Promise<void> {
|
||||||
await ElMessageBox.confirm(`确认删除选中的 ${rows.length} 个用户?`, '提示', { type: 'warning' })
|
await ElMessageBox.confirm(`确认删除选中的 ${rows.length} 个用户?`, '提示', { type: 'warning' })
|
||||||
for (const r of rows) {
|
const result = await batchDeleteUsers(rows.map((r) => String(r.id)))
|
||||||
await deleteUser(String(r.id))
|
if (result.failed.length > 0) {
|
||||||
}
|
ElMessage.warning(`删除完成:成功 ${result.success} 个,失败 ${result.failed.length} 个`)
|
||||||
|
} else {
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
|
}
|
||||||
crudRef.value?.reload()
|
crudRef.value?.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,7 +229,7 @@ function escapeCsv(v: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function exportUsers(): Promise<void> {
|
async function exportUsers(): Promise<void> {
|
||||||
const result = await listUsers({ page: 1, pageSize: 1000 })
|
const result = await listUsers({ page: 1, pageSize: 200 })
|
||||||
const headers = ['用户名', '昵称', '邮箱', '手机', '状态', '所属组织', '创建时间']
|
const headers = ['用户名', '昵称', '邮箱', '手机', '状态', '所属组织', '创建时间']
|
||||||
const lines = [headers.join(',')]
|
const lines = [headers.join(',')]
|
||||||
for (const u of result.list) {
|
for (const u of result.list) {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { setupLayouts } from 'virtual:generated-layouts'
|
|||||||
import i18n, { loadLanguage } from '@/i18n'
|
import i18n, { loadLanguage } from '@/i18n'
|
||||||
import { getAccessToken } from '@/utils/token'
|
import { getAccessToken } from '@/utils/token'
|
||||||
import { useMenuStore } from '@/stores/modules/menu'
|
import { useMenuStore } from '@/stores/modules/menu'
|
||||||
|
import { useAuthStore } from '@/stores/modules/auth'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@ -66,8 +67,14 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
next('/login')
|
next('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 路由级权限:自由路径放行,其余须在可访问菜单中
|
// 首登强制改密:待改密用户仅放行改密页,其余路由重定向
|
||||||
if (FREE_PATHS.has(to.path)) {
|
const auth = useAuthStore()
|
||||||
|
if (auth.mustChangePassword && to.path !== '/force-change-password') {
|
||||||
|
next('/force-change-password')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 路由级权限:自由路径或 meta.freeAccess 放行,其余须在可访问菜单中
|
||||||
|
if (FREE_PATHS.has(to.path) || to.meta.freeAccess === true) {
|
||||||
next()
|
next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,39 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { http } from '@/api/http';
|
import { http } from '@/api/http';
|
||||||
import { login as loginApi, type LoginResult } from '@/api/auth';
|
import { fetchBrand, login as loginApi, switchTenant as switchTenantApi, type LoginResult, type TenantBrand } from '@/api/auth';
|
||||||
import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/utils/token';
|
import { clearTokens, getAccessToken, getMustChangePassword, getRefreshToken, setMustChangePassword, setTokens } from '@/utils/token';
|
||||||
|
|
||||||
export const useAuthStore = defineStore('authStore', () => {
|
export const useAuthStore = defineStore('authStore', () => {
|
||||||
const accessToken = ref<string | null>(getAccessToken());
|
const accessToken = ref<string | null>(getAccessToken());
|
||||||
const refreshToken = ref<string | null>(getRefreshToken());
|
const refreshToken = ref<string | null>(getRefreshToken());
|
||||||
|
const mustChangePassword = ref<boolean>(getMustChangePassword());
|
||||||
|
const brand = ref<TenantBrand | null>(null);
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!accessToken.value);
|
const isAuthenticated = computed(() => !!accessToken.value);
|
||||||
|
|
||||||
|
async function fetchBrandState(): Promise<void> {
|
||||||
|
brand.value = await fetchBrand();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台超管切换租户:换 token 后由调用方刷新页面重建上下文。 */
|
||||||
|
async function switchTenant(tenantCode: string): Promise<void> {
|
||||||
|
const data = await switchTenantApi(tenantCode);
|
||||||
|
setTokens(data.accessToken, data.refreshToken);
|
||||||
|
accessToken.value = data.accessToken;
|
||||||
|
refreshToken.value = data.refreshToken;
|
||||||
|
mustChangePassword.value = false;
|
||||||
|
setMustChangePassword(false);
|
||||||
|
}
|
||||||
|
|
||||||
async function login(username: string, password: string, tenantCode?: string): Promise<LoginResult> {
|
async function login(username: string, password: string, tenantCode?: string): Promise<LoginResult> {
|
||||||
const data = await loginApi(username, password, tenantCode);
|
const data = await loginApi(username, password, tenantCode);
|
||||||
if ('accessToken' in data) {
|
if ('accessToken' in data) {
|
||||||
setTokens(data.accessToken, data.refreshToken);
|
setTokens(data.accessToken, data.refreshToken);
|
||||||
accessToken.value = data.accessToken;
|
accessToken.value = data.accessToken;
|
||||||
refreshToken.value = data.refreshToken;
|
refreshToken.value = data.refreshToken;
|
||||||
|
mustChangePassword.value = data.mustChangePassword;
|
||||||
|
setMustChangePassword(data.mustChangePassword);
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@ -29,9 +47,7 @@ export const useAuthStore = defineStore('authStore', () => {
|
|||||||
// 忽略登出接口错误
|
// 忽略登出接口错误
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
clearTokens();
|
reset();
|
||||||
accessToken.value = null;
|
|
||||||
refreshToken.value = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 本地清空会话状态(不调用登出接口,供 401 强退使用)。 */
|
/** 本地清空会话状态(不调用登出接口,供 401 强退使用)。 */
|
||||||
@ -39,7 +55,9 @@ export const useAuthStore = defineStore('authStore', () => {
|
|||||||
clearTokens();
|
clearTokens();
|
||||||
accessToken.value = null;
|
accessToken.value = null;
|
||||||
refreshToken.value = null;
|
refreshToken.value = null;
|
||||||
|
mustChangePassword.value = false;
|
||||||
|
setMustChangePassword(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { accessToken, refreshToken, isAuthenticated, login, logout, reset };
|
return { accessToken, refreshToken, isAuthenticated, mustChangePassword, brand, login, logout, reset, fetchBrandState, switchTenant };
|
||||||
});
|
});
|
||||||
|
|||||||
45
src/stores/modules/notice.ts
Normal file
45
src/stores/modules/notice.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { getUnreadCount, listMyNotices, markAllRead, markRead, type MyNotice } from '@/api/notice'
|
||||||
|
|
||||||
|
/** 站内通知:未读数 + 最近列表 + 轮询。 */
|
||||||
|
export const useNoticeStore = defineStore('noticeStore', () => {
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
const list = ref<MyNotice[]>([])
|
||||||
|
let timer: number | null = null
|
||||||
|
|
||||||
|
async function fetchUnread(): Promise<void> {
|
||||||
|
unreadCount.value = await getUnreadCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchList(): Promise<void> {
|
||||||
|
const r = await listMyNotices({ page: 1, pageSize: 10 })
|
||||||
|
list.value = r.list
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOne(id: string): Promise<void> {
|
||||||
|
await markRead(id)
|
||||||
|
await Promise.all([fetchUnread(), fetchList()])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAll(): Promise<void> {
|
||||||
|
await markAllRead()
|
||||||
|
await Promise.all([fetchUnread(), fetchList()])
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling(interval = 30000): void {
|
||||||
|
stopPolling()
|
||||||
|
timer = window.setInterval(() => {
|
||||||
|
void fetchUnread()
|
||||||
|
}, interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling(): void {
|
||||||
|
if (timer != null) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { unreadCount, list, fetchUnread, fetchList, readOne, readAll, startPolling, stopPolling }
|
||||||
|
})
|
||||||
@ -5,15 +5,22 @@ export interface TabItem {
|
|||||||
path: string
|
path: string
|
||||||
title: string
|
title: string
|
||||||
closable: boolean
|
closable: boolean
|
||||||
|
keepAlive: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HOME_PATH = '/'
|
export const HOME_PATH = '/'
|
||||||
|
|
||||||
|
/** 由路径推导 keep-alive 组件名:'/'→dashboard,其余取路径末段 kebab。 */
|
||||||
|
export function pathToName(path: string): string {
|
||||||
|
if (path === '/') return 'dashboard'
|
||||||
|
return path.split('/').filter(Boolean).pop() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
/** 多页签状态(持久化,刷新保留)。 */
|
/** 多页签状态(持久化,刷新保留)。 */
|
||||||
export const useTabsStore = defineStore(
|
export const useTabsStore = defineStore(
|
||||||
'tabsStore',
|
'tabsStore',
|
||||||
() => {
|
() => {
|
||||||
const tabs = ref<TabItem[]>([{ path: HOME_PATH, title: '首页', closable: false }])
|
const tabs = ref<TabItem[]>([{ path: HOME_PATH, title: '首页', closable: false, keepAlive: true }])
|
||||||
|
|
||||||
function addTab(tab: TabItem): void {
|
function addTab(tab: TabItem): void {
|
||||||
if (!tabs.value.some((t) => t.path === tab.path)) {
|
if (!tabs.value.some((t) => t.path === tab.path)) {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
const ACCESS_KEY = 'sysmgr_access_token';
|
const ACCESS_KEY = 'sysmgr_access_token';
|
||||||
const REFRESH_KEY = 'sysmgr_refresh_token';
|
const REFRESH_KEY = 'sysmgr_refresh_token';
|
||||||
|
const MUST_CHANGE_KEY = 'sysmgr_must_change_password';
|
||||||
|
|
||||||
export function getAccessToken(): string | null {
|
export function getAccessToken(): string | null {
|
||||||
return localStorage.getItem(ACCESS_KEY);
|
return localStorage.getItem(ACCESS_KEY);
|
||||||
@ -18,3 +19,12 @@ export function clearTokens(): void {
|
|||||||
localStorage.removeItem(ACCESS_KEY);
|
localStorage.removeItem(ACCESS_KEY);
|
||||||
localStorage.removeItem(REFRESH_KEY);
|
localStorage.removeItem(REFRESH_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMustChangePassword(): boolean {
|
||||||
|
return sessionStorage.getItem(MUST_CHANGE_KEY) === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMustChangePassword(v: boolean): void {
|
||||||
|
if (v) sessionStorage.setItem(MUST_CHANGE_KEY, '1');
|
||||||
|
else sessionStorage.removeItem(MUST_CHANGE_KEY);
|
||||||
|
}
|
||||||
|
|||||||
52
typed-router.d.ts
vendored
52
typed-router.d.ts
vendored
@ -51,6 +51,13 @@ declare module 'vue-router/auto-routes' {
|
|||||||
Record<never, never>,
|
Record<never, never>,
|
||||||
| never
|
| never
|
||||||
>,
|
>,
|
||||||
|
'/force-change-password': RouteRecordInfo<
|
||||||
|
'/force-change-password',
|
||||||
|
'/force-change-password',
|
||||||
|
Record<never, never>,
|
||||||
|
Record<never, never>,
|
||||||
|
| never
|
||||||
|
>,
|
||||||
'/forgot-password': RouteRecordInfo<
|
'/forgot-password': RouteRecordInfo<
|
||||||
'/forgot-password',
|
'/forgot-password',
|
||||||
'/forgot-password',
|
'/forgot-password',
|
||||||
@ -79,6 +86,13 @@ declare module 'vue-router/auto-routes' {
|
|||||||
Record<never, never>,
|
Record<never, never>,
|
||||||
| never
|
| never
|
||||||
>,
|
>,
|
||||||
|
'/monitor/session/': RouteRecordInfo<
|
||||||
|
'/monitor/session/',
|
||||||
|
'/monitor/session',
|
||||||
|
Record<never, never>,
|
||||||
|
Record<never, never>,
|
||||||
|
| never
|
||||||
|
>,
|
||||||
'/register': RouteRecordInfo<
|
'/register': RouteRecordInfo<
|
||||||
'/register',
|
'/register',
|
||||||
'/register',
|
'/register',
|
||||||
@ -100,6 +114,13 @@ declare module 'vue-router/auto-routes' {
|
|||||||
Record<never, never>,
|
Record<never, never>,
|
||||||
| never
|
| never
|
||||||
>,
|
>,
|
||||||
|
'/system/notice/': RouteRecordInfo<
|
||||||
|
'/system/notice/',
|
||||||
|
'/system/notice',
|
||||||
|
Record<never, never>,
|
||||||
|
Record<never, never>,
|
||||||
|
| never
|
||||||
|
>,
|
||||||
'/system/org/': RouteRecordInfo<
|
'/system/org/': RouteRecordInfo<
|
||||||
'/system/org/',
|
'/system/org/',
|
||||||
'/system/org',
|
'/system/org',
|
||||||
@ -121,6 +142,13 @@ declare module 'vue-router/auto-routes' {
|
|||||||
Record<never, never>,
|
Record<never, never>,
|
||||||
| never
|
| never
|
||||||
>,
|
>,
|
||||||
|
'/system/role/assign-orgs-form': RouteRecordInfo<
|
||||||
|
'/system/role/assign-orgs-form',
|
||||||
|
'/system/role/assign-orgs-form',
|
||||||
|
Record<never, never>,
|
||||||
|
Record<never, never>,
|
||||||
|
| never
|
||||||
|
>,
|
||||||
'/system/role/assign-resources-form': RouteRecordInfo<
|
'/system/role/assign-resources-form': RouteRecordInfo<
|
||||||
'/system/role/assign-resources-form',
|
'/system/role/assign-resources-form',
|
||||||
'/system/role/assign-resources-form',
|
'/system/role/assign-resources-form',
|
||||||
@ -193,6 +221,12 @@ declare module 'vue-router/auto-routes' {
|
|||||||
views:
|
views:
|
||||||
| never
|
| never
|
||||||
}
|
}
|
||||||
|
'src/pages/force-change-password.vue': {
|
||||||
|
routes:
|
||||||
|
| '/force-change-password'
|
||||||
|
views:
|
||||||
|
| never
|
||||||
|
}
|
||||||
'src/pages/forgot-password.vue': {
|
'src/pages/forgot-password.vue': {
|
||||||
routes:
|
routes:
|
||||||
| '/forgot-password'
|
| '/forgot-password'
|
||||||
@ -217,6 +251,12 @@ declare module 'vue-router/auto-routes' {
|
|||||||
views:
|
views:
|
||||||
| never
|
| never
|
||||||
}
|
}
|
||||||
|
'src/pages/monitor/session/index.vue': {
|
||||||
|
routes:
|
||||||
|
| '/monitor/session/'
|
||||||
|
views:
|
||||||
|
| never
|
||||||
|
}
|
||||||
'src/pages/register.vue': {
|
'src/pages/register.vue': {
|
||||||
routes:
|
routes:
|
||||||
| '/register'
|
| '/register'
|
||||||
@ -235,6 +275,12 @@ declare module 'vue-router/auto-routes' {
|
|||||||
views:
|
views:
|
||||||
| never
|
| never
|
||||||
}
|
}
|
||||||
|
'src/pages/system/notice/index.vue': {
|
||||||
|
routes:
|
||||||
|
| '/system/notice/'
|
||||||
|
views:
|
||||||
|
| never
|
||||||
|
}
|
||||||
'src/pages/system/org/index.vue': {
|
'src/pages/system/org/index.vue': {
|
||||||
routes:
|
routes:
|
||||||
| '/system/org/'
|
| '/system/org/'
|
||||||
@ -253,6 +299,12 @@ declare module 'vue-router/auto-routes' {
|
|||||||
views:
|
views:
|
||||||
| never
|
| never
|
||||||
}
|
}
|
||||||
|
'src/pages/system/role/assign-orgs-form.vue': {
|
||||||
|
routes:
|
||||||
|
| '/system/role/assign-orgs-form'
|
||||||
|
views:
|
||||||
|
| never
|
||||||
|
}
|
||||||
'src/pages/system/role/assign-resources-form.vue': {
|
'src/pages/system/role/assign-resources-form.vue': {
|
||||||
routes:
|
routes:
|
||||||
| '/system/role/assign-resources-form'
|
| '/system/role/assign-resources-form'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user