From b86c22e7d26bb6b0c76419b041739dd3f8484456 Mon Sep 17 00:00:00 2001 From: ZhuRui Date: Sat, 25 Jul 2026 18:00:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=A8=E8=B5=9B=E4=BA=8B?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=B8=8B=E6=89=80=E6=9C=89=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=20=E8=A1=A5=E5=85=A8=E7=B3=BB=E7=BB=9F=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E4=B8=8B=E6=89=80=E6=9C=89=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/img/icon/logout.svg | 1 + .../ChangePasswordModal/controller.ts | 48 +++ src/components/ChangePasswordModal/index.tsx | 103 ++++++ .../ChangePasswordModal/style.module.less | 37 ++ src/components/index.ts | 1 + src/layouts/BasicLayout.module.less | 65 ++++ src/layouts/BasicLayout.tsx | 108 +++++- .../banner/components/BannerFormModal.tsx | 1 - src/pages/login/index.tsx | 9 +- .../components/RoleFormModal.module.less | 33 ++ .../system/roles/components/RoleFormModal.tsx | 144 ++++++++ .../components/UserListModal.module.less | 23 ++ .../system/roles/components/UserListModal.tsx | 139 ++++++++ src/pages/system/roles/controller.ts | 65 ++++ src/pages/system/roles/index.module.less | 47 +++ src/pages/system/roles/index.tsx | 191 ++++++++++- src/pages/system/roles/model/useRoleModel.ts | 323 ++++++++++++++++++ .../components/UserFormModal.module.less | 61 ++++ .../system/users/components/UserFormModal.tsx | 217 ++++++++++++ src/pages/system/users/controller.ts | 96 ++++++ src/pages/system/users/index.module.less | 43 +++ src/pages/system/users/index.tsx | 199 ++++++++++- src/pages/system/users/model/useUserModel.ts | 248 ++++++++++++++ src/stores/userStore.ts | 39 +++ src/utils/form.ts | 47 +++ 25 files changed, 2282 insertions(+), 6 deletions(-) create mode 100644 src/assets/img/icon/logout.svg create mode 100644 src/components/ChangePasswordModal/controller.ts create mode 100644 src/components/ChangePasswordModal/index.tsx create mode 100644 src/components/ChangePasswordModal/style.module.less create mode 100644 src/pages/system/roles/components/RoleFormModal.module.less create mode 100644 src/pages/system/roles/components/RoleFormModal.tsx create mode 100644 src/pages/system/roles/components/UserListModal.module.less create mode 100644 src/pages/system/roles/components/UserListModal.tsx create mode 100644 src/pages/system/roles/controller.ts create mode 100644 src/pages/system/roles/index.module.less create mode 100644 src/pages/system/roles/model/useRoleModel.ts create mode 100644 src/pages/system/users/components/UserFormModal.module.less create mode 100644 src/pages/system/users/components/UserFormModal.tsx create mode 100644 src/pages/system/users/controller.ts create mode 100644 src/pages/system/users/index.module.less create mode 100644 src/pages/system/users/model/useUserModel.ts create mode 100644 src/stores/userStore.ts create mode 100644 src/utils/form.ts diff --git a/src/assets/img/icon/logout.svg b/src/assets/img/icon/logout.svg new file mode 100644 index 0000000..43a1425 --- /dev/null +++ b/src/assets/img/icon/logout.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ChangePasswordModal/controller.ts b/src/components/ChangePasswordModal/controller.ts new file mode 100644 index 0000000..22ccb67 --- /dev/null +++ b/src/components/ChangePasswordModal/controller.ts @@ -0,0 +1,48 @@ +/** + * 修改密码弹窗 - 表单域控制器 + * + * 集中管理弹窗内的所有规则与提示文案。 + */ +import type { Rule } from 'ant-design-vue/es/form'; +import { isValidPassword } from '@/utils/form'; + +// ============================================================ +// 文案 +// ============================================================ + +/** 弹窗底部提示 */ +export const TIPS_TEXT = '修改后立即生效,请妥善保管新密码'; + +// ============================================================ +// 表单规则 +// ============================================================ + +/** 新密码规则:必填 + 至少 8 位 + 字母 + 数字 */ +export const newPasswordRules: Rule[] = [ + { required: true, message: '请输入新密码' }, + { + validator: (_rule: unknown, value: string) => { + if (!value) return Promise.resolve(); + if (!isValidPassword(value)) { + return Promise.reject(new Error('密码至少8位,需包含字母与数字')); + } + return Promise.resolve(); + }, + }, +]; + +/** 确认密码规则:必填 + 必须与新密码一致 */ +export function confirmNewPasswordRules(getPassword: () => string): Rule[] { + return [ + { required: true, message: '请再次输入新密码' }, + { + validator: (_rule: unknown, value: string) => { + if (!value) return Promise.resolve(); + if (value !== getPassword()) { + return Promise.reject(new Error('两次输入的密码不一致')); + } + return Promise.resolve(); + }, + }, + ]; +} diff --git a/src/components/ChangePasswordModal/index.tsx b/src/components/ChangePasswordModal/index.tsx new file mode 100644 index 0000000..e103b6c --- /dev/null +++ b/src/components/ChangePasswordModal/index.tsx @@ -0,0 +1,103 @@ +import { defineComponent, ref, reactive, watch } from 'vue'; +import { Modal, Form, Input, Button, message } from 'ant-design-vue'; +import { TIPS_TEXT, newPasswordRules, confirmNewPasswordRules } from './controller'; +import styles from './style.module.less'; + +interface ChangePasswordModalProps { + visible: boolean; + submitting?: boolean; + onClose: () => void; + /** 提交回调:父组件负责调用 API,loading 状态由 submitting 控制 */ + onSave: (payload: { newPassword: string }) => void; +} + +/** 默认表单数据 */ +const getDefaultForm = () => ({ + newPassword: '', + confirmPassword: '', +}); + +export default defineComponent({ + name: 'ChangePasswordModal', + props: { + visible: { type: Boolean, default: false }, + submitting: { type: Boolean, default: false }, + onClose: { type: Function, required: true }, + onSave: { type: Function, required: true }, + }, + setup(props: ChangePasswordModalProps) { + const formRef = ref(); + const formData = reactive(getDefaultForm()); + + /** 监听 visible 重置表单 */ + watch( + () => props.visible, + (val) => { + if (val) { + Object.assign(formData, getDefaultForm()); + setTimeout(() => formRef.value?.clearValidate(), 0); + } + }, + { immediate: true }, + ); + + /** 提交 */ + const handleSubmit = async () => { + try { + await formRef.value?.validate(); + } catch { + return; + } + props.onSave({ newPassword: formData.newPassword }); + }; + + const handleClose = () => { + props.onClose(); + }; + + return () => ( + +
+ + + + + formData.newPassword)} + > + + + +
{TIPS_TEXT}
+
+ + {/* 底部按钮 */} +
+ + +
+
+ ); + }, +}); diff --git a/src/components/ChangePasswordModal/style.module.less b/src/components/ChangePasswordModal/style.module.less new file mode 100644 index 0000000..4d622b2 --- /dev/null +++ b/src/components/ChangePasswordModal/style.module.less @@ -0,0 +1,37 @@ +.form { + :global { + .ant-form-item { + margin-bottom: 16px; + } + .ant-form-item-explain-error { + font-size: 12px; + } + } +} + +/* 底部提示文案 */ +.tip { + margin-top: -4px; + margin-bottom: 24px; + color: rgba(0, 0, 0, 0.45); + font-size: 12px; + line-height: 1.5; +} + +/* 底部按钮 */ +.footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 16px; + border-top: 1px solid #f0f0f0; +} + +/* 微调 Modal 内部样式 */ +.modal { + :global { + .ant-modal-body { + padding: 16px 24px 0; + } + } +} diff --git a/src/components/index.ts b/src/components/index.ts index 0c7e7d8..30b0699 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,3 +5,4 @@ export { default as HelloWorld } from './HelloWorld'; export { default as ImageCropper } from './ImageCropper'; export type { ImageCropperRef, ImageCropperProps } from './ImageCropper'; +export { default as ChangePasswordModal } from './ChangePasswordModal'; diff --git a/src/layouts/BasicLayout.module.less b/src/layouts/BasicLayout.module.less index 412b4a4..3b182a3 100644 --- a/src/layouts/BasicLayout.module.less +++ b/src/layouts/BasicLayout.module.less @@ -32,6 +32,71 @@ } } +// 面包屑 +.breadcrumb { + flex: 1; + margin-left: 8px; + font-size: 14px; + + :global { + .ant-breadcrumb-link, + .ant-breadcrumb-separator, + .ant-breadcrumb-item a { + color: rgba(0, 0, 0, 0.65); + } + + .ant-breadcrumb-item:last-child { + .ant-breadcrumb-link, + .ant-breadcrumb-separator, + a { + color: rgba(0, 0, 0, 0.88); + font-weight: 500; + cursor: default; + } + } + } +} + +// Header 右侧:账号信息 + 退出图标 +.headerRight { + margin-left: auto; + display: flex; + gap: 10px; + align-items: center; +} + +.userPhone { + font-size: 14px; + color: rgba(0, 0, 0, 0.65); +} + +.logoutIcon { + width: 18px; + height: 18px; + opacity: 0.45; + cursor: pointer; + transition: opacity 0.3s; + + &:hover { + opacity: 1; + } +} + +// 下拉菜单项:图标 + 文字 +.menuItem { + display: inline-flex; + align-items: center; + gap: 8px; + + :global(.anticon) { + font-size: 14px; + } +} + +.menuItemDanger { + color: #ff4d4f; +} + .content { margin: 16px; border-radius: 8px; diff --git a/src/layouts/BasicLayout.tsx b/src/layouts/BasicLayout.tsx index d140312..1d1804d 100644 --- a/src/layouts/BasicLayout.tsx +++ b/src/layouts/BasicLayout.tsx @@ -1,11 +1,13 @@ import { computed, defineComponent, h, ref } from 'vue'; import type { CSSProperties } from 'vue'; import { useRouter, useRoute, RouterView } from 'vue-router'; -import { Layout, Menu } from 'ant-design-vue'; +import { Layout, Menu, Modal, Breadcrumb, Dropdown, message } from 'ant-design-vue'; import type { MenuProps } from 'ant-design-vue'; import { DashboardOutlined, + EditOutlined, InfoCircleOutlined, + LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, SettingOutlined, @@ -13,6 +15,10 @@ import { TrophyOutlined, } from '@ant-design/icons-vue'; import { useMenuStore } from '@/stores/menuStore'; +import { useUserStore } from '@/stores/userStore'; +import { useState, useBreadcrumb, auth } from '@/hooks'; +import { ChangePasswordModal } from '@/components'; +import logoutSvg from '@/assets/img/icon/logout.svg'; import styles from './BasicLayout.module.less'; const { Header, Sider, Content, Footer } = Layout; @@ -86,6 +92,8 @@ export default defineComponent({ const collapsed = ref(false); const { menuItems } = useMenuStore(); + const { phone } = useUserStore(); + const { breadcrumbs } = useBreadcrumb(); const selectedKeys = computed(() => [route.path]); @@ -99,6 +107,53 @@ export default defineComponent({ collapsed.value = !collapsed.value; }; + /** ===== 退出登录 ===== */ + const handleLogout = () => { + Modal.confirm({ + title: '提示', + content: '是否退出登录?', + okText: '确认', + cancelText: '取消', + onOk: () => { + const { clearUser } = useUserStore(); + clearUser(); + auth.logout(); + }, + }); + }; + + /** ===== 修改密码弹窗 ===== */ + const [changePwdVisible, setChangePwdVisible] = useState(false); + const [changePwdSubmitting, setChangePwdSubmitting] = useState(false); + + const handleChangePassword = () => { + setChangePwdVisible(true); + }; + + const handleChangePwdSave = async (payload: { newPassword: string }) => { + if (changePwdSubmitting.value) return; + setChangePwdSubmitting(true); + try { + // TODO: 替换为真实 API 调用 + console.log('修改密码:', payload); + message.success('修改成功'); + setChangePwdVisible(false); + } catch (err: any) { + message.error(err?.msg || '修改失败'); + } finally { + setChangePwdSubmitting(false); + } + }; + + /** ===== 头像下拉菜单:修改密码 / 退出登录 ===== */ + const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => { + if (key === 'changePassword') { + handleChangePassword(); + } else if (key === 'logout') { + handleLogout(); + } + }; + return () => ( @@ -119,6 +174,49 @@ export default defineComponent({ {collapsed.value ? : } + + {/* 面包屑:紧跟在折叠按钮之后 */} + + {breadcrumbs.value.map((item, index) => { + const isLast = index === breadcrumbs.value.length - 1; + return ( + + {isLast || !item.path ? ( + {item.title} + ) : ( + router.push(item.path as string)}>{item.title} + )} + + ); + })} + + + {/* 右侧: 账号信息 + 退出 */} +
+ {phone.value} + + {{ + default: () => , + overlay: () => ( + + + + + 修改密码 + + + + + + + 退出登录 + + + + ), + }} + +
@@ -127,6 +225,14 @@ export default defineComponent({
六个羽友赛事运营平台 ©{new Date().getFullYear()}
+ + {/* 修改密码弹窗 */} + setChangePwdVisible(false)} + onSave={handleChangePwdSave} + /> ); }, diff --git a/src/pages/events/banner/components/BannerFormModal.tsx b/src/pages/events/banner/components/BannerFormModal.tsx index bde7652..eb8cf2f 100644 --- a/src/pages/events/banner/components/BannerFormModal.tsx +++ b/src/pages/events/banner/components/BannerFormModal.tsx @@ -498,7 +498,6 @@ export default defineComponent({ { required: true, message: '请输入排序' }, { type: 'number', min: 1, max: 999, message: '排序范围 1-999' }, ]} - extra="数字越小越靠前" > setTimeout(r, 600)); - await auth.login('mock_token_' + Date.now()); + const token = 'mock_token_' + Date.now(); + + // 写入账号信息到 userStore + const { setUser } = useUserStore(); + setUser({ phone: form.username, nickname: form.username, token }); + + await auth.login(token); message.success('登录成功'); if (rememberMe.value) { diff --git a/src/pages/system/roles/components/RoleFormModal.module.less b/src/pages/system/roles/components/RoleFormModal.module.less new file mode 100644 index 0000000..2958fed --- /dev/null +++ b/src/pages/system/roles/components/RoleFormModal.module.less @@ -0,0 +1,33 @@ +.form { + :global { + .ant-form-item { + margin-bottom: 16px; + } + } +} + +.treeWrapper { + max-height: 380px; + overflow: auto; + padding: 8px 12px; + border: 1px solid #f0f0f0; + border-radius: 4px; + background: #fafafa; +} + +.footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 16px; + border-top: 1px solid #f0f0f0; + margin-top: 8px; +} + +.modal { + :global { + .ant-modal-body { + padding: 16px 24px 0; + } + } +} diff --git a/src/pages/system/roles/components/RoleFormModal.tsx b/src/pages/system/roles/components/RoleFormModal.tsx new file mode 100644 index 0000000..3df6fc5 --- /dev/null +++ b/src/pages/system/roles/components/RoleFormModal.tsx @@ -0,0 +1,144 @@ +import { defineComponent, ref, reactive, watch } from 'vue'; +import { Modal, Form, Input, Tree, Button } from 'ant-design-vue'; +import type { TreeProps } from 'ant-design-vue'; +import { roleNameRules, PERMISSION_TREE } from '../controller'; +import styles from './RoleFormModal.module.less'; + +interface RoleFormModalProps { + visible: boolean; + record: any; + submitting?: boolean; + onClose: () => void; + onSave: (formData: any) => void; +} + +/** 默认表单数据 */ +const getDefaultForm = () => ({ + roleName: '', + checkedKeys: [] as string[], +}); + +/** 编辑模式默认勾选:所有权限(演示用) */ +const ALL_KEYS = (() => { + const keys: string[] = []; + const walk = (nodes: any[]) => { + for (const n of nodes) { + keys.push(n.key); + if (n.children?.length) walk(n.children); + } + }; + walk(PERMISSION_TREE); + return keys; +})(); + +export default defineComponent({ + name: 'RoleFormModal', + props: { + visible: { type: Boolean, default: false }, + record: { type: Object, default: null }, + submitting: { type: Boolean, default: false }, + onClose: { type: Function, required: true }, + onSave: { type: Function, required: true }, + }, + setup(props: RoleFormModalProps) { + const formRef = ref(); + const formData = reactive(getDefaultForm()); + + /** 根据 record 初始化表单 */ + const initFormFromRecord = (record: any) => { + const fresh = getDefaultForm(); + if (!record) { + Object.assign(formData, fresh); + return; + } + Object.assign(formData, { + ...fresh, + roleName: record.roleName || '', + // 编辑时默认全选(演示用,后续应按 record.permissions 回填) + checkedKeys: [...ALL_KEYS], + }); + }; + + /** 监听 visible 重置 */ + watch( + () => props.visible, + (val) => { + if (val) { + initFormFromRecord(props.record); + setTimeout(() => formRef.value?.clearValidate(), 0); + } + }, + { immediate: true }, + ); + + /** Tree 勾选回调(受控) */ + const handleCheck: TreeProps['onCheck'] = (checked) => { + // checked 可能是 { checked: string[], halfChecked: string[] } 或 string[] + const keys = Array.isArray(checked) ? checked : (checked as any).checked; + formData.checkedKeys = keys as string[]; + }; + + /** 提交 */ + const handleSubmit = async () => { + try { + await formRef.value?.validate(); + } catch { + return; + } + props.onSave({ + roleName: formData.roleName, + permissions: formData.checkedKeys, + }); + }; + + return () => { + const isEdit = !!props.record; + + return ( + +
+ {/* 角色名称 */} + + + + + {/* 菜单权限配置 */} + +
+ +
+
+
+ + {/* 底部按钮 */} +
+ + +
+
+ ); + }; + }, +}); diff --git a/src/pages/system/roles/components/UserListModal.module.less b/src/pages/system/roles/components/UserListModal.module.less new file mode 100644 index 0000000..47bf537 --- /dev/null +++ b/src/pages/system/roles/components/UserListModal.module.less @@ -0,0 +1,23 @@ +.filter { + margin-bottom: 16px; +} + +.bottom { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid #f0f0f0; +} + +.total { + color: rgba(0, 0, 0, 0.65); + font-size: 13px; +} + +.paginationRow { + display: flex; + justify-content: flex-end; + margin-top: 12px; +} diff --git a/src/pages/system/roles/components/UserListModal.tsx b/src/pages/system/roles/components/UserListModal.tsx new file mode 100644 index 0000000..08a6796 --- /dev/null +++ b/src/pages/system/roles/components/UserListModal.tsx @@ -0,0 +1,139 @@ +import { defineComponent, ref, reactive, computed, watch } from 'vue'; +import { Modal, Form, Input, Table, Button, Space, Pagination } from 'ant-design-vue'; +import { useState } from '@/hooks'; +import styles from './UserListModal.module.less'; + +interface UserListModalProps { + visible: boolean; + role: any; + users: any[]; + renderStatus: (status: string) => any; + onClose: () => void; +} + +export default defineComponent({ + name: 'RoleUserListModal', + props: { + visible: { type: Boolean, default: false }, + role: { type: Object, default: null }, + users: { type: Array, default: () => [] }, + renderStatus: { type: Function, required: true }, + onClose: { type: Function, required: true }, + }, + setup(props: UserListModalProps) { + const filterForm = reactive({ searchKeyword: '' }); + + const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 }); + + /** 过滤后的用户列表 */ + const filteredUsers = computed(() => { + const kw = filterForm.searchKeyword.trim(); + if (!kw) return props.users; + return props.users.filter( + (u: any) => u.realName.includes(kw) || (u.phone && u.phone.includes(kw)), + ); + }); + + /** 监听 visible/users 变化重置分页 */ + watch( + () => [props.visible, props.users.length], + () => { + setPagination({ current: 1, pageSize: 10, total: filteredUsers.value.length }); + }, + ); + + const handleSearch = () => { + setPagination({ ...pagination.value, total: filteredUsers.value.length, current: 1 }); + }; + + const handleReset = () => { + filterForm.searchKeyword = ''; + setPagination({ current: 1, pageSize: 10, total: props.users.length }); + }; + + const handlePageChange = (page: number, pageSize: number) => { + setPagination({ ...pagination.value, current: page, pageSize }); + }; + + return () => { + const roleName = props.role?.roleName || ''; + + const columns = [ + { title: '姓名', dataIndex: 'realName', key: 'realName', width: 120 }, + { title: '手机号', dataIndex: 'phone', key: 'phone', width: 160 }, + { title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 200 }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + align: 'center' as const, + customRender: ({ text }: { text: string }) => props.renderStatus(text), + }, + ]; + + return ( + + {/* 筛选 */} +
+
+ + + + + + + + + +
+
+ + {/* 表格 */} + + + {/* 底部:共 X 人 + 关闭按钮 */} +
+
共 {filteredUsers.value.length} 人
+ +
+ + {/* 分页(右侧) */} + {filteredUsers.value.length > pagination.value.pageSize && ( +
+ +
+ )} + + ); + }; + }, +}); diff --git a/src/pages/system/roles/controller.ts b/src/pages/system/roles/controller.ts new file mode 100644 index 0000000..fe1dba0 --- /dev/null +++ b/src/pages/system/roles/controller.ts @@ -0,0 +1,65 @@ +/** + * 角色权限 - 表单域控制器 + * + * 集中管理角色表单的规则、菜单权限树 mock 数据与提示文案。 + */ +import type { Rule } from 'ant-design-vue/es/form'; + +// ============================================================ +// 规则 +// ============================================================ + +/** 角色名称规则:必填 + 长度限制 */ +export const roleNameRules: Rule[] = [ + { required: true, message: '请输入角色名称' }, + { max: 20, message: '角色名称最多 20 个字符' }, +]; + +// ============================================================ +// 菜单权限树(mock) +// ============================================================ + +export interface MenuPermNode { + /** 唯一 key,作为 Tree 节点的 key */ + key: string; + /** 显示标题 */ + title: string; + /** 子节点 */ + children?: MenuPermNode[]; +} + +/** + * 完整菜单权限树。 + * 后续可改为从后端接口拉取(接口应返回该结构)。 + */ +export const PERMISSION_TREE: MenuPermNode[] = [ + { + key: 'events', + title: '赛事管理', + children: [ + { + key: 'events.list', + title: '赛事列表', + children: [ + { key: 'events.list.view', title: '赛事列表-查看' }, + { key: 'events.list.on', title: '赛事列表-上架' }, + { key: 'events.list.off', title: '赛事列表-下架' }, + { key: 'events.rules.view', title: '赛事规程-查看' }, + ], + }, + { + key: 'orders', + title: '订单管理', + children: [ + { key: 'orders.view', title: '订单管理-查看' }, + { key: 'orders.refund', title: '订单管理-重新退款' }, + ], + }, + { key: 'users', title: '用户列表' }, + ], + }, + { + key: 'events_logs', + title: '赛事操作日志', + }, +]; diff --git a/src/pages/system/roles/index.module.less b/src/pages/system/roles/index.module.less new file mode 100644 index 0000000..e693719 --- /dev/null +++ b/src/pages/system/roles/index.module.less @@ -0,0 +1,47 @@ +/** + * 表格 body 容器:占满 flex 剩余空间 + */ +.tableBody { + flex: 1; + min-height: 0; + overflow: hidden; + + :global { + .ant-spin-nested-loading, + .ant-spin-container, + .ant-table, + .ant-table-container { + height: 100%; + } + } +} + +/* 用户数链接 */ +.userCountLink { + font-variant-numeric: tabular-nums; +} + +/* ===== 删除确认弹窗 ===== */ +.confirmContent { + padding: 4px 24px; + font-size: 14px; + color: rgba(0, 0, 0, 0.85); + + strong { + margin: 0 4px; + font-weight: 600; + } +} + +.confirmTip { + margin-top: 8px; + font-size: 12px; + color: rgba(0, 0, 0, 0.45); + line-height: 1.5; +} + +:global(.deleteConfirmModal) { + .ant-modal-confirm-body { + padding: 24px !important; + } +} diff --git a/src/pages/system/roles/index.tsx b/src/pages/system/roles/index.tsx index 0b27c45..436763b 100644 --- a/src/pages/system/roles/index.tsx +++ b/src/pages/system/roles/index.tsx @@ -1,4 +1,76 @@ import { defineComponent } from 'vue'; +import { Button, Input, Table, Form, Space, Select, Modal, Pagination } from 'ant-design-vue'; +import type { ModalProps } from 'ant-design-vue'; +import { useRoleModel } from './model/useRoleModel'; +import { useContainerSize } from '@/hooks'; +import RoleFormModal from './components/RoleFormModal'; +import UserListModal from './components/UserListModal'; +import styles from './index.module.less'; + +/** + * bodyCell 渲染 + */ +function renderBodyCell({ + column, + record, + onViewUsers, + onEdit, + onDelete, +}: { + column: any; + text: any; + record: any; + onViewUsers: (record: any) => void; + onEdit: (record: any) => void; + onDelete: (record: any) => void; +}) { + if (column.dataIndex === 'userCount') { + return ( + onViewUsers(record)}> + {record.userCount}人 + + ); + } + + if (column.key === 'action') { + return ( + + + + + ); + } + return; +} + +/** + * 删除角色二次确认 + */ +function confirmDelete({ record, onOk }: { record: any; onOk: (record: any) => void }) { + Modal.confirm({ + title: '确认删除', + icon: null, + class: styles.deleteConfirmModal, + content: ( +
+
+ 确定要删除角色 + {record.roleName} + 吗? +
+
删除后该角色下的用户将失去对应的权限,请谨慎操作。
+
+ ), + okText: '确认', + cancelText: '取消', + okButtonProps: { danger: true } as ModalProps['okButtonProps'], + onOk: () => onOk(record), + }); +} /** * 角色权限 @@ -6,9 +78,126 @@ import { defineComponent } from 'vue'; export default defineComponent({ name: 'SystemRoles', setup() { + const { + filterForm, + loading, + dataSource, + columns, + pagination, + formVisible, + editingRecord, + formSubmitting, + userListVisible, + currentRole, + handleSearch, + handleReset, + handlePageChange, + handleAdd, + handleEdit, + handleCloseForm, + handleSave, + handleDelete, + handleViewUsers, + handleCloseUserList, + getRoleUsers, + renderStatus, + } = useRoleModel(); + + const { containerRef, height } = useContainerSize({ headerOffset: 55 }); + + const triggerDelete = (record: any) => confirmDelete({ record, onOk: handleDelete }); + + const tableColumns = [ + ...columns, + { + title: '操作', + key: 'action', + width: 140, + fixed: 'right' as const, + align: 'center' as const, + }, + ]; + return () => (
-
角色权限 - 开发中
+ {/* ===== 筛选区 ===== */} +
+
+ + + + + + + + + + + +
+ + {/* ===== 表格区 ===== */} +
+
+
+ {{ + bodyCell: (args: any) => + renderBodyCell({ + ...args, + onViewUsers: handleViewUsers, + onEdit: handleEdit, + onDelete: triggerDelete, + }), + }} +
+ + + {/* 分页 */} +
+ `共 ${total} 条`} + onChange={handlePageChange} + onShowSizeChange={handlePageChange} + /> +
+ + + {/* ===== 角色表单弹窗 ===== */} + + + {/* ===== 角色用户列表弹窗 ===== */} + ); }, diff --git a/src/pages/system/roles/model/useRoleModel.ts b/src/pages/system/roles/model/useRoleModel.ts new file mode 100644 index 0000000..d5ff605 --- /dev/null +++ b/src/pages/system/roles/model/useRoleModel.ts @@ -0,0 +1,323 @@ +import { computed, reactive, toRef, Ref, h } from 'vue'; +import { message, Tag } from 'ant-design-vue'; +import { useState, useDebounce, useThrottleFn } from '@/hooks'; + +// ============================================================ +// 假数据 +// ============================================================ + +const MOCK_DATA = [ + { + key: '1', + roleId: 'R20250601001', + roleName: '超级管理员', + userCount: 1, + createTime: '2025-06-01 09:00:00', + }, + { + key: '2', + roleId: 'R20250815001', + roleName: '运营管理员', + userCount: 3, + createTime: '2025-08-15 14:30:00', + }, + { + key: '3', + roleId: 'R20251010001', + roleName: '财务', + userCount: 2, + createTime: '2025-10-10 10:15:00', + }, + { + key: '4', + roleId: 'R20260120001', + roleName: '客服', + userCount: 4, + createTime: '2026-01-20 16:00:00', + }, +]; + +/** 角色下用户列表 mock */ +const MOCK_USERS: Record = { + R20250601001: [ + { + key: '1', + userId: 'U001', + realName: '张超管', + phone: '13900000001', + createTime: '2025-06-01 09:00:00', + status: 'active', + }, + ], + R20250815001: [ + { + key: '1', + userId: 'U002', + realName: '李运营', + phone: '15900000002', + createTime: '2026-01-10 16:00:00', + status: 'disabled', + }, + { + key: '2', + userId: 'U003', + realName: '王运营', + phone: '15900000001', + createTime: '2025-10-20 10:00:00', + status: 'active', + }, + { + key: '3', + userId: 'U004', + realName: '张运营', + phone: '13900000002', + createTime: '2025-08-15 14:30:00', + status: 'active', + }, + ], + R20251010001: [ + { + key: '1', + userId: 'U005', + realName: '赵财务', + phone: '13800000005', + createTime: '2025-10-10 10:15:00', + status: 'active', + }, + { + key: '2', + userId: 'U006', + realName: '钱财务', + phone: '13800000006', + createTime: '2025-10-12 11:00:00', + status: 'active', + }, + ], + R20260120001: [ + { + key: '1', + userId: 'U007', + realName: '孙客服', + phone: '13700000007', + createTime: '2026-01-20 16:00:00', + status: 'active', + }, + { + key: '2', + userId: 'U008', + realName: '周客服', + phone: '13700000008', + createTime: '2026-01-21 09:30:00', + status: 'active', + }, + { + key: '3', + userId: 'U009', + realName: '吴客服', + phone: '13700000009', + createTime: '2026-01-22 10:00:00', + status: 'active', + }, + { + key: '4', + userId: 'U010', + realName: '郑客服', + phone: '13700000010', + createTime: '2026-01-23 14:00:00', + status: 'active', + }, + ], +}; + +// ============================================================ +// 状态文本/颜色映射 +// ============================================================ +const STATUS_MAP: Record = { + active: { label: '正常', color: 'green' }, + disabled: { label: '禁用', color: 'red' }, +}; + +// ============================================================ +// Model +// ============================================================ + +/** + * 角色权限页数据模型 + */ +export function useRoleModel() { + // ===== 筛选条件 ===== + const filterForm = reactive({ + searchKeyword: '', + }); + + // 防抖 300ms + const { debouncedValue: debouncedKeyword } = useDebounce( + toRef(filterForm, 'searchKeyword') as Ref, + { delay: 300 }, + ); + + // ===== 表格状态 ===== + const [loading, setLoading] = useState(false); + const [dataSource, setDataSource] = useState(MOCK_DATA); + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 10, + total: MOCK_DATA.length, + }); + + // ===== 弹窗状态 ===== + const [formVisible, setFormVisible] = useState(false); + const [editingRecord, setEditingRecord] = useState(null); + const [userListVisible, setUserListVisible] = useState(false); + const [currentRole, setCurrentRole] = useState(null); + + // ===== 提交态 ===== + const [formSubmitting, setFormSubmitting] = useState(false); + + // ===== 表格列配置 ===== + const columns = [ + { title: '角色名称', dataIndex: 'roleName', key: 'roleName', width: 200 }, + { + title: '用户数', + dataIndex: 'userCount', + key: 'userCount', + width: 120, + align: 'center' as const, + customRender: ({ text }: { text: number }) => `${text}人`, + }, + { title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 200 }, + ]; + + // ===== 计算属性 ===== + const hasFilter = computed(() => debouncedKeyword.value.trim() !== ''); + + const isEdit = computed(() => editingRecord.value !== null); + + // ===== 方法 ===== + + /** 查询 */ + const handleSearch = useThrottleFn(async () => { + setLoading(true); + try { + console.log('搜索条件:', { keyword: debouncedKeyword.value }); + // TODO: 替换为真实 API 调用 + setDataSource(MOCK_DATA); + setPagination({ ...pagination.value, total: MOCK_DATA.length }); + message.success('查询成功'); + } catch (error: any) { + message.error(error.msg || '查询失败'); + } finally { + setLoading(false); + } + }, 500); + + /** 重置 */ + const handleReset = useThrottleFn(() => { + filterForm.searchKeyword = ''; + setPagination({ current: 1, pageSize: 10, total: MOCK_DATA.length }); + setDataSource(MOCK_DATA); + }, 500); + + const handlePageChange = (page: number, pageSize: number) => { + setPagination({ ...pagination.value, current: page, pageSize }); + handleSearch(); + }; + + /** 新增 */ + const handleAdd = () => { + setEditingRecord(null); + setFormVisible(true); + }; + + /** 编辑 */ + const handleEdit = (record: any) => { + setEditingRecord(record); + setFormVisible(true); + }; + + /** 关闭表单弹窗 */ + const handleCloseForm = () => { + setFormVisible(false); + setEditingRecord(null); + }; + + /** 提交(新增/编辑) */ + const handleSave = useThrottleFn(async (formPayload: any) => { + if (formSubmitting.value) return; + setFormSubmitting(true); + try { + console.log(isEdit.value ? '编辑角色' : '新增角色', formPayload); + // TODO: 替换为真实 API 调用 + message.success(isEdit.value ? '编辑成功' : '新增成功'); + handleCloseForm(); + handleSearch(); + } catch (error: any) { + message.error(error.msg || '操作失败'); + } finally { + setFormSubmitting(false); + } + }, 500); + + /** 删除 */ + const handleDelete = useThrottleFn(async (record: any) => { + setLoading(true); + try { + console.log('删除角色:', record.roleId); + // TODO: 替换为真实 API 调用 + setDataSource(dataSource.value.filter((item: any) => item.key !== record.key)); + message.success('删除成功'); + } catch (error: any) { + message.error(error.msg || '删除失败'); + } finally { + setLoading(false); + } + }, 500); + + /** 打开用户列表弹窗 */ + const handleViewUsers = (record: any) => { + setCurrentRole(record); + setUserListVisible(true); + }; + + /** 关闭用户列表弹窗 */ + const handleCloseUserList = () => { + setUserListVisible(false); + setCurrentRole(null); + }; + + /** 根据 roleId 获取该角色下的用户列表 */ + const getRoleUsers = (roleId: string) => MOCK_USERS[roleId] || []; + + /** 状态 Tag 渲染 */ + const renderStatus = (status: string) => { + const info = STATUS_MAP[status] || { label: status || '-', color: 'default' }; + return h(Tag, { color: info.color }, () => info.label); + }; + + return { + filterForm, + loading, + dataSource, + columns, + pagination, + hasFilter, + formVisible, + editingRecord, + isEdit, + formSubmitting, + userListVisible, + currentRole, + handleSearch, + handleReset, + handlePageChange, + handleAdd, + handleEdit, + handleCloseForm, + handleSave, + handleDelete, + handleViewUsers, + handleCloseUserList, + getRoleUsers, + renderStatus, + }; +} diff --git a/src/pages/system/users/components/UserFormModal.module.less b/src/pages/system/users/components/UserFormModal.module.less new file mode 100644 index 0000000..d31433e --- /dev/null +++ b/src/pages/system/users/components/UserFormModal.module.less @@ -0,0 +1,61 @@ +/* ===== 表单行/列布局 ===== */ +.row { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.col { + flex: 1; + min-width: 0; +} + +/* 让 antd Form.Item 在 col 里能正确控制宽度 */ +.form { + :global { + .ant-form-item { + flex: 1; + min-width: 0; + } + } +} + +/* ===== 编辑模式密码区 ===== */ +.passwordEditRow { + display: flex; + align-items: center; + gap: 8px; + height: 32px; + line-height: 32px; +} + +.passwordMask { + font-family: 'Courier New', Courier, monospace; + font-size: 16px; + letter-spacing: 1px; + color: rgba(0, 0, 0, 0.65); + user-select: none; +} + +.resetBtn { + flex-shrink: 0; +} + +/* ===== 取消重置行 ===== */ +.cancelRow { + display: flex; + justify-content: flex-end; + margin-top: -8px; + margin-bottom: 16px; + font-size: 12px; +} + +/* ===== 底部按钮 ===== */ +.footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 16px; + border-top: 1px solid #f0f0f0; + margin-top: 8px; +} diff --git a/src/pages/system/users/components/UserFormModal.tsx b/src/pages/system/users/components/UserFormModal.tsx new file mode 100644 index 0000000..f75e600 --- /dev/null +++ b/src/pages/system/users/components/UserFormModal.tsx @@ -0,0 +1,217 @@ +import { defineComponent, ref, reactive, watch } from 'vue'; +import { Modal, Form, Input, Select, Button } from 'ant-design-vue'; +import { + ROLE_FORM_OPTIONS, + PASSWORD_PLACEHOLDER, + realNameRules, + phoneRules, + passwordRules, + confirmPasswordRules, + roleRules, +} from '../controller'; +import styles from './UserFormModal.module.less'; + +interface UserFormModalProps { + visible: boolean; + record: any; + submitting?: boolean; + onClose: () => void; + onSave: (formData: any) => void; +} + +/** 默认表单数据 */ +const getDefaultForm = () => ({ + realName: '', + phone: '', + password: '', + confirmPassword: '', + role: '', +}); + +export default defineComponent({ + name: 'UserFormModal', + props: { + visible: { type: Boolean, default: false }, + record: { type: Object, default: null }, + submitting: { type: Boolean, default: false }, + onClose: { type: Function, required: true }, + onSave: { type: Function, required: true }, + }, + setup(props: UserFormModalProps) { + const formRef = ref(); + const formData = reactive(getDefaultForm()); + + /** 编辑模式下是否处于"重置密码"状态 */ + const isResetting = ref(false); + + /** 根据 record 初始化表单 */ + const initFormFromRecord = (record: any) => { + const fresh = getDefaultForm(); + if (!record) { + Object.assign(formData, fresh); + isResetting.value = false; + return; + } + Object.assign(formData, { + ...fresh, + realName: record.realName || '', + phone: record.phone || '', + role: record.role || '', + }); + isResetting.value = false; + }; + + /** 监听 visible 变化重置表单 */ + watch( + () => props.visible, + (val) => { + if (val) { + initFormFromRecord(props.record); + isResetting.value = false; + setTimeout(() => formRef.value?.clearValidate(), 0); + } + }, + { immediate: true }, + ); + + /** 点击"重置密码" */ + const handleResetPassword = () => { + isResetting.value = true; + formData.password = ''; + formData.confirmPassword = ''; + }; + + /** 提交 */ + const handleSubmit = async () => { + try { + await formRef.value?.validate(); + } catch { + return; + } + + // 编辑模式下,未触发重置密码时,不提交密码字段 + const payload: any = { + realName: formData.realName, + phone: formData.phone, + role: formData.role, + }; + if (props.record) { + if (isResetting.value) { + payload.password = formData.password; + } + } else { + // 新增:密码必传 + payload.password = formData.password; + } + + props.onSave(payload); + }; + + /** 关闭 */ + const handleClose = () => { + props.onClose(); + }; + + return () => { + const isEdit = !!props.record; + + return ( + +
+ {/* 姓名 / 手机号:同一行 */} +
+ + + + + + +
+ + {/* ====== 密码区 ====== */} + {isEdit && !isResetting.value ? ( + /* 编辑模式 - 密码占位 + 重置密码按钮 */ +
+ +
+ {PASSWORD_PLACEHOLDER} + +
+
+
+
+ ) : ( + <> + {/* 新密码 / 确认密码:同一行 */} +
+ + + + formData.password)} + > + + +
+ + )} + + {/* 角色:单独一行,固定宽度 */} + + + + + (filterForm.status = val || '')} + /> + + + + + + + + + +
+ + {/* ===== 表格区 ===== */} +
+
+ + {{ + bodyCell: (args: any) => + renderBodyCell({ + ...args, + onEdit: triggerEdit, + onToggleStatus: triggerToggle, + }), + }} +
+
+ + {/* 独立分页 */} +
+ `共 ${total} 条`} + onChange={handlePageChange} + onShowSizeChange={handlePageChange} + /> +
+
+ + {/* ===== 新增/编辑弹窗 ===== */} + ); }, diff --git a/src/pages/system/users/model/useUserModel.ts b/src/pages/system/users/model/useUserModel.ts new file mode 100644 index 0000000..4dde2cb --- /dev/null +++ b/src/pages/system/users/model/useUserModel.ts @@ -0,0 +1,248 @@ +import { computed, reactive, toRef, Ref, h } from 'vue'; +import { message, Tag } from 'ant-design-vue'; +import { useState, useDebounce, useThrottleFn } from '@/hooks'; + +// ============================================================ +// 常量 +// ============================================================ + +/** 角色选项(筛选) */ +export const USER_ROLE_OPTIONS = [ + { value: '', label: '全部' }, + { value: 'admin', label: '管理员' }, +] as const; + +/** 状态选项(筛选) */ +export const USER_STATUS_OPTIONS = [ + { value: '', label: '全部' }, + { value: 'active', label: '正常' }, + { value: 'disabled', label: '禁用' }, +] as const; + +/** 角色文本/颜色映射(表格渲染) */ +const ROLE_MAP: Record = { + admin: { label: '管理员', color: 'blue' }, + operator: { label: '运营人员', color: 'cyan' }, + auditor: { label: '审核人员', color: 'purple' }, +}; + +/** 状态文本/颜色映射 */ +const STATUS_MAP: Record = { + active: { label: '正常', color: 'green' }, + disabled: { label: '禁用', color: 'red' }, +}; + +// ============================================================ +// 假数据 +// ============================================================ + +const MOCK_DATA = [ + { + key: '1', + userId: 'U20260526001', + realName: '张三', + phone: '15585658975', + role: 'admin', + status: 'active' as 'active' | 'disabled', + createTime: '2026.05.26 08:00:00', + }, + { + key: '2', + userId: 'U20260526002', + realName: '王五', + phone: '13800138000', + role: 'admin', + status: 'disabled' as 'active' | 'disabled', + createTime: '2026.05.26 14:00:00', + }, +]; + +// ============================================================ +// Model +// ============================================================ + +/** + * 系统管理 - 用户列表页数据模型 + */ +export function useUserModel() { + // ===== 筛选条件 ===== + const filterForm = reactive({ + searchKeyword: '', // 用户名/手机号 + role: '' as '' | 'admin', + status: '' as '' | 'active' | 'disabled', + }); + + // 防抖 300ms + const { debouncedValue: debouncedKeyword } = useDebounce( + toRef(filterForm, 'searchKeyword') as Ref, + { delay: 300 }, + ); + + // ===== 表格状态 ===== + const [loading, setLoading] = useState(false); + const [dataSource, setDataSource] = useState(MOCK_DATA); + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 10, + total: MOCK_DATA.length, + }); + + // ===== 弹窗状态 ===== + const [modalVisible, setModalVisible] = useState(false); + const [editingRecord, setEditingRecord] = useState(null); + + // ===== 提交态 ===== + const [formSubmitting, setFormSubmitting] = useState(false); + + // ===== 表格列配置 ===== + const columns = [ + { title: '姓名', dataIndex: 'realName', key: 'realName', width: 120 }, + { title: '手机号', dataIndex: 'phone', key: 'phone', width: 140 }, + { + title: '角色', + dataIndex: 'role', + key: 'role', + width: 120, + align: 'center' as const, + customRender: ({ text }: { text: string }) => { + const info = ROLE_MAP[text] || { label: text || '-', color: 'default' }; + return h(Tag, { color: info.color }, () => info.label); + }, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + align: 'center' as const, + customRender: ({ text }: { text: string }) => { + const info = STATUS_MAP[text] || { label: text || '-', color: 'default' }; + return h(Tag, { color: info.color }, () => info.label); + }, + }, + { title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 180 }, + ]; + + // ===== 计算属性 ===== + const hasFilter = computed(() => { + return ( + debouncedKeyword.value.trim() !== '' || filterForm.role !== '' || filterForm.status !== '' + ); + }); + + const isEdit = computed(() => editingRecord.value !== null); + + // ===== 方法 ===== + + /** 查询(节流 500ms) */ + const handleSearch = useThrottleFn(async () => { + setLoading(true); + try { + console.log('搜索条件:', { + keyword: debouncedKeyword.value, + role: filterForm.role, + status: filterForm.status, + }); + // TODO: 替换为真实 API 调用 + setDataSource(MOCK_DATA); + setPagination({ ...pagination.value, total: MOCK_DATA.length }); + message.success('查询成功'); + } catch (error: any) { + message.error(error.msg || '查询失败'); + } finally { + setLoading(false); + } + }, 500); + + /** 重置(节流 500ms) */ + const handleReset = useThrottleFn(() => { + filterForm.searchKeyword = ''; + filterForm.role = ''; + filterForm.status = ''; + setPagination({ current: 1, pageSize: 10, total: MOCK_DATA.length }); + setDataSource(MOCK_DATA); + }, 500); + + const handlePageChange = (page: number, pageSize: number) => { + setPagination({ ...pagination.value, current: page, pageSize }); + handleSearch(); + }; + + /** 打开新增弹窗 */ + const handleAdd = () => { + setEditingRecord(null); + setModalVisible(true); + }; + + /** 打开编辑弹窗 */ + const handleEdit = (record: any) => { + setEditingRecord(record); + setModalVisible(true); + }; + + /** 关闭弹窗 */ + const handleCloseModal = () => { + setModalVisible(false); + setEditingRecord(null); + }; + + /** 提交表单(新增/编辑) */ + const handleSave = useThrottleFn(async (formPayload: any) => { + if (formSubmitting.value) return; + setFormSubmitting(true); + try { + console.log(isEdit.value ? '编辑用户' : '新增用户', formPayload); + // TODO: 替换为真实 API 调用 + message.success(isEdit.value ? '编辑成功' : '新增成功'); + handleCloseModal(); + handleSearch(); + } catch (error: any) { + message.error(error.msg || '操作失败'); + } finally { + setFormSubmitting(false); + } + }, 500); + + /** 启用/禁用切换(节流 500ms) */ + const handleToggleStatus = useThrottleFn(async (record: any) => { + setLoading(true); + try { + const newStatus = record.status === 'active' ? 'disabled' : 'active'; + const actionText = newStatus === 'active' ? '启用' : '禁用'; + console.log(`${actionText}用户:`, record.userId); + // TODO: 替换为真实 API 调用 + setDataSource( + dataSource.value.map((item: any) => + item.key === record.key ? { ...item, status: newStatus } : item, + ), + ); + message.success(`${actionText}成功`); + } catch (error: any) { + message.error(error.msg || '操作失败'); + } finally { + setLoading(false); + } + }, 500); + + return { + filterForm, + loading, + dataSource, + columns, + pagination, + hasFilter, + modalVisible, + setModalVisible, + editingRecord, + isEdit, + formSubmitting, + handleSearch, + handleReset, + handlePageChange, + handleAdd, + handleEdit, + handleCloseModal, + handleSave, + handleToggleStatus, + }; +} diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts new file mode 100644 index 0000000..e524f82 --- /dev/null +++ b/src/stores/userStore.ts @@ -0,0 +1,39 @@ +import { reactive, computed, toRefs } from 'vue'; + +interface UserState { + /** 手机号 */ + phone: string; + /** 昵称 */ + nickname: string; + /** 登录 token */ + token: string; +} + +const state = reactive({ + phone: '13377981125', + nickname: '', + token: '', +}); + +export function useUserStore() { + const setUser = (user: Partial) => { + if (user.phone !== undefined) state.phone = user.phone; + if (user.nickname !== undefined) state.nickname = user.nickname; + if (user.token !== undefined) state.token = user.token; + }; + + const clearUser = () => { + state.phone = ''; + state.nickname = ''; + state.token = ''; + }; + + return { + ...toRefs(state), + phone: computed(() => state.phone), + nickname: computed(() => state.nickname), + token: computed(() => state.token), + setUser, + clearUser, + }; +} diff --git a/src/utils/form.ts b/src/utils/form.ts new file mode 100644 index 0000000..27b11ea --- /dev/null +++ b/src/utils/form.ts @@ -0,0 +1,47 @@ +/** + * 表单正则校验工具 + * + * 只导出纯正则常量与独立的校验函数,不做任何 antd/framework 耦合。 + * 各页面的 Form.Item rules 应在对应页面目录的 controller.ts 中组装。 + */ + +// ============================================================ +// 正则常量 +// ============================================================ + +/** + * 中国大陆手机号 + * - 11 位数字,第二位 3-9 + */ +export const CHINA_PHONE_REGEX = /^1[3-9]\d{9}$/; + +/** + * 登录密码 + * - 至少 8 位,同时包含字母与数字 + * - 允许特殊字符 !@#$%^&*()_+-= + */ +export const LOGIN_PASSWORD_REGEX = /^(?=.*[A-Za-z])(?=.*\d)[\w!@#$%^&*()_+\-=]{8,}$/; + +/** + * 真实姓名(中文/英文/数字,1-8 位) + */ +export const REAL_NAME_REGEX = /^[\u4e00-\u9fa5A-Za-z0-9·]{1,8}$/; + +// ============================================================ +// 校验函数 +// ============================================================ + +/** 校验中国大陆手机号 */ +export function isChinaPhone(value: unknown): boolean { + return typeof value === 'string' && CHINA_PHONE_REGEX.test(value); +} + +/** 校验密码:至少 8 位 + 同时含字母与数字 */ +export function isValidPassword(value: unknown): boolean { + return typeof value === 'string' && LOGIN_PASSWORD_REGEX.test(value); +} + +/** 校验真实姓名:1-8 个字符 */ +export function isValidRealName(value: unknown): boolean { + return typeof value === 'string' && value.length > 0 && REAL_NAME_REGEX.test(value); +}