霖雨寺
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
import { request } from './http';
|
||||
|
||||
export const AUTH_USER_KEY = 'authUser';
|
||||
|
||||
export type AuthUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
is_admin: boolean;
|
||||
};
|
||||
|
||||
export type CaptchaResponse = {
|
||||
captcha_id: string;
|
||||
challenge: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
export type LoginPayload = {
|
||||
username: string;
|
||||
password: string;
|
||||
captcha_id?: string;
|
||||
captcha_code?: string;
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
expires_in: number;
|
||||
user: AuthUser;
|
||||
requires_second_verification: boolean;
|
||||
security_notice: string | null;
|
||||
};
|
||||
|
||||
export function getStoredUser() {
|
||||
const raw = localStorage.getItem(AUTH_USER_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as AuthUser;
|
||||
} catch {
|
||||
localStorage.removeItem(AUTH_USER_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function storeAuthUser(user: AuthUser) {
|
||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
export function clearAuthUser() {
|
||||
localStorage.removeItem(AUTH_USER_KEY);
|
||||
}
|
||||
|
||||
export function getCaptcha() {
|
||||
return request<CaptchaResponse>('/auth/captcha');
|
||||
}
|
||||
|
||||
export function login(payload: LoginPayload) {
|
||||
return request<LoginResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function getCurrentUser() {
|
||||
return request<AuthUser>('/auth/me');
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return request<void>('/auth/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
const API_BASE = '/api';
|
||||
export const CURRENT_TEMPLE_ID_KEY = 'currentTempleId';
|
||||
export const DEFAULT_TEMPLE_ID = '1';
|
||||
|
||||
type RequestOptions = RequestInit & {
|
||||
json?: unknown;
|
||||
};
|
||||
|
||||
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const currentTempleId = localStorage.getItem(CURRENT_TEMPLE_ID_KEY) || DEFAULT_TEMPLE_ID;
|
||||
if (currentTempleId && !headers.has('X-Temple-Id')) {
|
||||
headers.set('X-Temple-Id', currentTempleId);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
credentials: options.credentials || 'include',
|
||||
headers,
|
||||
body: options.json === undefined ? options.body : JSON.stringify(options.json),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.json().catch(() => null);
|
||||
const message = errorBody?.detail || `请求失败:${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { request } from './http';
|
||||
|
||||
export type Order = {
|
||||
id: number;
|
||||
temple_id: number;
|
||||
customer_name: string;
|
||||
customer_phone: string | null;
|
||||
order_type: 'product' | 'ritual';
|
||||
item_name: string;
|
||||
amount: number;
|
||||
status: 'pending' | 'paid' | 'completed' | 'cancelled';
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type OrderPayload = {
|
||||
customer_name: string;
|
||||
customer_phone?: string | null;
|
||||
order_type: 'product' | 'ritual';
|
||||
item_name: string;
|
||||
amount: number;
|
||||
status?: 'pending' | 'paid' | 'completed' | 'cancelled';
|
||||
};
|
||||
|
||||
export function listOrders() {
|
||||
return request<Order[]>('/orders');
|
||||
}
|
||||
|
||||
export function createOrder(payload: OrderPayload) {
|
||||
return request<Order>('/orders', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateOrder(id: number, payload: Partial<OrderPayload>) {
|
||||
return request<Order>(`/orders/${id}`, {
|
||||
method: 'PATCH',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteOrder(id: number) {
|
||||
return request<void>(`/orders/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { request } from './http';
|
||||
|
||||
export type WeChatJSAPIPayPayload = {
|
||||
order_id: number;
|
||||
openid: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type WeChatJSAPIPayParams = {
|
||||
appId: string;
|
||||
timeStamp: string;
|
||||
nonceStr: string;
|
||||
package: string;
|
||||
signType: string;
|
||||
paySign: string;
|
||||
out_trade_no: string;
|
||||
prepay_id: string;
|
||||
};
|
||||
|
||||
export function createWeChatJSAPIPayment(payload: WeChatJSAPIPayPayload) {
|
||||
return request<WeChatJSAPIPayParams>('/payments/wechat/jsapi', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { request } from './http';
|
||||
|
||||
export type Product = {
|
||||
id: number;
|
||||
temple_id: number;
|
||||
name: string;
|
||||
alias: string | null;
|
||||
description: string | null;
|
||||
payment_button_name: string;
|
||||
donation_button_name: string;
|
||||
sort_order: number;
|
||||
show_participant_count: boolean;
|
||||
cover_image: string | null;
|
||||
images: string[];
|
||||
listed_at: string | null;
|
||||
sale_starts_at: string | null;
|
||||
sale_ends_at: string | null;
|
||||
delisted_at: string | null;
|
||||
show_countdown: boolean;
|
||||
show_on_home: boolean;
|
||||
specs: ProductSpec[];
|
||||
details_html: string | null;
|
||||
share_title: string | null;
|
||||
share_description: string | null;
|
||||
share_image: string | null;
|
||||
merit_certificate_reserved: boolean;
|
||||
auto_process: boolean;
|
||||
is_active: boolean;
|
||||
sales_count: number;
|
||||
status: ProductStatus;
|
||||
};
|
||||
|
||||
export type ProductStatus =
|
||||
| 'delisted'
|
||||
| 'ended'
|
||||
| 'inactive'
|
||||
| 'pending_list'
|
||||
| 'pending_sale'
|
||||
| 'selling';
|
||||
|
||||
export type ProductSpec = {
|
||||
name: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
sort_order: number;
|
||||
is_chaodu: boolean;
|
||||
};
|
||||
|
||||
export type ProductPayload = {
|
||||
name: string;
|
||||
alias?: string | null;
|
||||
description?: string | null;
|
||||
payment_button_name?: string;
|
||||
donation_button_name?: string;
|
||||
sort_order?: number;
|
||||
show_participant_count?: boolean;
|
||||
cover_image?: string | null;
|
||||
images?: string[];
|
||||
listed_at?: string | null;
|
||||
sale_starts_at?: string | null;
|
||||
sale_ends_at?: string | null;
|
||||
delisted_at?: string | null;
|
||||
show_countdown?: boolean;
|
||||
show_on_home?: boolean;
|
||||
specs?: ProductSpec[];
|
||||
details_html?: string | null;
|
||||
share_title?: string | null;
|
||||
share_description?: string | null;
|
||||
share_image?: string | null;
|
||||
merit_certificate_reserved?: boolean;
|
||||
auto_process?: boolean;
|
||||
is_active?: boolean;
|
||||
sales_count?: number;
|
||||
};
|
||||
|
||||
export function listProducts() {
|
||||
return request<Product[]>('/products');
|
||||
}
|
||||
|
||||
export function getProduct(id: number) {
|
||||
return request<Product>(`/products/${id}`);
|
||||
}
|
||||
|
||||
export function createProduct(payload: ProductPayload) {
|
||||
return request<Product>('/products', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProduct(id: number, payload: Partial<ProductPayload>) {
|
||||
return request<Product>(`/products/${id}`, {
|
||||
method: 'PATCH',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteProduct(id: number) {
|
||||
return request<void>(`/products/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { request } from './http';
|
||||
|
||||
export type RitualService = {
|
||||
id: number;
|
||||
temple_id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
price: number;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type RitualServicePayload = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
price: number;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export function listRitualServices() {
|
||||
return request<RitualService[]>('/ritual-services');
|
||||
}
|
||||
|
||||
export function createRitualService(payload: RitualServicePayload) {
|
||||
return request<RitualService>('/ritual-services', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateRitualService(id: number, payload: Partial<RitualServicePayload>) {
|
||||
return request<RitualService>(`/ritual-services/${id}`, {
|
||||
method: 'PATCH',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteRitualService(id: number) {
|
||||
return request<void>(`/ritual-services/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { request } from './http';
|
||||
|
||||
export type Temple = {
|
||||
id: number;
|
||||
name: string;
|
||||
location: string | null;
|
||||
contact_phone: string | null;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type TemplePayload = {
|
||||
name: string;
|
||||
location?: string | null;
|
||||
contact_phone?: string | null;
|
||||
description?: string | null;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export function listTemples() {
|
||||
return request<Temple[]>('/temples');
|
||||
}
|
||||
|
||||
export function createTemple(payload: TemplePayload) {
|
||||
return request<Temple>('/temples', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTemple(id: number, payload: Partial<TemplePayload>) {
|
||||
return request<Temple>(`/temples/${id}`, {
|
||||
method: 'PATCH',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTemple(id: number) {
|
||||
return request<void>(`/temples/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { request } from './http';
|
||||
|
||||
export type UploadResponse = {
|
||||
url: string;
|
||||
object_key: string;
|
||||
};
|
||||
|
||||
export function uploadImage(file: Blob, folder = 'products') {
|
||||
const formData = new FormData();
|
||||
const filename = file instanceof File ? file.name : 'image';
|
||||
formData.append('file', file, filename);
|
||||
formData.append('folder', folder);
|
||||
|
||||
return request<UploadResponse>('/uploads/images', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { request } from './http';
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
registered_at: string;
|
||||
name: string;
|
||||
phone: string | null;
|
||||
nickname: string | null;
|
||||
avatar: string | null;
|
||||
is_admin: boolean;
|
||||
total_spent: number;
|
||||
};
|
||||
|
||||
export type CreateUserPayload = {
|
||||
name: string;
|
||||
phone: string;
|
||||
nickname?: string | null;
|
||||
avatar?: string | null;
|
||||
is_admin?: boolean;
|
||||
total_spent?: number;
|
||||
};
|
||||
|
||||
export type UpdateUserPayload = Partial<CreateUserPayload>;
|
||||
|
||||
export type ListUsersParams = {
|
||||
phone?: string;
|
||||
};
|
||||
|
||||
export function listUsers(params: ListUsersParams = {}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.phone) {
|
||||
searchParams.set('phone', params.phone);
|
||||
}
|
||||
|
||||
const query = searchParams.toString();
|
||||
return request<User[]>(query ? `/users?${query}` : '/users');
|
||||
}
|
||||
|
||||
export function createUser(payload: CreateUserPayload) {
|
||||
return request<User>('/users', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUser(id: number, payload: UpdateUserPayload) {
|
||||
return request<User>(`/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
json: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteUser(id: number) {
|
||||
return request<void>(`/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const appConfig = {
|
||||
name: import.meta.env.VITE_APP_NAME || '自在福田',
|
||||
subtitle: '管理后台',
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BankOutlined,
|
||||
FileTextOutlined,
|
||||
LoginOutlined,
|
||||
ProfileOutlined,
|
||||
ShoppingOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { clearAuthUser, logout } from '@/api/auth';
|
||||
import { appConfig } from '@/config/app';
|
||||
import { DEFAULT_TEMPLE_ID, CURRENT_TEMPLE_ID_KEY } from '@/api/http';
|
||||
import { listTemples, type Temple } from '@/api/temples';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const temples = ref<Temple[]>([]);
|
||||
const currentTempleId = ref(localStorage.getItem(CURRENT_TEMPLE_ID_KEY) || DEFAULT_TEMPLE_ID);
|
||||
|
||||
const selectedKeys = computed(() => {
|
||||
const moduleName = route.meta.module;
|
||||
return [typeof moduleName === 'string' ? moduleName : 'users'];
|
||||
});
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return route.matched
|
||||
.filter((matchedRoute) => matchedRoute.meta.title)
|
||||
.map((matchedRoute) => ({
|
||||
path: matchedRoute.path,
|
||||
title: String(matchedRoute.meta.title),
|
||||
}));
|
||||
});
|
||||
|
||||
const templeOptions = computed(() => {
|
||||
return temples.value.map((temple) => ({
|
||||
label: temple.name,
|
||||
value: String(temple.id),
|
||||
}));
|
||||
});
|
||||
|
||||
function openRoute(path: string) {
|
||||
router.push(path);
|
||||
}
|
||||
|
||||
function changeTemple(value: string) {
|
||||
localStorage.setItem(CURRENT_TEMPLE_ID_KEY, value);
|
||||
currentTempleId.value = value;
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function submitLogout() {
|
||||
await logout().catch(() => undefined);
|
||||
clearAuthUser();
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
async function fetchTemples() {
|
||||
temples.value = await listTemples();
|
||||
if (!localStorage.getItem(CURRENT_TEMPLE_ID_KEY) && temples.value.length > 0) {
|
||||
const firstTempleId = String(temples.value[0].id);
|
||||
localStorage.setItem(CURRENT_TEMPLE_ID_KEY, firstTempleId);
|
||||
currentTempleId.value = firstTempleId;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchTemples);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout class="app-shell">
|
||||
<a-layout-sider class="sidebar" :width="232">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">F</div>
|
||||
<div>
|
||||
<div class="brand-name">{{ appConfig.name }}</div>
|
||||
<div class="brand-subtitle">{{ appConfig.subtitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-menu
|
||||
class="side-menu"
|
||||
mode="inline"
|
||||
theme="light"
|
||||
:selected-keys="selectedKeys"
|
||||
>
|
||||
<a-menu-item key="temples" @click="openRoute('/temples')">
|
||||
<template #icon><BankOutlined /></template>
|
||||
寺院管理
|
||||
</a-menu-item>
|
||||
<a-menu-item key="products" @click="openRoute('/products')">
|
||||
<template #icon><ShoppingOutlined /></template>
|
||||
商品管理
|
||||
</a-menu-item>
|
||||
<a-menu-item key="orders" @click="openRoute('/orders')">
|
||||
<template #icon><FileTextOutlined /></template>
|
||||
订单管理
|
||||
</a-menu-item>
|
||||
<a-menu-item key="ritual-services" @click="openRoute('/ritual-services')">
|
||||
<template #icon><ProfileOutlined /></template>
|
||||
法事管理
|
||||
</a-menu-item>
|
||||
<a-menu-item key="users" @click="openRoute('/users')">
|
||||
<template #icon><TeamOutlined /></template>
|
||||
用户管理
|
||||
</a-menu-item>
|
||||
<a-menu-item key="todos" @click="openRoute('/todos')">
|
||||
<template #icon><ProfileOutlined /></template>
|
||||
Todo 管理
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout>
|
||||
<a-layout-header class="topbar">
|
||||
<div />
|
||||
<a-space>
|
||||
<a-select
|
||||
class="temple-selector"
|
||||
:options="templeOptions"
|
||||
:value="currentTempleId"
|
||||
placeholder="选择寺院"
|
||||
@change="changeTemple"
|
||||
/>
|
||||
<a-button type="link" @click="submitLogout">
|
||||
<template #icon><LoginOutlined /></template>
|
||||
退出登录
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-layout-header>
|
||||
|
||||
<a-layout-content class="content">
|
||||
<a-breadcrumb class="breadcrumb">
|
||||
<a-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
|
||||
{{ item.title }}
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
import Antd from 'ant-design-vue';
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
import './styles.css';
|
||||
|
||||
import App from './App.vue';
|
||||
import { appConfig } from './config/app';
|
||||
import router from './router';
|
||||
|
||||
document.title = appConfig.name;
|
||||
|
||||
createApp(App).use(router).use(Antd).mount('#app');
|
||||
@@ -0,0 +1,138 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
import { clearAuthUser, getCurrentUser, getStoredUser, storeAuthUser } from '@/api/auth';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import LoginPage from '@/views/login/LoginPage.vue';
|
||||
import OrderManagement from '@/views/orders/OrderManagement.vue';
|
||||
import ProductDetail from '@/views/products/ProductDetail.vue';
|
||||
import ProductManagement from '@/views/products/ProductManagement.vue';
|
||||
import RitualManagement from '@/views/rituals/RitualManagement.vue';
|
||||
import TempleManagement from '@/views/temples/TempleManagement.vue';
|
||||
import TodoManagement from '@/views/todos/TodoManagement.vue';
|
||||
import UserManagement from '@/views/users/UserManagement.vue';
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/users',
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginPage,
|
||||
meta: {
|
||||
title: '登录',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AdminLayout,
|
||||
meta: {
|
||||
title: '首页',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'temples',
|
||||
name: 'temples',
|
||||
component: TempleManagement,
|
||||
meta: {
|
||||
module: 'temples',
|
||||
title: '寺院管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'products',
|
||||
name: 'products',
|
||||
component: ProductManagement,
|
||||
meta: {
|
||||
module: 'products',
|
||||
title: '商品管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'products/create',
|
||||
name: 'product-create',
|
||||
component: ProductDetail,
|
||||
meta: {
|
||||
module: 'products',
|
||||
title: '新建商品',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'products/:productId/edit',
|
||||
name: 'product-edit',
|
||||
component: ProductDetail,
|
||||
meta: {
|
||||
module: 'products',
|
||||
title: '编辑商品',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'orders',
|
||||
component: OrderManagement,
|
||||
meta: {
|
||||
module: 'orders',
|
||||
title: '订单管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'ritual-services',
|
||||
name: 'ritual-services',
|
||||
component: RitualManagement,
|
||||
meta: {
|
||||
module: 'ritual-services',
|
||||
title: '法事管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'users',
|
||||
component: UserManagement,
|
||||
meta: {
|
||||
module: 'users',
|
||||
title: '用户管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'todos',
|
||||
name: 'todos',
|
||||
component: TodoManagement,
|
||||
meta: {
|
||||
module: 'todos',
|
||||
title: 'Todo 管理',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (to.name === 'login') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const storedUser = getStoredUser();
|
||||
if (storedUser) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
storeAuthUser(user);
|
||||
return true;
|
||||
} catch {
|
||||
clearAuthUser();
|
||||
return {
|
||||
name: 'login',
|
||||
query: {
|
||||
redirect: to.fullPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,279 @@
|
||||
:root {
|
||||
color: #1f2937;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
background: #f3f5f8;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #fff !important;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.brand {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 44px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
align-items: center;
|
||||
background: #1677ff;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
font-weight: 700;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.side-menu {
|
||||
background: transparent;
|
||||
border-inline-end: 0 !important;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: center;
|
||||
background: #fff !important;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
color: #1f2937;
|
||||
display: flex;
|
||||
height: 64px;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.temple-selector {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.page-heading h1 {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-heading p {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.query-bar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
align-items: center;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(22, 119, 255, 0.12), transparent 36%),
|
||||
#f3f5f8;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.08);
|
||||
margin: 0 auto;
|
||||
max-width: 420px;
|
||||
padding: 28px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.login-brand h1 {
|
||||
font-size: 22px;
|
||||
line-height: 30px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-brand p {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-options {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: -4px 0 18px;
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(0, 1fr) 132px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: #1677ff;
|
||||
font-size: 56px;
|
||||
}
|
||||
|
||||
.full-width-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.product-detail-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 142px);
|
||||
height: calc(100dvh - 142px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-detail-body,
|
||||
.product-detail-body .ant-spin-nested-loading,
|
||||
.product-detail-body .ant-spin-container,
|
||||
.product-detail-form,
|
||||
.product-detail-tabs {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.product-detail-tabs .ant-tabs-content-holder {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-detail-tabs .ant-tabs-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.product-detail-tabs .ant-tabs-tabpane {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.product-rich-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.product-rich-editor .ql-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.product-rich-editor .ql-editor {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-shell {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.page-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.product-rich-editor {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { LockOutlined, ReloadOutlined, SafetyCertificateOutlined, UserOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { getCaptcha, login, storeAuthUser } from '@/api/auth';
|
||||
import { appConfig } from '@/config/app';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const usernameInputRef = ref();
|
||||
const loading = ref(false);
|
||||
const captchaLoading = ref(false);
|
||||
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha_id: '',
|
||||
captcha_code: '',
|
||||
});
|
||||
|
||||
const captchaChallenge = ref('');
|
||||
|
||||
function trimUsername() {
|
||||
loginForm.username = loginForm.username.trim();
|
||||
}
|
||||
|
||||
async function refreshCaptcha() {
|
||||
captchaLoading.value = true;
|
||||
try {
|
||||
const captcha = await getCaptcha();
|
||||
loginForm.captcha_id = captcha.captcha_id;
|
||||
loginForm.captcha_code = '';
|
||||
captchaChallenge.value = captcha.challenge;
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '验证码加载失败,请刷新重试');
|
||||
} finally {
|
||||
captchaLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitLogin() {
|
||||
trimUsername();
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await login({
|
||||
captcha_code: loginForm.captcha_code.trim(),
|
||||
captcha_id: loginForm.captcha_id,
|
||||
password: loginForm.password,
|
||||
username: loginForm.username,
|
||||
});
|
||||
storeAuthUser(result.user);
|
||||
if (result.security_notice) {
|
||||
message.warning(result.security_notice);
|
||||
} else {
|
||||
message.success('登录成功');
|
||||
}
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/users';
|
||||
router.push(redirect);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '登录失败,请稍后重试');
|
||||
await refreshCaptcha();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshCaptcha();
|
||||
usernameInputRef.value?.focus?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<section class="login-panel">
|
||||
<div class="login-brand">
|
||||
<div class="brand-mark">F</div>
|
||||
<div>
|
||||
<h1>{{ appConfig.name }}</h1>
|
||||
<p>{{ appConfig.subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-form class="login-form" :model="loginForm" layout="vertical" @finish="submitLogin">
|
||||
<a-form-item
|
||||
label="用户名"
|
||||
name="username"
|
||||
:rules="[{ required: true, message: '请输入用户名' }]"
|
||||
>
|
||||
<a-input
|
||||
ref="usernameInputRef"
|
||||
v-model:value="loginForm.username"
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
placeholder="admin"
|
||||
size="large"
|
||||
@blur="trimUsername"
|
||||
@input="trimUsername"
|
||||
>
|
||||
<template #prefix><UserOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="密码"
|
||||
name="password"
|
||||
:rules="[{ required: true, message: '请输入密码' }]"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="loginForm.password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix><LockOutlined /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="验证码"
|
||||
name="captcha_code"
|
||||
:rules="[{ required: true, message: '请输入验证码' }]"
|
||||
>
|
||||
<div class="captcha-row">
|
||||
<a-input
|
||||
v-model:value="loginForm.captcha_code"
|
||||
autocomplete="off"
|
||||
placeholder="计算结果"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix><SafetyCertificateOutlined /></template>
|
||||
</a-input>
|
||||
<a-button :loading="captchaLoading" size="large" @click="refreshCaptcha">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
{{ captchaChallenge || '刷新' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-button block html-type="submit" :loading="loading" size="large" type="primary">
|
||||
登录
|
||||
</a-button>
|
||||
</a-form>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import type { Rule } from 'ant-design-vue/es/form';
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
createOrder,
|
||||
deleteOrder,
|
||||
listOrders,
|
||||
updateOrder,
|
||||
type Order,
|
||||
type OrderPayload,
|
||||
} from '@/api/orders';
|
||||
import { createWeChatJSAPIPayment } from '@/api/payments';
|
||||
|
||||
type OrderFormState = {
|
||||
id?: number;
|
||||
customer_name: string;
|
||||
customer_phone: string;
|
||||
order_type: 'product' | 'ritual';
|
||||
item_name: string;
|
||||
amount: number;
|
||||
status: 'pending' | 'paid' | 'completed' | 'cancelled';
|
||||
};
|
||||
|
||||
const orders = ref<Order[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const modalOpen = ref(false);
|
||||
const payModalOpen = ref(false);
|
||||
const formRef = ref();
|
||||
const payFormRef = ref();
|
||||
|
||||
const formState = reactive<OrderFormState>({
|
||||
customer_name: '',
|
||||
customer_phone: '',
|
||||
order_type: 'product',
|
||||
item_name: '',
|
||||
amount: 0,
|
||||
status: 'pending',
|
||||
});
|
||||
const payFormState = reactive({
|
||||
order_id: 0,
|
||||
item_name: '',
|
||||
openid: '',
|
||||
});
|
||||
|
||||
const isEditing = computed(() => formState.id !== undefined);
|
||||
const modalTitle = computed(() => (isEditing.value ? '编辑订单' : '新建订单'));
|
||||
|
||||
const columns: ColumnsType<Order> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 72 },
|
||||
{ title: '客户', dataIndex: 'customer_name', width: 120 },
|
||||
{ title: '手机号', dataIndex: 'customer_phone', width: 150 },
|
||||
{ title: '类型', dataIndex: 'order_type', width: 100 },
|
||||
{ title: '项目', dataIndex: 'item_name' },
|
||||
{ title: '金额', dataIndex: 'amount', width: 140 },
|
||||
{ title: '状态', dataIndex: 'status', width: 110 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 190 },
|
||||
{ title: '操作', key: 'actions', width: 140, fixed: 'right' },
|
||||
];
|
||||
|
||||
const rules: Record<string, Rule[]> = {
|
||||
customer_name: [{ required: true, message: '请输入客户姓名', trigger: 'blur' }],
|
||||
item_name: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
|
||||
amount: [{ type: 'number', min: 0, message: '金额不能小于 0', trigger: 'change' }],
|
||||
};
|
||||
|
||||
const orderTypeOptions = [
|
||||
{ label: '商品', value: 'product' },
|
||||
{ label: '法事', value: 'ritual' },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '待处理', value: 'pending' },
|
||||
{ label: '已支付', value: 'paid' },
|
||||
{ label: '已完成', value: 'completed' },
|
||||
{ label: '已取消', value: 'cancelled' },
|
||||
];
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
currency: 'CNY',
|
||||
style: 'currency',
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function getStatusColor(statusValue: Order['status']) {
|
||||
const colors = {
|
||||
cancelled: 'default',
|
||||
completed: 'green',
|
||||
paid: 'blue',
|
||||
pending: 'orange',
|
||||
};
|
||||
return colors[statusValue];
|
||||
}
|
||||
|
||||
function getStatusLabel(statusValue: Order['status']) {
|
||||
return statusOptions.find((item) => item.value === statusValue)?.label || statusValue;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formState.id = undefined;
|
||||
formState.customer_name = '';
|
||||
formState.customer_phone = '';
|
||||
formState.order_type = 'product';
|
||||
formState.item_name = '';
|
||||
formState.amount = 0;
|
||||
formState.status = 'pending';
|
||||
formRef.value?.clearValidate?.();
|
||||
}
|
||||
|
||||
async function fetchOrders() {
|
||||
loading.value = true;
|
||||
try {
|
||||
orders.value = await listOrders();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载订单失败,请先选择寺院');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
resetForm();
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditModal(order: Order) {
|
||||
formState.id = order.id;
|
||||
formState.customer_name = order.customer_name;
|
||||
formState.customer_phone = order.customer_phone || '';
|
||||
formState.order_type = order.order_type;
|
||||
formState.item_name = order.item_name;
|
||||
formState.amount = order.amount;
|
||||
formState.status = order.status;
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function openPayModal(order: Order) {
|
||||
payFormState.order_id = order.id;
|
||||
payFormState.item_name = order.item_name;
|
||||
payFormState.openid = '';
|
||||
payModalOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
await formRef.value?.validate?.();
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: OrderPayload = {
|
||||
customer_name: formState.customer_name,
|
||||
customer_phone: formState.customer_phone || null,
|
||||
order_type: formState.order_type,
|
||||
item_name: formState.item_name,
|
||||
amount: formState.amount,
|
||||
status: formState.status,
|
||||
};
|
||||
|
||||
if (isEditing.value && formState.id !== undefined) {
|
||||
await updateOrder(formState.id, payload);
|
||||
message.success('订单已更新');
|
||||
} else {
|
||||
await createOrder(payload);
|
||||
message.success('订单已创建');
|
||||
}
|
||||
|
||||
modalOpen.value = false;
|
||||
resetForm();
|
||||
await fetchOrders();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存订单失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPayForm() {
|
||||
await payFormRef.value?.validate?.();
|
||||
saving.value = true;
|
||||
try {
|
||||
const result = await createWeChatJSAPIPayment({
|
||||
description: payFormState.item_name,
|
||||
openid: payFormState.openid.trim(),
|
||||
order_id: payFormState.order_id,
|
||||
});
|
||||
message.success(`微信预下单成功:${result.prepay_id}`);
|
||||
payModalOpen.value = false;
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '微信预下单失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(order: Order) {
|
||||
Modal.confirm({
|
||||
title: '删除订单',
|
||||
content: `确认删除订单 ${order.item_name}?`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await deleteOrder(order.id);
|
||||
message.success('订单已删除');
|
||||
await fetchOrders();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(fetchOrders);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace">
|
||||
<div class="page-heading">
|
||||
<h1>订单管理</h1>
|
||||
<a-space>
|
||||
<a-button :loading="loading" @click="fetchOrders">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openCreateModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新建订单
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="orders"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 8, showSizeChanger: false }"
|
||||
:scroll="{ x: 1180 }"
|
||||
bordered
|
||||
row-key="id"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'customer_phone'">
|
||||
{{ record.customer_phone || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'order_type'">
|
||||
{{ record.order_type === 'product' ? '商品' : '法事' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'amount'">
|
||||
{{ formatMoney(record.amount) }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusLabel(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'created_at'">
|
||||
{{ formatDate(record.created_at) }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-tooltip title="编辑">
|
||||
<a-button type="text" @click="openEditModal(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-button v-if="record.status === 'pending'" type="link" @click="openPayModal(record)">
|
||||
微信支付
|
||||
</a-button>
|
||||
<a-tooltip title="删除">
|
||||
<a-button type="text" danger @click="confirmDelete(record)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalOpen"
|
||||
:confirm-loading="saving"
|
||||
:title="modalTitle"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="submitForm"
|
||||
>
|
||||
<a-form ref="formRef" :model="formState" :rules="rules" layout="vertical">
|
||||
<a-form-item label="客户姓名" name="customer_name">
|
||||
<a-input v-model:value="formState.customer_name" placeholder="张三" />
|
||||
</a-form-item>
|
||||
<a-form-item label="客户手机号" name="customer_phone">
|
||||
<a-input v-model:value="formState.customer_phone" placeholder="13800138000" />
|
||||
</a-form-item>
|
||||
<a-form-item label="订单类型" name="order_type">
|
||||
<a-select v-model:value="formState.order_type" :options="orderTypeOptions" />
|
||||
</a-form-item>
|
||||
<a-form-item label="项目名称" name="item_name">
|
||||
<a-input v-model:value="formState.item_name" placeholder="平安香" />
|
||||
</a-form-item>
|
||||
<a-form-item label="金额" name="amount">
|
||||
<a-input-number v-model:value="formState.amount" :min="0" :precision="2" addon-before="¥" class="full-width-control" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select v-model:value="formState.status" :options="statusOptions" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="payModalOpen"
|
||||
:confirm-loading="saving"
|
||||
title="微信 JSAPI 预下单"
|
||||
ok-text="生成支付参数"
|
||||
cancel-text="取消"
|
||||
@ok="submitPayForm"
|
||||
>
|
||||
<a-form ref="payFormRef" :model="payFormState" layout="vertical">
|
||||
<a-form-item label="项目名称">
|
||||
<a-input v-model:value="payFormState.item_name" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="用户 OpenID"
|
||||
name="openid"
|
||||
:rules="[{ required: true, message: '请输入微信用户 OpenID' }]"
|
||||
>
|
||||
<a-input v-model:value="payFormState.openid" placeholder="微信 JSAPI 支付需要用户 openid" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,697 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeftOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import type { Rule } from 'ant-design-vue/es/form';
|
||||
import type { UploadFile } from 'ant-design-vue/es/upload/interface';
|
||||
import type { UploadRequestOption } from 'ant-design-vue/es/vc-upload/interface';
|
||||
import { QuillEditor } from '@vueup/vue-quill';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import '@vueup/vue-quill/dist/vue-quill.snow.css';
|
||||
|
||||
import {
|
||||
createProduct,
|
||||
getProduct,
|
||||
updateProduct,
|
||||
type Product,
|
||||
type ProductPayload,
|
||||
type ProductSpec,
|
||||
} from '@/api/products';
|
||||
import { uploadImage, type UploadResponse } from '@/api/uploads';
|
||||
|
||||
type ProductFormState = {
|
||||
name: string;
|
||||
alias: string;
|
||||
description: string;
|
||||
payment_button_name: string;
|
||||
donation_button_name: string;
|
||||
sort_order: number;
|
||||
show_participant_count: boolean;
|
||||
cover_image: string;
|
||||
images: string[];
|
||||
listed_at: string;
|
||||
sale_starts_at: string;
|
||||
sale_ends_at: string;
|
||||
delisted_at: string;
|
||||
show_countdown: boolean;
|
||||
show_on_home: boolean;
|
||||
specs: ProductSpec[];
|
||||
details_html: string;
|
||||
share_title: string;
|
||||
share_description: string;
|
||||
share_image: string;
|
||||
merit_certificate_reserved: boolean;
|
||||
auto_process: boolean;
|
||||
is_active: boolean;
|
||||
sales_count: number;
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const formRef = ref();
|
||||
const editingSpecIndex = ref<number | null>(null);
|
||||
const coverImageFileList = ref<UploadFile[]>([]);
|
||||
const productImageFileList = ref<UploadFile[]>([]);
|
||||
const shareImageFileList = ref<UploadFile[]>([]);
|
||||
|
||||
const productId = computed(() => {
|
||||
const value = route.params.productId;
|
||||
const rawId = Array.isArray(value) ? value[0] : value;
|
||||
const id = Number(rawId);
|
||||
return Number.isFinite(id) && id > 0 ? id : null;
|
||||
});
|
||||
const isEditing = computed(() => productId.value !== null);
|
||||
const pageTitle = computed(() => (isEditing.value ? '编辑商品' : '新建商品'));
|
||||
|
||||
const formState = reactive<ProductFormState>({
|
||||
name: '',
|
||||
alias: '',
|
||||
description: '',
|
||||
payment_button_name: '立即支付',
|
||||
donation_button_name: '随喜',
|
||||
sort_order: 0,
|
||||
show_participant_count: false,
|
||||
cover_image: '',
|
||||
images: [],
|
||||
listed_at: '',
|
||||
sale_starts_at: '',
|
||||
sale_ends_at: '',
|
||||
delisted_at: '',
|
||||
show_countdown: false,
|
||||
show_on_home: false,
|
||||
specs: [],
|
||||
details_html: '',
|
||||
share_title: '',
|
||||
share_description: '',
|
||||
share_image: '',
|
||||
merit_certificate_reserved: false,
|
||||
auto_process: false,
|
||||
is_active: true,
|
||||
sales_count: 0,
|
||||
});
|
||||
|
||||
const specColumns = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '单价', dataIndex: 'price', width: 130 },
|
||||
{ title: '库存', dataIndex: 'stock', width: 120 },
|
||||
{ title: '排序', dataIndex: 'sort_order', width: 110 },
|
||||
{ title: '是否超度', dataIndex: 'is_chaodu', width: 110 },
|
||||
{ title: '操作', key: 'actions', width: 130 },
|
||||
];
|
||||
|
||||
const rules: Record<string, Rule[]> = {
|
||||
name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
|
||||
payment_button_name: [{ required: true, message: '请输入支付按钮名称', trigger: 'blur' }],
|
||||
donation_button_name: [{ required: true, message: '请输入随喜按钮名称', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
const dateTimeValueFormat = 'YYYY-MM-DDTHH:mm:ss';
|
||||
const dateTimeDisplayFormat = 'YYYY-MM-DD HH:mm';
|
||||
|
||||
function padDatePart(value: number) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatDateTimeValue(value: string | null) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return [
|
||||
date.getFullYear(),
|
||||
'-',
|
||||
padDatePart(date.getMonth() + 1),
|
||||
'-',
|
||||
padDatePart(date.getDate()),
|
||||
'T',
|
||||
padDatePart(date.getHours()),
|
||||
':',
|
||||
padDatePart(date.getMinutes()),
|
||||
':',
|
||||
padDatePart(date.getSeconds()),
|
||||
].join('');
|
||||
}
|
||||
|
||||
function toOptionalValue(value: string) {
|
||||
return value.trim() || null;
|
||||
}
|
||||
|
||||
function fillForm(product: Product) {
|
||||
formState.name = product.name;
|
||||
formState.alias = product.alias || '';
|
||||
formState.description = product.description || '';
|
||||
formState.payment_button_name = product.payment_button_name;
|
||||
formState.donation_button_name = product.donation_button_name;
|
||||
formState.sort_order = product.sort_order;
|
||||
formState.show_participant_count = product.show_participant_count;
|
||||
formState.cover_image = product.cover_image || '';
|
||||
formState.images = [...product.images];
|
||||
formState.listed_at = formatDateTimeValue(product.listed_at);
|
||||
formState.sale_starts_at = formatDateTimeValue(product.sale_starts_at);
|
||||
formState.sale_ends_at = formatDateTimeValue(product.sale_ends_at);
|
||||
formState.delisted_at = formatDateTimeValue(product.delisted_at);
|
||||
formState.show_countdown = product.show_countdown;
|
||||
formState.show_on_home = product.show_on_home;
|
||||
formState.specs = product.specs.map((spec) => ({ ...spec }));
|
||||
formState.details_html = product.details_html || '';
|
||||
formState.share_title = product.share_title || '';
|
||||
formState.share_description = product.share_description || '';
|
||||
formState.share_image = product.share_image || '';
|
||||
formState.merit_certificate_reserved = product.merit_certificate_reserved;
|
||||
formState.auto_process = product.auto_process;
|
||||
formState.is_active = product.is_active;
|
||||
formState.sales_count = product.sales_count;
|
||||
editingSpecIndex.value = null;
|
||||
syncUploadFileLists();
|
||||
}
|
||||
|
||||
async function fetchProductDetail() {
|
||||
if (!productId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const product = await getProduct(productId.value);
|
||||
fillForm(product);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载商品详情失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addSpec() {
|
||||
formState.specs.push({
|
||||
is_chaodu: false,
|
||||
name: '',
|
||||
price: 0,
|
||||
sort_order: formState.specs.length + 1,
|
||||
stock: 0,
|
||||
});
|
||||
editingSpecIndex.value = formState.specs.length - 1;
|
||||
}
|
||||
|
||||
function editSpec(index: number) {
|
||||
editingSpecIndex.value = index;
|
||||
}
|
||||
|
||||
function saveSpec() {
|
||||
editingSpecIndex.value = null;
|
||||
}
|
||||
|
||||
function deleteSpec(index: number) {
|
||||
formState.specs.splice(index, 1);
|
||||
editingSpecIndex.value = null;
|
||||
}
|
||||
|
||||
function getFileName(url: string) {
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
return pathname.split('/').filter(Boolean).pop() || 'image';
|
||||
} catch {
|
||||
return url.split('/').filter(Boolean).pop() || 'image';
|
||||
}
|
||||
}
|
||||
|
||||
function createUploadFile(url: string, index: number, prefix: string): UploadFile {
|
||||
return {
|
||||
uid: `${prefix}-${index}-${url}`,
|
||||
name: getFileName(url),
|
||||
status: 'done',
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
function syncUploadFileLists() {
|
||||
coverImageFileList.value = formState.cover_image
|
||||
? [createUploadFile(formState.cover_image, 0, 'cover')]
|
||||
: [];
|
||||
productImageFileList.value = formState.images.map((url, index) => createUploadFile(url, index, 'product'));
|
||||
shareImageFileList.value = formState.share_image
|
||||
? [createUploadFile(formState.share_image, 0, 'share')]
|
||||
: [];
|
||||
}
|
||||
|
||||
function getUploadFileUrl(file: UploadFile) {
|
||||
const response = file.response as UploadResponse | undefined;
|
||||
return file.url || response?.url || '';
|
||||
}
|
||||
|
||||
function replaceUploadingFile(fileUid: string, url: string, list: UploadFile[]) {
|
||||
const uploadedFile = createUploadFile(url, list.length, 'uploaded');
|
||||
uploadedFile.uid = fileUid;
|
||||
|
||||
const currentIndex = list.findIndex((item) => item.uid === fileUid);
|
||||
if (currentIndex === -1) {
|
||||
return [...list, uploadedFile];
|
||||
}
|
||||
|
||||
const next = [...list];
|
||||
next[currentIndex] = uploadedFile;
|
||||
return next;
|
||||
}
|
||||
|
||||
function getRequestFile(option: UploadRequestOption) {
|
||||
return option.file instanceof Blob ? option.file : null;
|
||||
}
|
||||
|
||||
function getRequestFileUid(option: UploadRequestOption) {
|
||||
return String((option.file as Blob & { uid?: string }).uid || Date.now());
|
||||
}
|
||||
|
||||
async function handleCoverUpload(option: UploadRequestOption) {
|
||||
const file = getRequestFile(option);
|
||||
if (!file) {
|
||||
message.error('请选择图片文件');
|
||||
option.onError?.(new Error('请选择图片文件'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await uploadImage(file, 'products/cover');
|
||||
formState.cover_image = result.url;
|
||||
coverImageFileList.value = replaceUploadingFile(getRequestFileUid(option), result.url, coverImageFileList.value).slice(-1);
|
||||
option.onSuccess?.(result);
|
||||
} catch (error) {
|
||||
const uploadError = error instanceof Error ? error : new Error('上传失败');
|
||||
message.error(uploadError.message);
|
||||
option.onError?.(uploadError);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProductImageUpload(option: UploadRequestOption) {
|
||||
const file = getRequestFile(option);
|
||||
if (!file) {
|
||||
message.error('请选择图片文件');
|
||||
option.onError?.(new Error('请选择图片文件'));
|
||||
return;
|
||||
}
|
||||
if (formState.images.length >= 10) {
|
||||
message.error('商品图片不能超过 10 张');
|
||||
option.onError?.(new Error('商品图片不能超过 10 张'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await uploadImage(file, 'products/images');
|
||||
formState.images = [...formState.images, result.url].slice(0, 10);
|
||||
productImageFileList.value = replaceUploadingFile(
|
||||
getRequestFileUid(option),
|
||||
result.url,
|
||||
productImageFileList.value,
|
||||
).slice(0, 10);
|
||||
option.onSuccess?.(result);
|
||||
} catch (error) {
|
||||
const uploadError = error instanceof Error ? error : new Error('上传失败');
|
||||
message.error(uploadError.message);
|
||||
option.onError?.(uploadError);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShareImageUpload(option: UploadRequestOption) {
|
||||
const file = getRequestFile(option);
|
||||
if (!file) {
|
||||
message.error('请选择图片文件');
|
||||
option.onError?.(new Error('请选择图片文件'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await uploadImage(file, 'products/share');
|
||||
formState.share_image = result.url;
|
||||
shareImageFileList.value = replaceUploadingFile(getRequestFileUid(option), result.url, shareImageFileList.value).slice(-1);
|
||||
option.onSuccess?.(result);
|
||||
} catch (error) {
|
||||
const uploadError = error instanceof Error ? error : new Error('上传失败');
|
||||
message.error(uploadError.message);
|
||||
option.onError?.(uploadError);
|
||||
}
|
||||
}
|
||||
|
||||
function removeCoverImage() {
|
||||
formState.cover_image = '';
|
||||
coverImageFileList.value = [];
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeProductImage(file: UploadFile) {
|
||||
const removedUrl = getUploadFileUrl(file);
|
||||
productImageFileList.value = productImageFileList.value.filter((item) => item.uid !== file.uid);
|
||||
formState.images = removedUrl
|
||||
? formState.images.filter((url) => url !== removedUrl)
|
||||
: productImageFileList.value.map(getUploadFileUrl).filter(Boolean);
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeShareImage() {
|
||||
formState.share_image = '';
|
||||
shareImageFileList.value = [];
|
||||
return true;
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
router.push('/products');
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
await formRef.value?.validate?.();
|
||||
|
||||
if (formState.images.length > 10) {
|
||||
message.error('商品图片不能超过 10 张');
|
||||
return;
|
||||
}
|
||||
if (formState.specs.some((spec) => !spec.name.trim())) {
|
||||
message.error('商品规格名称不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: ProductPayload = {
|
||||
alias: toOptionalValue(formState.alias),
|
||||
auto_process: formState.auto_process,
|
||||
cover_image: toOptionalValue(formState.cover_image),
|
||||
delisted_at: toOptionalValue(formState.delisted_at),
|
||||
description: toOptionalValue(formState.description),
|
||||
details_html: formState.details_html || null,
|
||||
donation_button_name: formState.donation_button_name,
|
||||
images: formState.images,
|
||||
is_active: formState.is_active,
|
||||
listed_at: toOptionalValue(formState.listed_at),
|
||||
merit_certificate_reserved: formState.merit_certificate_reserved,
|
||||
name: formState.name,
|
||||
payment_button_name: formState.payment_button_name,
|
||||
sale_ends_at: toOptionalValue(formState.sale_ends_at),
|
||||
sale_starts_at: toOptionalValue(formState.sale_starts_at),
|
||||
sales_count: formState.sales_count,
|
||||
share_description: toOptionalValue(formState.share_description),
|
||||
share_image: toOptionalValue(formState.share_image),
|
||||
share_title: toOptionalValue(formState.share_title),
|
||||
show_countdown: formState.show_countdown,
|
||||
show_on_home: formState.show_on_home,
|
||||
show_participant_count: formState.show_participant_count,
|
||||
sort_order: formState.sort_order,
|
||||
specs: formState.specs,
|
||||
};
|
||||
|
||||
if (isEditing.value && productId.value !== null) {
|
||||
await updateProduct(productId.value, payload);
|
||||
message.success('商品已更新');
|
||||
} else {
|
||||
await createProduct(payload);
|
||||
message.success('商品已创建');
|
||||
}
|
||||
|
||||
backToList();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存商品失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchProductDetail);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace product-detail-page">
|
||||
<div class="page-heading">
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
<a-space>
|
||||
<a-button @click="backToList">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
返回
|
||||
</a-button>
|
||||
<a-button type="primary" :loading="saving" @click="submitForm">
|
||||
<template #icon><SaveOutlined /></template>
|
||||
保存
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="product-detail-body">
|
||||
<a-spin :spinning="loading">
|
||||
<a-form ref="formRef" :model="formState" :rules="rules" class="product-detail-form" layout="vertical">
|
||||
<a-tabs class="product-detail-tabs">
|
||||
<a-tab-pane key="basic" tab="基础信息">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="商品名称" name="name">
|
||||
<a-input v-model:value="formState.name" placeholder="平安香" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="商品别称" name="alias">
|
||||
<a-input v-model:value="formState.alias" placeholder="平安" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="商品描述" name="description">
|
||||
<a-textarea
|
||||
v-model:value="formState.description"
|
||||
:maxlength="500"
|
||||
:rows="3"
|
||||
placeholder="用于简要说明商品用途、祈福内容或展示摘要"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="支付按钮名称" name="payment_button_name">
|
||||
<a-input v-model:value="formState.payment_button_name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="随喜按钮名称" name="donation_button_name">
|
||||
<a-input v-model:value="formState.donation_button_name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="排序号" name="sort_order">
|
||||
<a-input-number v-model:value="formState.sort_order" :min="0" class="full-width-control" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="销量" name="sales_count">
|
||||
<a-input-number v-model:value="formState.sales_count" :min="0" class="full-width-control" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="是否启用" name="is_active">
|
||||
<a-switch v-model:checked="formState.is_active" checked-children="启用" un-checked-children="停用" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="商品封面" name="cover_image">
|
||||
<a-upload
|
||||
v-model:file-list="coverImageFileList"
|
||||
accept="image/*"
|
||||
list-type="picture-card"
|
||||
:custom-request="handleCoverUpload"
|
||||
:max-count="1"
|
||||
@remove="removeCoverImage"
|
||||
>
|
||||
<div v-if="coverImageFileList.length < 1">
|
||||
<PlusOutlined />
|
||||
<div class="upload-text">上传封面</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="商品图片(不超过 10 张)" name="images">
|
||||
<a-upload
|
||||
v-model:file-list="productImageFileList"
|
||||
accept="image/*"
|
||||
list-type="picture-card"
|
||||
multiple
|
||||
:custom-request="handleProductImageUpload"
|
||||
:max-count="10"
|
||||
@remove="removeProductImage"
|
||||
>
|
||||
<div v-if="productImageFileList.length < 10">
|
||||
<PlusOutlined />
|
||||
<div class="upload-text">上传图片</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="time" tab="时间与展示">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="上架时间" name="listed_at">
|
||||
<a-date-picker
|
||||
v-model:value="formState.listed_at"
|
||||
allow-clear
|
||||
class="full-width-control"
|
||||
:format="dateTimeDisplayFormat"
|
||||
placeholder="选择上架时间"
|
||||
show-time
|
||||
:value-format="dateTimeValueFormat"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="开售时间" name="sale_starts_at">
|
||||
<a-date-picker
|
||||
v-model:value="formState.sale_starts_at"
|
||||
allow-clear
|
||||
class="full-width-control"
|
||||
:format="dateTimeDisplayFormat"
|
||||
placeholder="选择开售时间"
|
||||
show-time
|
||||
:value-format="dateTimeValueFormat"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="结束时间" name="sale_ends_at">
|
||||
<a-date-picker
|
||||
v-model:value="formState.sale_ends_at"
|
||||
allow-clear
|
||||
class="full-width-control"
|
||||
:format="dateTimeDisplayFormat"
|
||||
placeholder="选择结束时间"
|
||||
show-time
|
||||
:value-format="dateTimeValueFormat"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="下架时间" name="delisted_at">
|
||||
<a-date-picker
|
||||
v-model:value="formState.delisted_at"
|
||||
allow-clear
|
||||
class="full-width-control"
|
||||
:format="dateTimeDisplayFormat"
|
||||
placeholder="选择下架时间"
|
||||
show-time
|
||||
:value-format="dateTimeValueFormat"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="结束倒计时">
|
||||
<a-switch v-model:checked="formState.show_countdown" checked-children="显示" un-checked-children="隐藏" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="展示到首页">
|
||||
<a-switch v-model:checked="formState.show_on_home" checked-children="是" un-checked-children="否" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="分享参与人数">
|
||||
<a-switch v-model:checked="formState.show_participant_count" checked-children="显示" un-checked-children="隐藏" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="功德证书预留">
|
||||
<a-switch v-model:checked="formState.merit_certificate_reserved" checked-children="预留" un-checked-children="关闭" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="自动处理">
|
||||
<a-switch v-model:checked="formState.auto_process" checked-children="是" un-checked-children="否" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="specs" tab="商品规格">
|
||||
<div class="table-toolbar">
|
||||
<a-button type="primary" @click="addSpec">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加规格
|
||||
</a-button>
|
||||
</div>
|
||||
<a-table
|
||||
:columns="specColumns"
|
||||
:data-source="formState.specs"
|
||||
:pagination="false"
|
||||
bordered
|
||||
row-key="sort_order"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.dataIndex === 'name'">
|
||||
<a-input v-if="editingSpecIndex === index" v-model:value="record.name" />
|
||||
<span v-else>{{ record.name || '-' }}</span>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'price'">
|
||||
<a-input-number v-if="editingSpecIndex === index" v-model:value="record.price" :min="0" :precision="2" class="full-width-control" />
|
||||
<span v-else>{{ record.price }}</span>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'stock'">
|
||||
<a-input-number v-if="editingSpecIndex === index" v-model:value="record.stock" :min="0" class="full-width-control" />
|
||||
<span v-else>{{ record.stock }}</span>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'sort_order'">
|
||||
<a-input-number v-if="editingSpecIndex === index" v-model:value="record.sort_order" :min="0" class="full-width-control" />
|
||||
<span v-else>{{ record.sort_order }}</span>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'is_chaodu'">
|
||||
<a-switch v-if="editingSpecIndex === index" v-model:checked="record.is_chaodu" checked-children="是" un-checked-children="否" />
|
||||
<a-tag v-else :color="record.is_chaodu ? 'purple' : 'default'">
|
||||
{{ record.is_chaodu ? '是' : '否' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button v-if="editingSpecIndex === index" type="link" @click="saveSpec">保存</a-button>
|
||||
<a-button v-else type="link" @click="editSpec(index)">编辑</a-button>
|
||||
<a-button danger type="link" @click="deleteSpec(index)">删除</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="details" tab="商品详情">
|
||||
<QuillEditor
|
||||
v-model:content="formState.details_html"
|
||||
class="product-rich-editor"
|
||||
content-type="html"
|
||||
theme="snow"
|
||||
toolbar="full"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="share" tab="微信分享">
|
||||
<a-form-item label="分享标题" name="share_title">
|
||||
<a-input v-model:value="formState.share_title" />
|
||||
</a-form-item>
|
||||
<a-form-item label="分享描述" name="share_description">
|
||||
<a-textarea v-model:value="formState.share_description" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-form-item label="分享图片" name="share_image">
|
||||
<a-upload
|
||||
v-model:file-list="shareImageFileList"
|
||||
accept="image/*"
|
||||
list-type="picture-card"
|
||||
:custom-request="handleShareImageUpload"
|
||||
:max-count="1"
|
||||
@remove="removeShareImage"
|
||||
>
|
||||
<div v-if="shareImageFileList.length < 1">
|
||||
<PlusOutlined />
|
||||
<div class="upload-text">上传分享图</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
</a-form-item>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import {
|
||||
deleteProduct,
|
||||
listProducts,
|
||||
type Product,
|
||||
type ProductStatus,
|
||||
} from '@/api/products';
|
||||
|
||||
const router = useRouter();
|
||||
const products = ref<Product[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const columns: ColumnsType<Product> = [
|
||||
{ title: '商品名称', dataIndex: 'name', width: 180 },
|
||||
{ title: '简称', dataIndex: 'alias', width: 120 },
|
||||
{ title: '销量', dataIndex: 'sales_count', width: 100 },
|
||||
{ title: '首页展示', dataIndex: 'show_on_home', width: 110 },
|
||||
{ title: '上架时间', dataIndex: 'listed_at', width: 180 },
|
||||
{ title: '开售时间', dataIndex: 'sale_starts_at', width: 180 },
|
||||
{ title: '商品状态', dataIndex: 'status', width: 120 },
|
||||
{ title: '操作', key: 'actions', width: 140, fixed: 'right' },
|
||||
];
|
||||
|
||||
const statusLabels: Record<ProductStatus, string> = {
|
||||
delisted: '已下架',
|
||||
ended: '已结束',
|
||||
inactive: '未启用',
|
||||
pending_list: '待上架',
|
||||
pending_sale: '待开售',
|
||||
selling: '销售中',
|
||||
};
|
||||
|
||||
const statusColors: Record<ProductStatus, string> = {
|
||||
delisted: 'default',
|
||||
ended: 'default',
|
||||
inactive: 'default',
|
||||
pending_list: 'orange',
|
||||
pending_sale: 'blue',
|
||||
selling: 'green',
|
||||
};
|
||||
|
||||
function getStatusLabel(status: ProductStatus) {
|
||||
return statusLabels[status] || status;
|
||||
}
|
||||
|
||||
function getStatusColor(status: ProductStatus) {
|
||||
return statusColors[status] || 'default';
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
async function fetchProducts() {
|
||||
loading.value = true;
|
||||
try {
|
||||
products.value = await listProducts();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载商品失败,请先选择寺院');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreatePage() {
|
||||
router.push('/products/create');
|
||||
}
|
||||
|
||||
function openEditPage(product: Product) {
|
||||
router.push(`/products/${product.id}/edit`);
|
||||
}
|
||||
|
||||
function confirmDelete(product: Product) {
|
||||
Modal.confirm({
|
||||
title: '删除商品',
|
||||
content: `确认删除商品 ${product.name}?`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await deleteProduct(product.id);
|
||||
message.success('商品已删除');
|
||||
await fetchProducts();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(fetchProducts);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace">
|
||||
<div class="page-heading">
|
||||
<h1>商品管理</h1>
|
||||
<a-space>
|
||||
<a-button :loading="loading" @click="fetchProducts">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openCreatePage">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新建商品
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="products"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 8, showSizeChanger: false }"
|
||||
:scroll="{ x: 1120 }"
|
||||
bordered
|
||||
row-key="id"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'alias'">
|
||||
{{ record.alias || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'show_on_home'">
|
||||
<a-tag :color="record.show_on_home ? 'green' : 'default'">
|
||||
{{ record.show_on_home ? '展示' : '不展示' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'listed_at'">
|
||||
{{ formatDate(record.listed_at) }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'sale_starts_at'">
|
||||
{{ formatDate(record.sale_starts_at) }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusLabel(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-tooltip title="编辑">
|
||||
<a-button type="text" @click="openEditPage(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="删除">
|
||||
<a-button type="text" danger @click="confirmDelete(record)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import type { Rule } from 'ant-design-vue/es/form';
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
createRitualService,
|
||||
deleteRitualService,
|
||||
listRitualServices,
|
||||
updateRitualService,
|
||||
type RitualService,
|
||||
} from '@/api/rituals';
|
||||
|
||||
type RitualFormState = {
|
||||
id?: number;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
const rituals = ref<RitualService[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const modalOpen = ref(false);
|
||||
const formRef = ref();
|
||||
|
||||
const formState = reactive<RitualFormState>({
|
||||
name: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const isEditing = computed(() => formState.id !== undefined);
|
||||
const modalTitle = computed(() => (isEditing.value ? '编辑法事' : '新建法事'));
|
||||
|
||||
const columns: ColumnsType<RitualService> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 72 },
|
||||
{ title: '法事名称', dataIndex: 'name', width: 180 },
|
||||
{ title: '价格', dataIndex: 'price', width: 140 },
|
||||
{ title: '状态', dataIndex: 'is_active', width: 100 },
|
||||
{ title: '说明', dataIndex: 'description' },
|
||||
{ title: '操作', key: 'actions', width: 140, fixed: 'right' },
|
||||
];
|
||||
|
||||
const rules: Record<string, Rule[]> = {
|
||||
name: [{ required: true, message: '请输入法事名称', trigger: 'blur' }],
|
||||
price: [{ type: 'number', min: 0, message: '价格不能小于 0', trigger: 'change' }],
|
||||
};
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
currency: 'CNY',
|
||||
style: 'currency',
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formState.id = undefined;
|
||||
formState.name = '';
|
||||
formState.description = '';
|
||||
formState.price = 0;
|
||||
formState.is_active = true;
|
||||
formRef.value?.clearValidate?.();
|
||||
}
|
||||
|
||||
async function fetchRituals() {
|
||||
loading.value = true;
|
||||
try {
|
||||
rituals.value = await listRitualServices();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载法事失败,请先选择寺院');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
resetForm();
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditModal(ritual: RitualService) {
|
||||
formState.id = ritual.id;
|
||||
formState.name = ritual.name;
|
||||
formState.description = ritual.description || '';
|
||||
formState.price = ritual.price;
|
||||
formState.is_active = ritual.is_active;
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
await formRef.value?.validate?.();
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: formState.name,
|
||||
description: formState.description || null,
|
||||
price: formState.price,
|
||||
is_active: formState.is_active,
|
||||
};
|
||||
|
||||
if (isEditing.value && formState.id !== undefined) {
|
||||
await updateRitualService(formState.id, payload);
|
||||
message.success('法事已更新');
|
||||
} else {
|
||||
await createRitualService(payload);
|
||||
message.success('法事已创建');
|
||||
}
|
||||
|
||||
modalOpen.value = false;
|
||||
resetForm();
|
||||
await fetchRituals();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存法事失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(ritual: RitualService) {
|
||||
Modal.confirm({
|
||||
title: '删除法事',
|
||||
content: `确认删除法事 ${ritual.name}?`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await deleteRitualService(ritual.id);
|
||||
message.success('法事已删除');
|
||||
await fetchRituals();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(fetchRituals);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace">
|
||||
<div class="page-heading">
|
||||
<h1>法事管理</h1>
|
||||
<a-space>
|
||||
<a-button :loading="loading" @click="fetchRituals">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openCreateModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新建法事
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="rituals"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 8, showSizeChanger: false }"
|
||||
bordered
|
||||
row-key="id"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'price'">
|
||||
{{ formatMoney(record.price) }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'description'">
|
||||
{{ record.description || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'is_active'">
|
||||
<a-tag :color="record.is_active ? 'green' : 'default'">
|
||||
{{ record.is_active ? '启用' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-tooltip title="编辑">
|
||||
<a-button type="text" @click="openEditModal(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="删除">
|
||||
<a-button type="text" danger @click="confirmDelete(record)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalOpen"
|
||||
:confirm-loading="saving"
|
||||
:title="modalTitle"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="submitForm"
|
||||
>
|
||||
<a-form ref="formRef" :model="formState" :rules="rules" layout="vertical">
|
||||
<a-form-item label="法事名称" name="name">
|
||||
<a-input v-model:value="formState.name" placeholder="祈福法会" />
|
||||
</a-form-item>
|
||||
<a-form-item label="价格" name="price">
|
||||
<a-input-number v-model:value="formState.price" :min="0" :precision="2" addon-before="¥" class="full-width-control" />
|
||||
</a-form-item>
|
||||
<a-form-item label="说明" name="description">
|
||||
<a-textarea v-model:value="formState.description" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="is_active">
|
||||
<a-switch v-model:checked="formState.is_active" checked-children="启用" un-checked-children="停用" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import type { Rule } from 'ant-design-vue/es/form';
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
createTemple,
|
||||
deleteTemple,
|
||||
listTemples,
|
||||
updateTemple,
|
||||
type Temple,
|
||||
} from '@/api/temples';
|
||||
|
||||
type TempleFormState = {
|
||||
id?: number;
|
||||
name: string;
|
||||
location: string;
|
||||
contact_phone: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
const temples = ref<Temple[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const modalOpen = ref(false);
|
||||
const formRef = ref();
|
||||
|
||||
const formState = reactive<TempleFormState>({
|
||||
name: '',
|
||||
location: '',
|
||||
contact_phone: '',
|
||||
description: '',
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const isEditing = computed(() => formState.id !== undefined);
|
||||
const modalTitle = computed(() => (isEditing.value ? '编辑寺院' : '新建寺院'));
|
||||
|
||||
const columns: ColumnsType<Temple> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 72 },
|
||||
{ title: '寺院名称', dataIndex: 'name', width: 180 },
|
||||
{ title: '位置', dataIndex: 'location', width: 180 },
|
||||
{ title: '联系电话', dataIndex: 'contact_phone', width: 160 },
|
||||
{ title: '状态', dataIndex: 'is_active', width: 100 },
|
||||
{ title: '简介', dataIndex: 'description' },
|
||||
{ title: '操作', key: 'actions', width: 140, fixed: 'right' },
|
||||
];
|
||||
|
||||
const rules: Record<string, Rule[]> = {
|
||||
name: [{ required: true, message: '请输入寺院名称', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
function resetForm() {
|
||||
formState.id = undefined;
|
||||
formState.name = '';
|
||||
formState.location = '';
|
||||
formState.contact_phone = '';
|
||||
formState.description = '';
|
||||
formState.is_active = true;
|
||||
formRef.value?.clearValidate?.();
|
||||
}
|
||||
|
||||
async function fetchTemples() {
|
||||
loading.value = true;
|
||||
try {
|
||||
temples.value = await listTemples();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载寺院失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
resetForm();
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditModal(temple: Temple) {
|
||||
formState.id = temple.id;
|
||||
formState.name = temple.name;
|
||||
formState.location = temple.location || '';
|
||||
formState.contact_phone = temple.contact_phone || '';
|
||||
formState.description = temple.description || '';
|
||||
formState.is_active = temple.is_active;
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
await formRef.value?.validate?.();
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: formState.name,
|
||||
location: formState.location || null,
|
||||
contact_phone: formState.contact_phone || null,
|
||||
description: formState.description || null,
|
||||
is_active: formState.is_active,
|
||||
};
|
||||
|
||||
if (isEditing.value && formState.id !== undefined) {
|
||||
await updateTemple(formState.id, payload);
|
||||
message.success('寺院已更新');
|
||||
} else {
|
||||
await createTemple(payload);
|
||||
message.success('寺院已创建');
|
||||
}
|
||||
|
||||
modalOpen.value = false;
|
||||
resetForm();
|
||||
await fetchTemples();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存寺院失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(temple: Temple) {
|
||||
Modal.confirm({
|
||||
title: '删除寺院',
|
||||
content: `确认删除寺院 ${temple.name}?`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await deleteTemple(temple.id);
|
||||
message.success('寺院已删除');
|
||||
await fetchTemples();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(fetchTemples);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace">
|
||||
<div class="page-heading">
|
||||
<h1>寺院管理</h1>
|
||||
<a-space>
|
||||
<a-button :loading="loading" @click="fetchTemples">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openCreateModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新建寺院
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="temples"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 8, showSizeChanger: false }"
|
||||
:scroll="{ x: 1000 }"
|
||||
bordered
|
||||
row-key="id"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'is_active'">
|
||||
<a-tag :color="record.is_active ? 'green' : 'default'">
|
||||
{{ record.is_active ? '启用' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'location'">
|
||||
{{ record.location || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'contact_phone'">
|
||||
{{ record.contact_phone || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'description'">
|
||||
{{ record.description || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-tooltip title="编辑">
|
||||
<a-button type="text" @click="openEditModal(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="删除">
|
||||
<a-button type="text" danger @click="confirmDelete(record)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalOpen"
|
||||
:confirm-loading="saving"
|
||||
:title="modalTitle"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="submitForm"
|
||||
>
|
||||
<a-form ref="formRef" :model="formState" :rules="rules" layout="vertical">
|
||||
<a-form-item label="寺院名称" name="name">
|
||||
<a-input v-model:value="formState.name" placeholder="福田寺" />
|
||||
</a-form-item>
|
||||
<a-form-item label="位置" name="location">
|
||||
<a-input v-model:value="formState.location" placeholder="深圳福田" />
|
||||
</a-form-item>
|
||||
<a-form-item label="联系电话" name="contact_phone">
|
||||
<a-input v-model:value="formState.contact_phone" placeholder="0755-10000001" />
|
||||
</a-form-item>
|
||||
<a-form-item label="简介" name="description">
|
||||
<a-textarea v-model:value="formState.description" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="is_active">
|
||||
<a-switch v-model:checked="formState.is_active" checked-children="启用" un-checked-children="停用" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { ApiOutlined, ProfileOutlined } from '@ant-design/icons-vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h1>Todo 管理</h1>
|
||||
<p>对应后端 Todo 模块,接口已存在,页面后续接入</p>
|
||||
</div>
|
||||
<a-tag color="processing">
|
||||
<template #icon><ApiOutlined /></template>
|
||||
/todos
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<a-empty description="Todo 前端功能后续接入">
|
||||
<template #image>
|
||||
<ProfileOutlined class="empty-icon" />
|
||||
</template>
|
||||
</a-empty>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,339 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import type { Rule } from 'ant-design-vue/es/form';
|
||||
import type { ColumnsType } from 'ant-design-vue/es/table';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
listUsers,
|
||||
updateUser,
|
||||
type User,
|
||||
} from '@/api/users';
|
||||
|
||||
type UserFormState = {
|
||||
id?: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
is_admin: boolean;
|
||||
total_spent: number;
|
||||
};
|
||||
|
||||
const users = ref<User[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const modalOpen = ref(false);
|
||||
const formRef = ref();
|
||||
const searchPhone = ref('');
|
||||
|
||||
const formState = reactive<UserFormState>({
|
||||
name: '',
|
||||
phone: '',
|
||||
nickname: '',
|
||||
avatar: '',
|
||||
is_admin: false,
|
||||
total_spent: 0,
|
||||
});
|
||||
|
||||
const isEditing = computed(() => formState.id !== undefined);
|
||||
const modalTitle = computed(() => (isEditing.value ? '编辑用户' : '新建用户'));
|
||||
|
||||
const columns: ColumnsType<User> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 72 },
|
||||
{ title: '头像', dataIndex: 'avatar', width: 84 },
|
||||
{ title: '姓名', dataIndex: 'name', width: 140 },
|
||||
{ title: '手机号', dataIndex: 'phone', width: 160 },
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 140 },
|
||||
{ title: '管理员', dataIndex: 'is_admin', width: 110 },
|
||||
{ title: '总消费', dataIndex: 'total_spent', width: 140 },
|
||||
{ title: '注册时间', dataIndex: 'registered_at', width: 190 },
|
||||
{ title: '操作', key: 'actions', width: 140, fixed: 'right' },
|
||||
];
|
||||
|
||||
const rules: Record<string, Rule[]> = {
|
||||
name: [
|
||||
{ required: true, message: '请输入姓名', trigger: 'blur' },
|
||||
{ min: 1, max: 80, message: '姓名最多 80 个字符', trigger: 'blur' },
|
||||
],
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ min: 5, max: 20, message: '手机号长度为 5-20 个字符', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^[0-9+\-\s]+$/,
|
||||
message: '手机号只能包含数字、空格、+ 或 -',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
total_spent: [
|
||||
{ type: 'number', min: 0, message: '总消费不能小于 0', trigger: 'change' },
|
||||
],
|
||||
};
|
||||
|
||||
function resetForm() {
|
||||
formState.id = undefined;
|
||||
formState.name = '';
|
||||
formState.phone = '';
|
||||
formState.nickname = '';
|
||||
formState.avatar = '';
|
||||
formState.is_admin = false;
|
||||
formState.total_spent = 0;
|
||||
formRef.value?.clearValidate?.();
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
currency: 'CNY',
|
||||
style: 'currency',
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
users.value = await listUsers({
|
||||
phone: searchPhone.value.trim() || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载用户失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetSearch() {
|
||||
searchPhone.value = '';
|
||||
await fetchUsers();
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
resetForm();
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditModal(user: User) {
|
||||
formState.id = user.id;
|
||||
formState.name = user.name;
|
||||
formState.phone = user.phone || '';
|
||||
formState.nickname = user.nickname || '';
|
||||
formState.avatar = user.avatar || '';
|
||||
formState.is_admin = user.is_admin;
|
||||
formState.total_spent = user.total_spent;
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
await formRef.value?.validate?.();
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: formState.name,
|
||||
phone: formState.phone,
|
||||
nickname: formState.nickname || null,
|
||||
avatar: formState.avatar || null,
|
||||
is_admin: formState.is_admin,
|
||||
total_spent: formState.total_spent,
|
||||
};
|
||||
|
||||
if (isEditing.value && formState.id !== undefined) {
|
||||
await updateUser(formState.id, payload);
|
||||
message.success('用户已更新');
|
||||
} else {
|
||||
await createUser(payload);
|
||||
message.success('用户已创建');
|
||||
}
|
||||
|
||||
modalOpen.value = false;
|
||||
resetForm();
|
||||
await fetchUsers();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存用户失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(user: User) {
|
||||
Modal.confirm({
|
||||
title: '删除用户',
|
||||
content: `确认删除用户 ${user.name}?`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await deleteUser(user.id);
|
||||
message.success('用户已删除');
|
||||
await fetchUsers();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(fetchUsers);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h1>用户管理</h1>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button :loading="loading" @click="fetchUsers">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openCreateModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新建用户
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-form class="query-bar" layout="inline" @finish="fetchUsers">
|
||||
<a-form-item label="手机号">
|
||||
<a-input
|
||||
v-model:value="searchPhone"
|
||||
allow-clear
|
||||
placeholder="输入手机号查询"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit" :loading="loading">
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="resetSearch">
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="users"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 8, showSizeChanger: false }"
|
||||
:scroll="{ x: 1180 }"
|
||||
row-key="id"
|
||||
size="middle"
|
||||
bordered
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'avatar'">
|
||||
<a-avatar :src="record.avatar || undefined">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'phone'">
|
||||
{{ record.phone || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'nickname'">
|
||||
{{ record.nickname || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'is_admin'">
|
||||
<a-tag :color="record.is_admin ? 'gold' : 'default'">
|
||||
{{ record.is_admin ? '管理员' : '普通用户' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'total_spent'">
|
||||
{{ formatMoney(record.total_spent) }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'registered_at'">
|
||||
{{ formatDate(record.registered_at) }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-tooltip title="编辑">
|
||||
<a-button type="text" @click="openEditModal(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="删除">
|
||||
<a-button type="text" danger @click="confirmDelete(record)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalOpen"
|
||||
:confirm-loading="saving"
|
||||
:title="modalTitle"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="submitForm"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="姓名" name="name">
|
||||
<a-input v-model:value="formState.name" placeholder="张三" />
|
||||
</a-form-item>
|
||||
<a-form-item label="手机号" name="phone">
|
||||
<a-input v-model:value="formState.phone" placeholder="13800000000" />
|
||||
</a-form-item>
|
||||
<a-form-item label="昵称" name="nickname">
|
||||
<a-input v-model:value="formState.nickname" placeholder="小张" />
|
||||
</a-form-item>
|
||||
<a-form-item label="头像" name="avatar">
|
||||
<a-input v-model:value="formState.avatar" placeholder="https://example.com/avatar.png" />
|
||||
</a-form-item>
|
||||
<a-form-item label="总消费" name="total_spent">
|
||||
<a-input-number
|
||||
v-model:value="formState.total_spent"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="10"
|
||||
addon-before="¥"
|
||||
class="full-width-control"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="管理员" name="is_admin">
|
||||
<a-switch
|
||||
v-model:checked="formState.is_admin"
|
||||
checked-children="是"
|
||||
un-checked-children="否"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user