1. 提交代码

This commit is contained in:
2026-04-23 10:59:36 +08:00
parent 2103173b9d
commit 02ff9e4e65
11 changed files with 1300 additions and 335 deletions

View File

@@ -16,6 +16,8 @@ export interface StoreUser {
roleNames?: string
/** 门店名称(列表接口返回) */
storeName?: string
/** 编辑时提交的门店绑定(全量替换) */
storeIds?: number[]
}
// 门店-用户绑定 API
@@ -30,6 +32,11 @@ export const StoreUserApi = {
return await request.get({ url: `/ydoyun/store-user/get?id=` + id })
},
/** 编辑弹窗:用户账号 + 已绑定门店列表 */
getStoreUserForEdit: async (userId: number) => {
return await request.get({ url: `/ydoyun/store-user/get-edit`, params: { userId } })
},
// 新增门店-用户绑定
createStoreUser: async (data: StoreUser) => {
return await request.post({ url: `/ydoyun/store-user/create`, data })
@@ -40,16 +47,36 @@ export const StoreUserApi = {
return await request.put({ url: `/ydoyun/store-user/update`, data })
},
// 删除门店-用户绑定
// 删除单条绑定记录(不删账号)
deleteStoreUser: async (id: number) => {
return await request.delete({ url: `/ydoyun/store-user/delete?id=` + id })
},
/** 批量删除门店-用户绑定 */
/** 按用户 + 门店解除绑定(不删账号) */
deleteStoreUserBinding: async (userId: number, storeId: number) => {
return await request.delete({
url: `/ydoyun/store-user/delete-binding`,
params: { userId, storeId }
})
},
/** 删除用户账号及全部门店绑定 */
deleteStoreUserAccount: async (userId: number) => {
return await request.delete({ url: `/ydoyun/store-user/delete-user`, params: { userId } })
},
/** 批量删除绑定记录(不删账号) */
deleteStoreUserList: async (ids: number[]) => {
return await request.delete({ url: `/ydoyun/store-user/delete-list?ids=${ids.join(',')}` })
},
/** 批量删除用户账号 */
deleteStoreUserAccountList: async (userIds: number[]) => {
return await request.delete({
url: `/ydoyun/store-user/delete-user-list?userIds=${userIds.join(',')}`
})
},
// 导出门店-用户绑定 Excel
exportStoreUser: async (params: any) => {
return await request.download({ url: `/ydoyun/store-user/export-excel`, params })

View File

@@ -420,7 +420,8 @@ export default {
sex: 'Sex',
man: 'Man',
woman: 'Woman',
createTime: 'Created Date'
createTime: 'Created Date',
avatar: 'Avatar'
},
info: {
title: 'Basic Information',

View File

@@ -414,7 +414,8 @@ export default {
sex: '性别',
man: '男',
woman: '女',
createTime: '创建日期'
createTime: '创建日期',
avatar: '头像'
},
info: {
title: '基本信息',

View File

@@ -830,6 +830,17 @@ const remainingRouter: AppRouteRecordRaw[] = [
canTo: true
},
component: () => import('@/views/ydoyun/report/lijun/reportpage6/detail.vue')
},
{
path: 'product-splb',
name: 'ProductSplbReport',
meta: {
title: '商品报表',
noCache: true,
hidden: true,
canTo: true
},
component: () => import('@/views/ydoyun/report/productSplb/index.vue')
}
]
},

View File

@@ -1,4 +1,8 @@
<template>
<div class="basic-info-avatar-row">
<span class="basic-info-avatar-label">{{ t('profile.user.avatar') }}</span>
<UserAvatar :img="profileAvatar" />
</div>
<Form ref="formRef" :labelWidth="200" :rules="rules" :schema="schema">
<template #sex="form">
<el-radio-group v-model="form['sex']">
@@ -22,6 +26,7 @@ import {
UserProfileUpdateReqVO
} from '@/api/system/user/profile'
import { useUserStore } from '@/store/modules/user'
import UserAvatar from './UserAvatar.vue'
defineOptions({ name: 'BasicInfo' })
@@ -78,17 +83,18 @@ const schema = reactive<FormSchema[]>([
}
])
const formRef = ref<FormExpose>() // 表单 Ref
/** 基本设置内头像展示与表单、store 同步 */
const profileAvatar = ref('')
// 监听 userStore 中头像的变化,同步更新表单数据
// 监听 userStore 中头像的变化,同步更新表单与头像区
watch(
() => userStore.getUser.avatar,
(newAvatar) => {
if (newAvatar && formRef.value) {
// 直接更新表单模型中的头像字段
const formModel = formRef.value.formModel
if (formModel) {
formModel.avatar = newAvatar
}
if (!newAvatar) return
profileAvatar.value = newAvatar
const formModel = formRef.value?.formModel
if (formModel) {
formModel.avatar = newAvatar
}
}
)
@@ -112,6 +118,7 @@ const submit = () => {
const init = async () => {
const res = await getUserProfile()
unref(formRef)?.setValues(res)
profileAvatar.value = res.avatar || ''
return res
}
@@ -119,3 +126,21 @@ onMounted(async () => {
await init()
})
</script>
<style scoped lang="scss">
.basic-info-avatar-row {
display: flex;
align-items: center;
margin-bottom: 22px;
}
.basic-info-avatar-label {
width: 200px;
flex-shrink: 0;
padding-right: 12px;
font-size: 14px;
color: var(--el-text-color-regular);
text-align: right;
box-sizing: border-box;
}
</style>

View File

@@ -1394,8 +1394,13 @@ onMounted(async () => {
align-items: flex-start;
.product-image {
box-sizing: border-box;
width: 48px;
height: 56px;
min-width: 48px;
min-height: 56px;
max-width: 48px;
max-height: 56px;
background-color: var(--el-fill-color);
border-radius: 4px;
display: flex;
@@ -1406,9 +1411,14 @@ onMounted(async () => {
flex-shrink: 0;
overflow: hidden;
.product-img {
display: block;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: cover;
object-position: center;
flex-shrink: 0;
}
}
@@ -1537,8 +1547,13 @@ onMounted(async () => {
margin-bottom: 16px;
.kb22-thumb-box {
box-sizing: border-box;
width: 80px;
height: 96px;
min-width: 80px;
min-height: 96px;
max-width: 80px;
max-height: 96px;
background: var(--el-fill-color);
border-radius: 6px;
display: flex;
@@ -1548,9 +1563,14 @@ onMounted(async () => {
overflow: hidden;
.kb22-thumb-img {
display: block;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: cover;
object-position: center;
flex-shrink: 0;
}
.kb22-no-img {

File diff suppressed because it is too large Load Diff

View File

@@ -149,9 +149,8 @@
<div class="time-elapsed-line" :style="{ left: clampPercent(monthTimeProgress) + '%' }"></div>
<div class="target-marker" :style="{ left: '100%' }"></div>
</div>
<div class="bullet-time-under" aria-label="时间进度">
<span class="bullet-time-label">时间进度</span>
<span class="bullet-time-value">{{ formatPercent(monthTimeProgress) }}</span>
<div class="bullet-time-under" :aria-label="monthVsTimeProgressDisplay.ariaLabel">
<span class="bullet-time-label">时间进度 {{ formatPercent(monthTimeProgress) }}<span :class="monthVsTimeProgressDisplay.cls">{{ monthVsTimeProgressDisplay.text }}</span></span>
</div>
</div>
</div>
@@ -898,6 +897,36 @@ const todayGoalRmb = computed(() => {
const monthGoalRate = computed(() => normalizePercent(summary.bydcl))
const monthTimeProgress = computed(() => normalizePercent(summary.bysjjd))
/** 本月累计完成率、时间进度按「百分点」取值;无效时为 NaN与 normalizePercent 规则一致) */
function toPercentPoints(v: any): number {
const n = toFiniteNumber(v)
if (!Number.isFinite(n)) return NaN
return n <= 1 ? n * 100 : n
}
/**
* 相对时间进度的领先/落后:本月累计完成率 时间进度(百分点)。
* 为正表示完成进度快于日历时间(领先),为负表示落后于时间进度。
*/
const monthVsTimeProgressDisplay = computed(() => {
const rate = toPercentPoints(summary.bydcl)
const time = toPercentPoints(summary.bysjjd)
if (!Number.isFinite(rate) || !Number.isFinite(time)) {
return { text: '--', cls: '', ariaLabel: '相对时间进度:数据不足' } as const
}
const gap = rate - time
if (Math.abs(gap) < 0.05) {
const t = '与时间进度持平'
return { text: t, cls: '', ariaLabel: `相对时间进度:${t}` } as const
}
if (gap > 0) {
const t = `时间进度领先 ${gap.toFixed(1)}%`
return { text: t, cls: 'sxrb-metric-up', ariaLabel: `相对时间进度:${t}` } as const
}
const t = `时间进度落后 ${Math.abs(gap).toFixed(1)}%`
return { text: t, cls: 'sxrb-metric-down', ariaLabel: `相对时间进度:${t}` } as const
})
function clampPercent(v: any): number {
const n = normalizePercent(v)
return Math.min(100, Math.max(0, n))

File diff suppressed because one or more lines are too long

View File

@@ -14,7 +14,25 @@
<el-input v-model="formData.nickname" placeholder="请输入用户昵称" />
</el-form-item>
<el-form-item label="手机号码" prop="mobile">
<el-input v-model="formData.mobile" placeholder="请输入手机号码" maxlength="11" />
<el-input v-model="formData.mobile" placeholder="选填" maxlength="11" />
</el-form-item>
<el-form-item v-if="formType === 'update'" label="门店绑定" prop="storeIds">
<el-select
v-model="formData.storeIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="请选择门店(可多家)"
class="!w-full"
>
<el-option
v-for="opt in storeOptions"
:key="opt.id"
:label="opt.label"
:value="opt.id"
/>
</el-select>
</el-form-item>
<el-form-item v-if="formType === 'create'" label="密码" prop="password">
<el-input
@@ -36,94 +54,135 @@
</template>
<script setup lang="ts">
import { StoreUserApi, StoreUser } from '@/api/ydoyun/storeuser'
import { StoreApi } from '@/api/ydoyun/store'
/** 门店-用户绑定 表单 */
/** 门店用户表单(新增单门店绑定 / 编辑账号与多门店绑定) */
defineOptions({ name: 'StoreUserForm' })
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const { t } = useI18n()
const message = useMessage()
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formLoading = ref(false)
const formType = ref('')
const storeOptions = ref<{ id: number; label: string }[]>([])
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中1修改时的数据加载2提交的按钮禁用
const formType = ref('') // 表单的类型create - 新增update - 修改
const formData = ref({
id: undefined,
storeId: undefined,
userId: undefined,
username: undefined,
nickname: undefined,
password: undefined,
mobile: undefined
id: undefined as number | undefined,
storeId: undefined as number | undefined,
userId: undefined as number | undefined,
username: undefined as string | undefined,
nickname: undefined as string | undefined,
password: undefined as string | undefined,
mobile: undefined as string | undefined,
storeIds: [] as number[]
})
const mobilePattern =
/^(?:(?:\+|00)86)?1(?:3[\d]|4[5-79]|5[0-35-9]|6[5-7]|7[0-8]|8[\d]|9[189])\d{8}$/
const formRules = computed(() => {
const rules: any = {
username: [{ required: true, message: '用户名称不能为空', trigger: 'blur' }],
nickname: [{ required: true, message: '用户昵称不能为空', trigger: 'blur' }],
mobile: [
{
pattern: /^(?:(?:\+|00)86)?1(?:3[\d]|4[5-79]|5[0-35-9]|6[5-7]|7[0-8]|8[\d]|9[189])\d{8}$/,
message: '请输入正确的手机号码',
validator: (_rule: any, value: string, callback: (e?: Error) => void) => {
const s = value?.trim()
if (!s) {
callback()
return
}
if (!mobilePattern.test(s)) {
callback(new Error('请输入正确的手机号码'))
} else {
callback()
}
},
trigger: 'blur'
}
]
}
// 只有新增时才需要密码
if (formType.value === 'create') {
rules.password = [{ required: true, message: '密码不能为空', trigger: 'blur' }]
}
return rules
})
const formRef = ref() // 表单 Ref
/** 打开弹窗 */
const open = async (type: string, id?: number, storeId?: number) => {
const formRef = ref()
const loadStoreOptions = async () => {
const data = await StoreApi.getStorePage({ pageNo: 1, pageSize: -1 })
const list = data?.list ?? []
storeOptions.value = list.map((s: { id: number; storeCode?: string; storeName?: string }) => {
const code = s.storeCode?.trim()
const name = s.storeName?.trim()
const label = [code, name].filter(Boolean).join(' ') || `门店#${s.id}`
return { id: s.id, label }
})
}
/** 打开弹窗update 时 id 为系统用户 userId */
const open = async (type: string, userId?: number, treeStoreId?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
// 设置门店ID
if (storeId) {
formData.value.storeId = storeId
await loadStoreOptions()
if (treeStoreId) {
formData.value.storeId = treeStoreId
}
// 修改时,设置数据
if (id) {
if (type === 'update' && userId) {
formLoading.value = true
try {
const data = await StoreUserApi.getStoreUser(id)
formData.value = { ...data, storeId: storeId || data.storeId }
const data = await StoreUserApi.getStoreUserForEdit(userId)
formData.value.userId = data.userId
formData.value.username = data.username
formData.value.nickname = data.nickname
formData.value.mobile = data.mobile ?? ''
formData.value.storeIds = Array.isArray(data.storeIds) ? [...data.storeIds] : []
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
defineExpose({ open })
const emit = defineEmits(['success'])
const submitForm = async () => {
// 校验表单
await formRef.value.validate()
// 提交请求
formLoading.value = true
try {
const data = formData.value as unknown as StoreUser
if (formType.value === 'create') {
const data: StoreUser = {
storeId: formData.value.storeId,
username: formData.value.username,
nickname: formData.value.nickname,
mobile: formData.value.mobile,
password: formData.value.password
}
await StoreUserApi.createStoreUser(data)
message.success(t('common.createSuccess'))
} else {
await StoreUserApi.updateStoreUser(data)
await StoreUserApi.updateStoreUser({
userId: formData.value.userId,
username: formData.value.username,
nickname: formData.value.nickname,
mobile: formData.value.mobile,
storeIds: formData.value.storeIds ?? []
})
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
// 发送操作成功的事件
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
@@ -132,7 +191,8 @@ const resetForm = () => {
username: undefined,
nickname: undefined,
password: undefined,
mobile: undefined
mobile: undefined,
storeIds: []
}
formRef.value?.resetFields()
}

View File

@@ -99,13 +99,22 @@
<Icon icon="ep:refresh" />从E3同步账号
</el-button>
<el-button
type="danger"
type="warning"
plain
:disabled="checkedIds.length === 0"
@click="handleDeleteBatch"
:disabled="checkedUserIds.length === 0 || !currentStoreId"
@click="handleDeleteBatchBinding"
v-hasPermi="['ydoyun:store-user:delete']"
>
<Icon icon="ep:delete" />批量删除
<Icon icon="ep:link" />批量解除绑定
</el-button>
<el-button
type="danger"
plain
:disabled="checkedUserIds.length === 0"
@click="handleDeleteBatchUsers"
v-hasPermi="['ydoyun:store-user:delete']"
>
<Icon icon="ep:delete" />批量删除用户
</el-button>
<el-button
type="success"
@@ -120,7 +129,12 @@
</el-form>
</ContentWrap>
<ContentWrap>
<el-table v-loading="loading" :data="list" @selection-change="handleRowCheckboxChange">
<el-table
v-loading="loading"
:data="list"
row-key="userId"
@selection-change="handleRowCheckboxChange"
>
<el-table-column type="selection" width="55" />
<el-table-column
label="用户昵称"
@@ -186,13 +200,13 @@
:formatter="dateFormatter"
width="180"
/>
<el-table-column label="操作" align="center" width="280">
<el-table-column label="操作" align="center" width="320">
<template #default="scope">
<div class="flex flex-wrap items-center justify-center gap-x-1">
<el-button
type="primary"
link
@click="openForm('update', scope.row.id)"
@click="openForm('update', scope.row.userId)"
v-hasPermi="['ydoyun:store-user:update']"
>
<Icon icon="ep:edit" />修改
@@ -213,10 +227,16 @@
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
command="handleDelete"
command="handleDeleteBinding"
v-if="checkPermi(['ydoyun:store-user:delete'])"
>
<Icon icon="ep:delete" />删除
<Icon icon="ep:remove" />解除门店绑定
</el-dropdown-item>
<el-dropdown-item
command="handleDeleteUser"
v-if="checkPermi(['ydoyun:store-user:delete'])"
>
<Icon icon="ep:delete" />删除用户
</el-dropdown-item>
<el-dropdown-item
command="handleRole"
@@ -370,12 +390,12 @@ const handleStoreNodeClick = async (row: any) => {
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
const openForm = (type: string, userId?: number) => {
if (!currentStoreId.value && type === 'create') {
message.warning('请先选择门店')
return
}
formRef.value.open(type, id, currentStoreId.value)
formRef.value.open(type, userId, currentStoreId.value)
}
/** E3同步账号 */
@@ -426,8 +446,11 @@ const handleE3Sync = async () => {
/** 操作分发 */
const handleCommand = (command: string, row: StoreUser) => {
switch (command) {
case 'handleDelete':
handleDelete(row.id!)
case 'handleDeleteBinding':
handleDeleteBinding(row)
break
case 'handleDeleteUser':
handleDeleteUser(row)
break
case 'handleRole':
handleRole(row)
@@ -437,34 +460,84 @@ const handleCommand = (command: string, row: StoreUser) => {
}
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
/** 解除当前选中门店下的绑定(不删账号) */
const handleDeleteBinding = async (row: StoreUser) => {
if (!currentStoreId.value) {
message.warning('请先在左侧选择门店,再解除该用户与此门店的绑定')
return
}
if (!row.userId) {
message.warning('用户ID不存在')
return
}
try {
// 删除的二次确认
await message.delConfirm()
// 发起删除
await StoreUserApi.deleteStoreUser(id)
message.success(t('common.delSuccess'))
// 刷新列表
await message.confirm(
`确定解除用户「${row.nickname || row.username || row.userId}」与当前选中门店的绑定吗?不会删除系统账号。`
)
await StoreUserApi.deleteStoreUserBinding(row.userId, currentStoreId.value)
message.success('已解除绑定')
await getList()
} catch {}
}
/** 批量删除按钮操作 */
const checkedIds = ref<number[]>([])
const handleRowCheckboxChange = (rows: StoreUser[]) => {
checkedIds.value = rows.map((row) => row.id!).filter((id): id is number => id !== undefined)
/** 删除用户账号及全部门店绑定 */
const handleDeleteUser = async (row: StoreUser) => {
if (!row.userId) {
message.warning('用户ID不存在')
return
}
try {
await message.confirm(
`确定删除用户「${row.nickname || row.username || row.userId}」及其全部门店绑定吗?此操作将删除系统账号及权限数据,不可恢复。`
)
await StoreUserApi.deleteStoreUserAccount(row.userId)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
const handleDeleteBatch = async () => {
/** 勾选行为系统用户编号 */
const checkedUserIds = ref<number[]>([])
const handleRowCheckboxChange = (rows: StoreUser[]) => {
checkedUserIds.value = rows
.map((row) => row.userId!)
.filter((id): id is number => id !== undefined)
}
/** 批量解除当前门店绑定 */
const handleDeleteBatchBinding = async () => {
if (!currentStoreId.value) {
message.warning('请先在左侧选择门店')
return
}
if (checkedUserIds.value.length === 0) {
return
}
try {
// 删除的二次确认
await message.delConfirm()
// 发起批量删除
await StoreUserApi.deleteStoreUserList(checkedIds.value)
checkedIds.value = []
await message.confirm(
`确定解除已选 ${checkedUserIds.value.length} 个用户与当前门店的绑定吗?不会删除系统账号。`
)
for (const uid of checkedUserIds.value) {
await StoreUserApi.deleteStoreUserBinding(uid, currentStoreId.value)
}
checkedUserIds.value = []
message.success('已批量解除绑定')
await getList()
} catch {}
}
/** 批量删除用户 */
const handleDeleteBatchUsers = async () => {
if (checkedUserIds.value.length === 0) {
return
}
try {
await message.confirm(
`确定删除已选 ${checkedUserIds.value.length} 个用户及其全部门店绑定吗?将删除系统账号及权限数据,不可恢复。`
)
await StoreUserApi.deleteStoreUserAccountList(checkedUserIds.value)
checkedUserIds.value = []
message.success(t('common.delSuccess'))
// 刷新列表
await getList()
} catch {}
}