first commit

This commit is contained in:
湛兮
2026-05-26 11:14:37 +08:00
commit 88d4578600
214 changed files with 25313 additions and 0 deletions
+748
View File
@@ -0,0 +1,748 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import {
ElMessage,
ElMessageBox,
type FormInstance,
type FormRules
} from "element-plus";
import {
createEmployee,
deleteEmployee,
getEmployee,
listEmployees,
listRoles,
listStores,
updateEmployee,
updateEmployeeStatus,
type Employee,
type EmployeePayload,
type EmployeeStatus,
type RoleOption,
type StoreOption
} from "@/api/access";
import Plus from "~icons/ep/plus";
import Search from "~icons/ep/search";
import Refresh from "~icons/ep/refresh";
import EditPen from "~icons/ep/edit-pen";
import Delete from "~icons/ep/delete";
import CircleCheck from "~icons/ep/circle-check";
import CircleClose from "~icons/ep/circle-close";
defineOptions({
name: "EmployeeManagement"
});
type EmployeeFormState = EmployeePayload & {
id?: number;
};
const phonePattern = /^1[3-9]\d{9}$/;
const tableLoading = ref(false);
const catalogLoading = ref(false);
const submitLoading = ref(false);
const dialogVisible = ref(false);
const formRef = ref<FormInstance>();
const employees = ref<Employee[]>([]);
const stores = ref<StoreOption[]>([]);
const roles = ref<RoleOption[]>([]);
const query = reactive({
storeId: undefined as number | undefined,
status: undefined as EmployeeStatus | undefined,
keyword: "",
page: 1,
pageSize: 20
});
const pagination = reactive({
total: 0,
totalPages: 0
});
const form = reactive<EmployeeFormState>({
storeId: undefined as unknown as number,
name: "",
phone: "",
status: "ACTIVE",
remark: "",
roleIds: []
});
const rules: FormRules<EmployeeFormState> = {
storeId: [{ required: true, message: "请选择所属门店", trigger: "change" }],
name: [
{ required: true, message: "请输入员工姓名", trigger: "blur" },
{ max: 50, message: "员工姓名不能超过 50 个字符", trigger: "blur" }
],
phone: [
{ required: true, message: "请输入手机号", trigger: "blur" },
{
pattern: phonePattern,
message: "请输入正确的中国大陆手机号",
trigger: "blur"
}
],
status: [{ required: true, message: "请选择员工状态", trigger: "change" }],
roleIds: [{ type: "array", message: "请选择角色", trigger: "change" }],
remark: [{ max: 500, message: "备注不能超过 500 个字符", trigger: "blur" }]
};
const activeCount = computed(
() => employees.value.filter(item => item.status === "ACTIVE").length
);
const inactiveCount = computed(
() => employees.value.filter(item => item.status === "INACTIVE").length
);
const dialogTitle = computed(() => (form.id ? "编辑员工" : "新增员工"));
function getErrorMessage(error: unknown, fallback: string) {
const message = (
error as { response?: { data?: { error?: { message?: string } } } }
)?.response?.data?.error?.message;
if (message) return message;
if (error instanceof Error && error.message) return error.message;
return fallback;
}
function formatTime(value: string) {
return new Date(value).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
function resetFormState() {
Object.assign(form, {
id: undefined,
storeId: stores.value[0]?.id,
name: "",
phone: "",
status: "ACTIVE",
remark: "",
roleIds: []
});
formRef.value?.clearValidate();
}
function buildPayload(): EmployeePayload {
return {
storeId: form.storeId,
name: form.name.trim(),
phone: form.phone.trim(),
status: form.status,
remark: form.remark?.trim() ? form.remark.trim() : null,
roleIds: form.roleIds
};
}
async function fetchCatalog() {
catalogLoading.value = true;
try {
const [storeResult, roleResult] = await Promise.all([
listStores(),
listRoles()
]);
stores.value = storeResult.data;
roles.value = roleResult.data;
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载门店和角色选项失败"));
} finally {
catalogLoading.value = false;
}
}
async function fetchEmployees() {
tableLoading.value = true;
try {
const result = await listEmployees({
storeId: query.storeId,
status: query.status,
keyword: query.keyword.trim() || undefined,
page: query.page,
pageSize: query.pageSize
});
employees.value = result.data.items;
pagination.total = result.data.pagination.total;
pagination.totalPages = result.data.pagination.totalPages;
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载员工列表失败"));
} finally {
tableLoading.value = false;
}
}
function handleSearch() {
query.page = 1;
fetchEmployees();
}
function handleReset() {
query.storeId = undefined;
query.status = undefined;
query.keyword = "";
query.page = 1;
query.pageSize = 20;
fetchEmployees();
}
function handlePageChange(page: number) {
query.page = page;
fetchEmployees();
}
function handleSizeChange(pageSize: number) {
query.page = 1;
query.pageSize = pageSize;
fetchEmployees();
}
function openCreateDialog() {
resetFormState();
dialogVisible.value = true;
}
async function openEditDialog(row: Employee) {
try {
const result = await getEmployee(row.id);
const employee = result.data;
Object.assign(form, {
id: employee.id,
storeId: employee.storeId,
name: employee.name,
phone: employee.phone,
status: employee.status,
remark: employee.remark ?? "",
roleIds: employee.roles.map(role => role.id)
});
dialogVisible.value = true;
formRef.value?.clearValidate();
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载员工详情失败"));
}
}
async function submitForm() {
await formRef.value?.validate();
submitLoading.value = true;
try {
const payload = buildPayload();
if (form.id) {
await updateEmployee(form.id, payload);
ElMessage.success("员工信息已更新");
} else {
await createEmployee(payload);
ElMessage.success("员工已创建");
}
dialogVisible.value = false;
fetchEmployees();
} catch (error) {
ElMessage.error(getErrorMessage(error, "保存员工失败"));
} finally {
submitLoading.value = false;
}
}
async function toggleStatus(row: Employee) {
const nextStatus: EmployeeStatus =
row.status === "ACTIVE" ? "INACTIVE" : "ACTIVE";
const action = nextStatus === "ACTIVE" ? "启用" : "停用";
try {
await ElMessageBox.confirm(
`确认${action}员工「${row.name}」?`,
"状态变更",
{
type: "warning",
confirmButtonText: action,
cancelButtonText: "取消"
}
);
await updateEmployeeStatus(row.id, nextStatus);
ElMessage.success(`${action}`);
fetchEmployees();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(getErrorMessage(error, "状态变更失败"));
}
}
}
async function removeEmployee(row: Employee) {
try {
await ElMessageBox.confirm(
`删除后员工「${row.name}」会被软删除并停用,确认继续?`,
"删除员工",
{
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消"
}
);
await deleteEmployee(row.id);
ElMessage.success("员工已删除");
if (employees.value.length === 1 && query.page > 1) {
query.page -= 1;
}
fetchEmployees();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(getErrorMessage(error, "删除员工失败"));
}
}
}
onMounted(async () => {
await fetchCatalog();
await fetchEmployees();
});
</script>
<template>
<section class="employee-page">
<div class="page-heading">
<div>
<p class="eyebrow">门店员工权限管理</p>
<h1>员工管理</h1>
</div>
<el-button type="primary" :icon="Plus" @click="openCreateDialog">
新增员工
</el-button>
</div>
<div class="summary-strip">
<div class="summary-item">
<span>总员工</span>
<strong>{{ pagination.total }}</strong>
</div>
<div class="summary-item">
<span>当前页启用</span>
<strong>{{ activeCount }}</strong>
</div>
<div class="summary-item">
<span>当前页停用</span>
<strong>{{ inactiveCount }}</strong>
</div>
<div class="summary-item">
<span>角色数量</span>
<strong>{{ roles.length }}</strong>
</div>
</div>
<div class="toolbar">
<el-select
v-model="query.storeId"
clearable
filterable
placeholder="全部门店"
class="toolbar-control"
:loading="catalogLoading"
>
<el-option
v-for="store in stores"
:key="store.id"
:label="store.name"
:value="store.id"
/>
</el-select>
<el-select
v-model="query.status"
clearable
placeholder="全部状态"
class="toolbar-control"
>
<el-option label="启用" value="ACTIVE" />
<el-option label="停用" value="INACTIVE" />
</el-select>
<el-input
v-model="query.keyword"
clearable
class="keyword-input"
placeholder="搜索姓名或手机号"
@keyup.enter="handleSearch"
/>
<div class="toolbar-actions">
<el-button type="primary" :icon="Search" @click="handleSearch">
查询
</el-button>
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
</div>
</div>
<div class="table-shell">
<el-table
v-loading="tableLoading"
:data="employees"
row-key="id"
stripe
class="employee-table"
>
<el-table-column prop="name" label="员工" min-width="160">
<template #default="{ row }">
<div class="employee-cell">
<strong>{{ row.name }}</strong>
<span>{{ row.phone }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="storeName" label="门店" min-width="150" />
<el-table-column label="角色" min-width="220">
<template #default="{ row }">
<div class="role-tags">
<el-tag
v-for="role in row.roles"
:key="role.id"
effect="plain"
size="small"
>
{{ role.name }}
</el-tag>
<span v-if="row.roles.length === 0" class="muted">未绑定</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag
:type="row.status === 'ACTIVE' ? 'success' : 'info'"
effect="light"
>
{{ row.status === "ACTIVE" ? "启用" : "停用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="180">
<template #default="{ row }">
<span>{{ row.remark || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="更新时间" width="180">
<template #default="{ row }">
{{ formatTime(row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
:icon="EditPen"
@click="openEditDialog(row)"
>
编辑
</el-button>
<el-button
link
:type="row.status === 'ACTIVE' ? 'warning' : 'success'"
:icon="row.status === 'ACTIVE' ? CircleClose : CircleCheck"
@click="toggleStatus(row)"
>
{{ row.status === "ACTIVE" ? "停用" : "启用" }}
</el-button>
<el-button
link
type="danger"
:icon="Delete"
@click="removeEmployee(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-row">
<span
> {{ pagination.total }} {{ pagination.totalPages }} </span
>
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
background
layout="sizes, prev, pager, next"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
destroy-on-close
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="employee-form"
>
<el-form-item label="所属门店" prop="storeId">
<el-select
v-model="form.storeId"
filterable
placeholder="请选择门店"
class="full-width"
:loading="catalogLoading"
>
<el-option
v-for="store in stores"
:key="store.id"
:label="store.name"
:value="store.id"
/>
</el-select>
</el-form-item>
<div class="form-grid">
<el-form-item label="员工姓名" prop="name">
<el-input v-model="form.name" maxlength="50" placeholder="请输入" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="form.phone"
maxlength="11"
placeholder="请输入 11 位手机号"
/>
</el-form-item>
</div>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio-button label="ACTIVE">启用</el-radio-button>
<el-radio-button label="INACTIVE">停用</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="角色" prop="roleIds">
<el-select
v-model="form.roleIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="请选择角色"
class="full-width"
:loading="catalogLoading"
>
<el-option
v-for="role in roles"
:key="role.id"
:label="`${role.name}${role.code}`"
:value="role.id"
/>
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="form.remark"
type="textarea"
maxlength="500"
show-word-limit
:rows="4"
placeholder="可填写班次、权限说明或其他备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">
保存
</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped lang="scss">
.employee-page {
min-height: 100%;
padding: 20px;
background: #f6f8fb;
}
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
h1 {
margin: 4px 0 0;
font-size: 24px;
font-weight: 650;
color: #111827;
letter-spacing: 0;
}
}
.eyebrow {
margin: 0;
font-size: 13px;
color: #64748b;
}
.summary-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-item {
padding: 14px 16px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
span {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: #64748b;
}
strong {
font-size: 24px;
font-weight: 700;
color: #111827;
letter-spacing: 0;
}
}
.toolbar,
.table-shell {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 14px;
margin-bottom: 16px;
}
.toolbar-control {
width: 180px;
}
.keyword-input {
width: 240px;
}
.toolbar-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.table-shell {
padding: 8px 0 14px;
overflow: hidden;
}
.employee-table {
width: 100%;
}
.employee-cell {
display: flex;
flex-direction: column;
gap: 3px;
strong {
font-weight: 650;
color: #111827;
}
span {
font-size: 12px;
color: #64748b;
}
}
.role-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.muted {
color: #94a3b8;
}
.pagination-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px 0;
font-size: 13px;
color: #64748b;
}
.employee-form {
padding-top: 4px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.full-width {
width: 100%;
}
@media (max-width: 760px) {
.employee-page {
padding: 12px;
}
.page-heading {
align-items: flex-start;
flex-direction: column;
}
.summary-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.toolbar-control,
.keyword-input,
.toolbar-actions {
width: 100%;
margin-left: 0;
}
.toolbar-actions {
justify-content: flex-end;
}
.pagination-row {
align-items: flex-start;
flex-direction: column;
}
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import noAccess from "@/assets/status/403.svg?component";
defineOptions({
name: "403"
});
const router = useRouter();
</script>
<template>
<div
class="flex flex-col md:flex-row justify-center items-center min-h-full w-full p-4 md:p-0"
>
<noAccess />
<div class="mt-8 md:ml-12 md:mt-0 text-center md:text-left">
<p
v-motion
class="font-medium text-4xl mb-4! dark:text-white"
:initial="{
opacity: 0,
y: 100
}"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 80
}
}"
>
403
</p>
<p
v-motion
class="text-xl mb-4! text-gray-500"
:initial="{
opacity: 0,
y: 100
}"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 120
}
}"
>
抱歉你无权访问该页面
</p>
<el-button
v-motion
type="primary"
class="block mx-auto md:inline-block md:mx-0"
:initial="{
opacity: 0,
y: 100
}"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 160
}
}"
@click="router.push('/')"
>
返回首页
</el-button>
</div>
</div>
</template>
<style scoped>
.main-content {
margin: 0 !important;
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import noExist from "@/assets/status/404.svg?component";
defineOptions({
name: "404"
});
const router = useRouter();
</script>
<template>
<div
class="flex flex-col md:flex-row justify-center items-center min-h-full w-full p-4 md:p-0"
>
<noExist />
<div class="mt-8 md:ml-12 md:mt-0 text-center md:text-left">
<p
v-motion
class="font-medium text-4xl mb-4! dark:text-white"
:initial="{
opacity: 0,
y: 100
}"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 80
}
}"
>
404
</p>
<p
v-motion
class="text-xl mb-4! text-gray-500"
:initial="{
opacity: 0,
y: 100
}"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 120
}
}"
>
抱歉你访问的页面不存在
</p>
<el-button
v-motion
type="primary"
class="block mx-auto md:inline-block md:mx-0"
:initial="{
opacity: 0,
y: 100
}"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 160
}
}"
@click="router.push('/')"
>
返回首页
</el-button>
</div>
</div>
</template>
<style scoped>
.main-content {
margin: 0 !important;
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import noServer from "@/assets/status/500.svg?component";
defineOptions({
name: "500"
});
const router = useRouter();
</script>
<template>
<div
class="flex flex-col md:flex-row justify-center items-center min-h-full w-full p-4 md:p-0"
>
<noServer />
<div class="mt-8 md:ml-12 md:mt-0 text-center md:text-left">
<p
v-motion
class="font-medium text-4xl mb-4! dark:text-white"
:initial="{
opacity: 0,
y: 100
}"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 80
}
}"
>
500
</p>
<p
v-motion
class="text-xl mb-4! text-gray-500"
:initial="{
opacity: 0,
y: 100
}"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 120
}
}"
>
抱歉服务器出错了
</p>
<el-button
v-motion
type="primary"
class="block mx-auto md:inline-block md:mx-0"
:initial="{
opacity: 0,
y: 100
}"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 160
}
}"
@click="router.push('/')"
>
返回首页
</el-button>
</div>
</div>
</template>
<style scoped>
.main-content {
margin: 0 !important;
}
</style>
+180
View File
@@ -0,0 +1,180 @@
<script setup lang="ts">
import Motion from "./utils/motion";
import { useRouter } from "vue-router";
import { message } from "@/utils/message";
import { loginRules } from "./utils/rule";
import { ref, reactive, toRaw } from "vue";
import { debounce } from "@pureadmin/utils";
import { useNav } from "@/layout/hooks/useNav";
import { useEventListener } from "@vueuse/core";
import type { FormInstance } from "element-plus";
import { useLayout } from "@/layout/hooks/useLayout";
import { useUserStoreHook } from "@/store/modules/user";
import { initRouter, getTopMenu } from "@/router/utils";
import { bg, avatar, illustration } from "./utils/static";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
import dayIcon from "@/assets/svg/day.svg?component";
import darkIcon from "@/assets/svg/dark.svg?component";
import Lock from "~icons/ri/lock-fill";
import User from "~icons/ri/user-3-fill";
defineOptions({
name: "Login"
});
const router = useRouter();
const loading = ref(false);
const disabled = ref(false);
const ruleFormRef = ref<FormInstance>();
const { initStorage } = useLayout();
initStorage();
const { dataTheme, overallStyle, dataThemeChange } = useDataThemeChange();
dataThemeChange(overallStyle.value);
const { title } = useNav();
const ruleForm = reactive({
username: "admin",
password: "admin123"
});
const onLogin = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
await formEl.validate(valid => {
if (valid) {
loading.value = true;
useUserStoreHook()
.loginByUsername({
username: ruleForm.username,
password: ruleForm.password
})
.then(res => {
if (res.success) {
// 获取后端路由
return initRouter().then(() => {
disabled.value = true;
router
.push(getTopMenu(true).path)
.then(() => {
message("登录成功", { type: "success" });
})
.finally(() => (disabled.value = false));
});
} else {
message("登录失败", { type: "error" });
}
})
.finally(() => (loading.value = false));
}
});
};
const immediateDebounce: any = debounce(
formRef => onLogin(formRef),
1000,
true
);
useEventListener(document, "keydown", ({ code }) => {
if (
["Enter", "NumpadEnter"].includes(code) &&
!disabled.value &&
!loading.value
)
immediateDebounce(ruleFormRef.value);
});
</script>
<template>
<div class="select-none">
<img :src="bg" class="wave" />
<div class="flex-c absolute right-5 top-3">
<!-- 主题 -->
<el-switch
v-model="dataTheme"
inline-prompt
:active-icon="dayIcon"
:inactive-icon="darkIcon"
@change="dataThemeChange"
/>
</div>
<div class="login-container">
<div class="img">
<component :is="toRaw(illustration)" />
</div>
<div class="login-box">
<div class="login-form">
<avatar class="avatar" />
<Motion>
<h2 class="outline-hidden">{{ title }}</h2>
</Motion>
<el-form
ref="ruleFormRef"
:model="ruleForm"
:rules="loginRules"
size="large"
>
<Motion :delay="100">
<el-form-item
:rules="[
{
required: true,
message: '请输入账号',
trigger: 'blur'
}
]"
prop="username"
>
<el-input
v-model="ruleForm.username"
clearable
placeholder="账号"
:prefix-icon="useRenderIcon(User)"
/>
</el-form-item>
</Motion>
<Motion :delay="150">
<el-form-item prop="password">
<el-input
v-model="ruleForm.password"
clearable
show-password
placeholder="密码"
:prefix-icon="useRenderIcon(Lock)"
/>
</el-form-item>
</Motion>
<Motion :delay="250">
<el-button
class="w-full mt-4!"
size="default"
type="primary"
:loading="loading"
:disabled="disabled"
@click="onLogin(ruleFormRef)"
>
登录
</el-button>
</Motion>
</el-form>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@import url("@/style/login.css");
</style>
<style lang="scss" scoped>
:deep(.el-input-group__append, .el-input-group__prepend) {
padding: 0;
}
</style>
+40
View File
@@ -0,0 +1,40 @@
import { h, defineComponent, withDirectives, resolveDirective } from "vue";
/** 封装@vueuse/motion动画库中的自定义指令v-motion */
export default defineComponent({
name: "Motion",
props: {
delay: {
type: Number,
default: 50
}
},
render() {
const { delay } = this;
const motion = resolveDirective("motion");
return withDirectives(
h(
"div",
{},
{
default: () => [this.$slots.default()]
}
),
[
[
motion,
{
initial: { opacity: 0, y: 100 },
enter: {
opacity: 1,
y: 0,
transition: {
delay
}
}
}
]
]
);
}
});
+28
View File
@@ -0,0 +1,28 @@
import { reactive } from "vue";
import type { FormRules } from "element-plus";
/** 密码正则(密码格式应为8-18位数字、字母、符号的任意两种组合) */
export const REGEXP_PWD =
/^(?![0-9]+$)(?![a-z]+$)(?![A-Z]+$)(?!([^(0-9a-zA-Z)]|[()])+$)(?!^.*[\u4E00-\u9FA5].*$)([^(0-9a-zA-Z)]|[()]|[a-z]|[A-Z]|[0-9]){8,18}$/;
/** 登录校验 */
const loginRules = reactive<FormRules>({
password: [
{
validator: (rule, value, callback) => {
if (value === "") {
callback(new Error("请输入密码"));
} else if (!REGEXP_PWD.test(value)) {
callback(
new Error("密码格式应为8-18位数字、字母、符号的任意两种组合")
);
} else {
callback();
}
},
trigger: "blur"
}
]
});
export { loginRules };
+5
View File
@@ -0,0 +1,5 @@
import bg from "@/assets/login/bg.png";
import avatar from "@/assets/login/avatar.svg?component";
import illustration from "@/assets/login/illustration.svg?component";
export { bg, avatar, illustration };
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { hasAuth, getAuths } from "@/router/utils";
defineOptions({
name: "PermissionButtonRouter"
});
</script>
<template>
<div>
<p class="mb-2!">当前拥有的code列表{{ getAuths() }}</p>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">组件方式判断权限</div>
</template>
<el-space wrap>
<Auth value="permission:btn:add">
<el-button plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
</Auth>
<Auth :value="['permission:btn:edit']">
<el-button plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
</Auth>
<Auth
:value="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
>
<el-button plain type="danger">
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</Auth>
</el-space>
</el-card>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">函数方式判断权限</div>
</template>
<el-space wrap>
<el-button v-if="hasAuth('permission:btn:add')" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button v-if="hasAuth(['permission:btn:edit'])" plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-if="
hasAuth([
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
])
"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
<el-card shadow="never">
<template #header>
<div class="card-header">
指令方式判断权限(该方式不能动态修改权限)
</div>
</template>
<el-space wrap>
<el-button v-auth="'permission:btn:add'" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button v-auth="['permission:btn:edit']" plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-auth="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
</div>
</template>
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { hasPerms } from "@/utils/auth";
import { useUserStoreHook } from "@/store/modules/user";
const { permissions } = useUserStoreHook();
defineOptions({
name: "PermissionButtonLogin"
});
</script>
<template>
<div>
<p class="mb-2!">当前拥有的code列表{{ permissions }}</p>
<p v-show="permissions?.[0] === '*:*:*'" class="mb-2!">
*:*:* 代表拥有全部按钮级别权限
</p>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">组件方式判断权限</div>
</template>
<el-space wrap>
<Perms value="permission:btn:add">
<el-button plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
</Perms>
<Perms :value="['permission:btn:edit']">
<el-button plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
</Perms>
<Perms
:value="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
>
<el-button plain type="danger">
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</Perms>
</el-space>
</el-card>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">函数方式判断权限</div>
</template>
<el-space wrap>
<el-button v-if="hasPerms('permission:btn:add')" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button
v-if="hasPerms(['permission:btn:edit'])"
plain
type="primary"
>
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-if="
hasPerms([
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
])
"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
<el-card shadow="never">
<template #header>
<div class="card-header">
指令方式判断权限(该方式不能动态修改权限)
</div>
</template>
<el-space wrap>
<el-button v-perms="'permission:btn:add'" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button v-perms="['permission:btn:edit']" plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-perms="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
</div>
</template>
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { initRouter } from "@/router/utils";
import { storageLocal } from "@pureadmin/utils";
import { type CSSProperties, ref, computed } from "vue";
import { useUserStoreHook } from "@/store/modules/user";
import { usePermissionStoreHook } from "@/store/modules/permission";
defineOptions({
name: "PermissionPage"
});
const elStyle = computed((): CSSProperties => {
return {
width: "85vw",
justifyContent: "start"
};
});
const username = ref(useUserStoreHook()?.username);
const options = [
{
value: "admin",
label: "管理员角色"
},
{
value: "common",
label: "普通角色"
}
];
function onChange() {
useUserStoreHook()
.loginByUsername({ username: username.value, password: "admin123" })
.then(res => {
if (res.success) {
storageLocal().removeItem("async-routes");
usePermissionStoreHook().clearAllCachePage();
initRouter();
}
});
}
</script>
<template>
<div>
<p class="mb-2!">
模拟后台根据不同角色返回对应路由观察左侧菜单变化管理员角色可查看系统管理菜单普通角色不可查看系统管理菜单
</p>
<el-card shadow="never" :style="elStyle">
<template #header>
<div class="card-header">
<span>当前角色{{ username }}</span>
</div>
</template>
<el-select v-model="username" class="w-[160px]!" @change="onChange">
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-card>
</div>
</template>
+505
View File
@@ -0,0 +1,505 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import {
ElMessage,
ElMessageBox,
type FormInstance,
type FormRules
} from "element-plus";
import {
createRole,
deleteRole,
listRoles,
updateRole,
type Role,
type RolePayload
} from "@/api/access";
import Plus from "~icons/ep/plus";
import Search from "~icons/ep/search";
import Refresh from "~icons/ep/refresh";
import EditPen from "~icons/ep/edit-pen";
import Delete from "~icons/ep/delete";
defineOptions({
name: "RoleManagement"
});
type RoleFormState = RolePayload & {
id?: number;
};
const codePattern = /^[a-z][a-z0-9_]*$/;
const tableLoading = ref(false);
const submitLoading = ref(false);
const dialogVisible = ref(false);
const formRef = ref<FormInstance>();
const roles = ref<Role[]>([]);
const query = reactive({
keyword: ""
});
const form = reactive<RoleFormState>({
code: "",
name: "",
description: ""
});
const rules: FormRules<RoleFormState> = {
code: [
{ required: true, message: "请输入角色编码", trigger: "blur" },
{
pattern: codePattern,
message: "角色编码只能使用小写字母、数字和下划线,并以字母开头",
trigger: "blur"
},
{ max: 50, message: "角色编码不能超过 50 个字符", trigger: "blur" }
],
name: [
{ required: true, message: "请输入角色名称", trigger: "blur" },
{ max: 50, message: "角色名称不能超过 50 个字符", trigger: "blur" }
],
description: [
{ max: 255, message: "角色说明不能超过 255 个字符", trigger: "blur" }
]
};
const filteredRoles = computed(() => {
const keyword = query.keyword.trim().toLowerCase();
if (keyword.length === 0) {
return roles.value;
}
return roles.value.filter(role => {
return (
role.code.toLowerCase().includes(keyword) ||
role.name.toLowerCase().includes(keyword) ||
(role.description ?? "").toLowerCase().includes(keyword)
);
});
});
const describedCount = computed(
() => roles.value.filter(item => Boolean(item.description)).length
);
const systemRoleCount = computed(
() => roles.value.filter(item => item.code === "admin").length
);
const dialogTitle = computed(() => (form.id ? "编辑角色" : "新增角色"));
function getErrorMessage(error: unknown, fallback: string) {
const message = (
error as { response?: { data?: { error?: { message?: string } } } }
)?.response?.data?.error?.message;
if (message) return message;
if (error instanceof Error && error.message) return error.message;
return fallback;
}
function formatTime(value: string) {
return new Date(value).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
function resetFormState() {
Object.assign(form, {
id: undefined,
code: "",
name: "",
description: ""
});
formRef.value?.clearValidate();
}
function buildPayload(): RolePayload {
const description = form.description?.trim();
return {
code: form.code.trim(),
name: form.name.trim(),
description: description ? description : null
};
}
async function fetchRoles() {
tableLoading.value = true;
try {
const result = await listRoles();
roles.value = result.data;
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载角色列表失败"));
} finally {
tableLoading.value = false;
}
}
function handleReset() {
query.keyword = "";
}
function handleSearch() {
query.keyword = query.keyword.trim();
}
function openCreateDialog() {
resetFormState();
dialogVisible.value = true;
}
function openEditDialog(row: Role) {
Object.assign(form, {
id: row.id,
code: row.code,
name: row.name,
description: row.description ?? ""
});
dialogVisible.value = true;
formRef.value?.clearValidate();
}
async function submitForm() {
await formRef.value?.validate();
submitLoading.value = true;
try {
const payload = buildPayload();
if (form.id) {
await updateRole(form.id, payload);
ElMessage.success("角色信息已更新");
} else {
await createRole(payload);
ElMessage.success("角色已创建");
}
dialogVisible.value = false;
fetchRoles();
} catch (error) {
ElMessage.error(getErrorMessage(error, "保存角色失败"));
} finally {
submitLoading.value = false;
}
}
async function removeRole(row: Role) {
try {
await ElMessageBox.confirm(
`删除后角色「${row.name}」无法再绑定员工,确认继续?`,
"删除角色",
{
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消"
}
);
await deleteRole(row.id);
ElMessage.success("角色已删除");
fetchRoles();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(getErrorMessage(error, "删除角色失败"));
}
}
}
onMounted(fetchRoles);
</script>
<template>
<section class="management-page">
<div class="page-heading">
<div>
<p class="eyebrow">权限角色基础数据</p>
<h1>角色管理</h1>
</div>
<el-button type="primary" :icon="Plus" @click="openCreateDialog">
新增角色
</el-button>
</div>
<div class="summary-strip">
<div class="summary-item">
<span>总角色</span>
<strong>{{ roles.length }}</strong>
</div>
<div class="summary-item">
<span>已配置说明</span>
<strong>{{ describedCount }}</strong>
</div>
<div class="summary-item">
<span>系统角色</span>
<strong>{{ systemRoleCount }}</strong>
</div>
<div class="summary-item">
<span>当前筛选</span>
<strong>{{ filteredRoles.length }}</strong>
</div>
</div>
<div class="toolbar">
<el-input
v-model="query.keyword"
clearable
class="keyword-input"
placeholder="搜索编码、名称或说明"
@keyup.enter="handleSearch"
/>
<div class="toolbar-actions">
<el-button type="primary" :icon="Search" @click="handleSearch">
查询
</el-button>
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
</div>
</div>
<div class="table-shell">
<el-table
v-loading="tableLoading"
:data="filteredRoles"
row-key="id"
stripe
class="management-table"
>
<el-table-column prop="name" label="角色" min-width="160">
<template #default="{ row }">
<div class="role-cell">
<strong>{{ row.name }}</strong>
<span>{{ row.code }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="260">
<template #default="{ row }">
<span>{{ row.description || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="更新时间" width="180">
<template #default="{ row }">
{{ formatTime(row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
:icon="EditPen"
@click="openEditDialog(row)"
>
编辑
</el-button>
<el-button
link
type="danger"
:icon="Delete"
@click="removeRole(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
destroy-on-close
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="management-form"
>
<div class="form-grid">
<el-form-item label="角色编码" prop="code">
<el-input
v-model="form.code"
maxlength="50"
placeholder="例如 store_manager"
/>
</el-form-item>
<el-form-item label="角色名称" prop="name">
<el-input v-model="form.name" maxlength="50" placeholder="请输入" />
</el-form-item>
</div>
<el-form-item label="角色说明" prop="description">
<el-input
v-model="form.description"
type="textarea"
maxlength="255"
show-word-limit
:rows="4"
placeholder="请输入"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">
保存
</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped lang="scss">
.management-page {
min-height: 100%;
padding: 20px;
background: #f6f8fb;
}
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
h1 {
margin: 4px 0 0;
font-size: 24px;
font-weight: 650;
color: #111827;
letter-spacing: 0;
}
}
.eyebrow {
margin: 0;
font-size: 13px;
color: #64748b;
}
.summary-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-item {
padding: 14px 16px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
span {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: #64748b;
}
strong {
font-size: 24px;
font-weight: 700;
color: #111827;
letter-spacing: 0;
}
}
.toolbar,
.table-shell {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 14px;
margin-bottom: 16px;
}
.keyword-input {
width: 280px;
}
.toolbar-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.table-shell {
padding: 8px 0 14px;
overflow: hidden;
}
.management-table {
width: 100%;
}
.role-cell {
display: flex;
flex-direction: column;
gap: 3px;
strong {
font-weight: 650;
color: #111827;
}
span {
font-size: 12px;
color: #64748b;
}
}
.management-form {
padding-top: 4px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 760px) {
.management-page {
padding: 12px;
}
.page-heading {
align-items: flex-start;
flex-direction: column;
}
.summary-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.keyword-input,
.toolbar-actions {
width: 100%;
margin-left: 0;
}
.toolbar-actions {
justify-content: flex-end;
}
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
+561
View File
@@ -0,0 +1,561 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import {
ElMessage,
ElMessageBox,
type FormInstance,
type FormRules
} from "element-plus";
import {
createStore,
deleteStore,
listStores,
updateStore,
type Store,
type StorePayload,
type StoreStatus
} from "@/api/access";
import Plus from "~icons/ep/plus";
import Search from "~icons/ep/search";
import Refresh from "~icons/ep/refresh";
import EditPen from "~icons/ep/edit-pen";
import Delete from "~icons/ep/delete";
import CircleCheck from "~icons/ep/circle-check";
import CircleClose from "~icons/ep/circle-close";
defineOptions({
name: "StoreManagement"
});
type StoreFormState = StorePayload & {
id?: number;
};
const tableLoading = ref(false);
const submitLoading = ref(false);
const dialogVisible = ref(false);
const formRef = ref<FormInstance>();
const stores = ref<Store[]>([]);
const query = reactive({
status: undefined as StoreStatus | undefined,
keyword: ""
});
const form = reactive<StoreFormState>({
name: "",
address: "",
phone: "",
status: "ACTIVE"
});
const rules: FormRules<StoreFormState> = {
name: [
{ required: true, message: "请输入门店名称", trigger: "blur" },
{ max: 100, message: "门店名称不能超过 100 个字符", trigger: "blur" }
],
address: [
{ max: 255, message: "门店地址不能超过 255 个字符", trigger: "blur" }
],
phone: [{ max: 30, message: "联系电话不能超过 30 个字符", trigger: "blur" }],
status: [{ required: true, message: "请选择门店状态", trigger: "change" }]
};
const filteredStores = computed(() => {
const keyword = query.keyword.trim().toLowerCase();
return stores.value.filter(store => {
const matchedStatus =
query.status === undefined || store.status === query.status;
const matchedKeyword =
keyword.length === 0 ||
store.name.toLowerCase().includes(keyword) ||
(store.address ?? "").toLowerCase().includes(keyword) ||
(store.phone ?? "").toLowerCase().includes(keyword);
return matchedStatus && matchedKeyword;
});
});
const activeCount = computed(
() => stores.value.filter(item => item.status === "ACTIVE").length
);
const inactiveCount = computed(
() => stores.value.filter(item => item.status === "INACTIVE").length
);
const dialogTitle = computed(() => (form.id ? "编辑门店" : "新增门店"));
function getErrorMessage(error: unknown, fallback: string) {
const message = (
error as { response?: { data?: { error?: { message?: string } } } }
)?.response?.data?.error?.message;
if (message) return message;
if (error instanceof Error && error.message) return error.message;
return fallback;
}
function formatTime(value: string) {
return new Date(value).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
function resetFormState() {
Object.assign(form, {
id: undefined,
name: "",
address: "",
phone: "",
status: "ACTIVE"
});
formRef.value?.clearValidate();
}
function buildPayload(): StorePayload {
const address = form.address?.trim();
const phone = form.phone?.trim();
return {
name: form.name.trim(),
address: address ? address : null,
phone: phone ? phone : null,
status: form.status
};
}
async function fetchStores() {
tableLoading.value = true;
try {
const result = await listStores({ includeInactive: true });
stores.value = result.data;
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载门店列表失败"));
} finally {
tableLoading.value = false;
}
}
function handleReset() {
query.status = undefined;
query.keyword = "";
}
function handleSearch() {
query.keyword = query.keyword.trim();
}
function openCreateDialog() {
resetFormState();
dialogVisible.value = true;
}
function openEditDialog(row: Store) {
Object.assign(form, {
id: row.id,
name: row.name,
address: row.address ?? "",
phone: row.phone ?? "",
status: row.status
});
dialogVisible.value = true;
formRef.value?.clearValidate();
}
async function submitForm() {
await formRef.value?.validate();
submitLoading.value = true;
try {
const payload = buildPayload();
if (form.id) {
await updateStore(form.id, payload);
ElMessage.success("门店信息已更新");
} else {
await createStore(payload);
ElMessage.success("门店已创建");
}
dialogVisible.value = false;
fetchStores();
} catch (error) {
ElMessage.error(getErrorMessage(error, "保存门店失败"));
} finally {
submitLoading.value = false;
}
}
async function toggleStatus(row: Store) {
const nextStatus: StoreStatus =
row.status === "ACTIVE" ? "INACTIVE" : "ACTIVE";
const action = nextStatus === "ACTIVE" ? "启用" : "停用";
try {
await ElMessageBox.confirm(
`确认${action}门店「${row.name}」?`,
"状态变更",
{
type: "warning",
confirmButtonText: action,
cancelButtonText: "取消"
}
);
await updateStore(row.id, { status: nextStatus });
ElMessage.success(`${action}`);
fetchStores();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(getErrorMessage(error, "状态变更失败"));
}
}
}
async function removeStore(row: Store) {
try {
await ElMessageBox.confirm(
`删除后门店「${row.name}」不会再出现在员工新增下拉里,确认继续?`,
"删除门店",
{
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消"
}
);
await deleteStore(row.id);
ElMessage.success("门店已删除");
fetchStores();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(getErrorMessage(error, "删除门店失败"));
}
}
}
onMounted(fetchStores);
</script>
<template>
<section class="management-page">
<div class="page-heading">
<div>
<p class="eyebrow">门店基础数据</p>
<h1>门店管理</h1>
</div>
<el-button type="primary" :icon="Plus" @click="openCreateDialog">
新增门店
</el-button>
</div>
<div class="summary-strip">
<div class="summary-item">
<span>总门店</span>
<strong>{{ stores.length }}</strong>
</div>
<div class="summary-item">
<span>启用门店</span>
<strong>{{ activeCount }}</strong>
</div>
<div class="summary-item">
<span>停用门店</span>
<strong>{{ inactiveCount }}</strong>
</div>
<div class="summary-item">
<span>当前筛选</span>
<strong>{{ filteredStores.length }}</strong>
</div>
</div>
<div class="toolbar">
<el-select
v-model="query.status"
clearable
placeholder="全部状态"
class="toolbar-control"
>
<el-option label="启用" value="ACTIVE" />
<el-option label="停用" value="INACTIVE" />
</el-select>
<el-input
v-model="query.keyword"
clearable
class="keyword-input"
placeholder="搜索门店、地址或电话"
@keyup.enter="handleSearch"
/>
<div class="toolbar-actions">
<el-button type="primary" :icon="Search" @click="handleSearch">
查询
</el-button>
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
</div>
</div>
<div class="table-shell">
<el-table
v-loading="tableLoading"
:data="filteredStores"
row-key="id"
stripe
class="management-table"
>
<el-table-column prop="name" label="门店" min-width="180">
<template #default="{ row }">
<strong class="primary-text">{{ row.name }}</strong>
</template>
</el-table-column>
<el-table-column prop="address" label="地址" min-width="240">
<template #default="{ row }">
<span>{{ row.address || "-" }}</span>
</template>
</el-table-column>
<el-table-column prop="phone" label="联系电话" min-width="150">
<template #default="{ row }">
<span>{{ row.phone || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag
:type="row.status === 'ACTIVE' ? 'success' : 'info'"
effect="light"
>
{{ row.status === "ACTIVE" ? "启用" : "停用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="更新时间" width="180">
<template #default="{ row }">
{{ formatTime(row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
:icon="EditPen"
@click="openEditDialog(row)"
>
编辑
</el-button>
<el-button
link
:type="row.status === 'ACTIVE' ? 'warning' : 'success'"
:icon="row.status === 'ACTIVE' ? CircleClose : CircleCheck"
@click="toggleStatus(row)"
>
{{ row.status === "ACTIVE" ? "停用" : "启用" }}
</el-button>
<el-button
link
type="danger"
:icon="Delete"
@click="removeStore(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
destroy-on-close
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="management-form"
>
<el-form-item label="门店名称" prop="name">
<el-input v-model="form.name" maxlength="100" placeholder="请输入" />
</el-form-item>
<div class="form-grid">
<el-form-item label="联系电话" prop="phone">
<el-input
v-model="form.phone"
maxlength="30"
placeholder="请输入"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio-button label="ACTIVE">启用</el-radio-button>
<el-radio-button label="INACTIVE">停用</el-radio-button>
</el-radio-group>
</el-form-item>
</div>
<el-form-item label="门店地址" prop="address">
<el-input
v-model="form.address"
type="textarea"
maxlength="255"
show-word-limit
:rows="4"
placeholder="请输入"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">
保存
</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped lang="scss">
.management-page {
min-height: 100%;
padding: 20px;
background: #f6f8fb;
}
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
h1 {
margin: 4px 0 0;
font-size: 24px;
font-weight: 650;
color: #111827;
letter-spacing: 0;
}
}
.eyebrow {
margin: 0;
font-size: 13px;
color: #64748b;
}
.summary-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-item {
padding: 14px 16px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
span {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: #64748b;
}
strong {
font-size: 24px;
font-weight: 700;
color: #111827;
letter-spacing: 0;
}
}
.toolbar,
.table-shell {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 14px;
margin-bottom: 16px;
}
.toolbar-control {
width: 180px;
}
.keyword-input {
width: 260px;
}
.toolbar-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.table-shell {
padding: 8px 0 14px;
overflow: hidden;
}
.management-table {
width: 100%;
}
.primary-text {
font-weight: 650;
color: #111827;
}
.management-form {
padding-top: 4px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 760px) {
.management-page {
padding: 12px;
}
.page-heading {
align-items: flex-start;
flex-direction: column;
}
.summary-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.toolbar-control,
.keyword-input,
.toolbar-actions {
width: 100%;
margin-left: 0;
}
.toolbar-actions {
justify-content: flex-end;
}
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
defineOptions({
name: "Welcome"
});
</script>
<template>
<h1>Pure-Admin-Thin非国际化版本</h1>
</template>