霖雨寺

This commit is contained in:
wenfp
2026-07-27 09:57:35 +08:00
commit 6e531cf7af
57 changed files with 7529 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
# 前端模块说明
## 技术栈
- Vue 3
- Vite
- vue-router
- Ant Design Vue
- VueQuill
## 模块边界
- `src/main.ts`:Vue 应用入口,注册路由和 Ant Design Vue。
- `src/config/app.ts`:前端应用配置,应用名默认“自在福田”,支持 `VITE_APP_NAME` 覆盖。
- `src/router/index.ts`:路由表,按后端功能模块划分。
- `src/layouts/AdminLayout.vue`:后台主布局。
- `src/views/login/LoginPage.vue`:登录页,包含用户名、密码、验证码和友好错误提示。
- `src/views/temples/TempleManagement.vue`:寺院管理页。
- `src/views/products/ProductManagement.vue`:商品管理列表页,按当前寺院隔离。
- `src/views/products/ProductDetail.vue`:商品新建和编辑页,按当前寺院隔离。
- `src/views/orders/OrderManagement.vue`:订单管理页,按当前寺院隔离。
- `src/views/rituals/RitualManagement.vue`:法事管理页,按当前寺院隔离。
- `src/views/users/UserManagement.vue`:用户管理页。
- `src/views/todos/TodoManagement.vue`Todo 模块占位页。
- `src/api/http.ts`:统一请求封装。
- `src/api/auth.ts`:登录认证、验证码、当前用户和退出登录接口封装。
- `src/api/users.ts`:用户接口封装。
- `src/api/temples.ts`:寺院接口封装。
- `src/api/products.ts`:商品接口封装。
- `src/api/uploads.ts`:图片上传接口封装。
- `src/api/orders.ts`:订单接口封装。
- `src/api/payments.ts`:微信支付接口封装。
- `src/api/rituals.ts`:法事接口封装。
## 路由约定
```text
/login 静态登录页
/temples 寺院管理
/products 商品管理
/products/create 新建商品
/products/:productId/edit 编辑商品
/orders 订单管理
/ritual-services 法事管理
/users 用户管理
/todos Todo 管理占位页
```
商品、订单、法事页面使用当前寺院上下文访问数据。当前寺院由后台顶部选择器维护,`src/api/http.ts` 会把 `currentTempleId` 写入请求头 `X-Temple-Id`
后台页面通过路由守卫检查登录状态;认证令牌只存在后端设置的 HttpOnly Cookie 中,前端 localStorage 只缓存当前用户展示信息,不保存 token。
## 用户管理页面
用户管理页字段与后端用户模型保持一致:
```text
注册时间、姓名、手机号、昵称、头像、是否管理员、总消费
```
注册时间只展示,不在表单中编辑。
## 商品管理页面
商品列表只展示:
```text
商品名称、简称、销量、首页展示、上架时间、开售时间、商品状态、修改删除操作
```
商品新建和编辑打开独立页面,路由 `meta.module` 仍保持 `products`,让商品管理菜单在详情页高亮。商品详情编辑包含基础信息、商品描述、时间与展示、商品规格、商品详情、微信分享配置。商品封面和商品图片使用上传组件,上传后保存 OSS 返回的图片 URL。商品规格在表格中行编辑保存;商品详情使用 VueQuill 富文本编辑器,保存 HTML 内容。
## 依赖约定
- 新增前端依赖时只写入 `package.json`,由用户执行 `pnpm install`
- `node_modules` 不提交。
## 验证
安装依赖后优先执行:
```bash
pnpm typecheck
pnpm build
```
## 修改记录
- 2026-07-22:用户管理页同步后端新用户模型,表格展示注册时间、姓名、手机号、昵称、头像、是否管理员、总消费,表单支持编辑除注册时间外的字段。验证:`pnpm typecheck``pnpm build` 通过。
- 2026-07-22:前端应用名改为配置化,默认“自在福田”;后台主布局新增路由面包屑,面包屑来源为路由 `meta.title`。验证:`pnpm typecheck``pnpm build` 通过,构建仅保留 Ant Design Vue 首包体积提示。
- 2026-07-22:用户管理页新增手机号查询栏,`src/api/users.ts` 支持向 `/users` 传递 `phone` 查询参数。验证:`pnpm typecheck``pnpm build` 通过。
- 2026-07-22:新增寺院、商品、订单、法事前端模块,后台布局新增模块菜单和当前寺院选择器,隔离模块请求自动携带 `X-Temple-Id`。验证:`pnpm typecheck``pnpm build` 通过。
- 2026-07-23:商品管理页同步新的商品详情模型;列表调整为商品名称、简称、销量、首页展示、上架时间、开售时间、商品状态和操作,编辑弹窗增加商品图片、时间展示、规格行编辑、详情编辑和微信分享配置。验证:`pnpm typecheck``pnpm build` 通过。
- 2026-07-23:商品新建和编辑从列表弹窗调整为独立页面,新增 `/products/create``/products/:productId/edit` 路由,详情页保持商品管理菜单高亮。验证:`pnpm typecheck``pnpm build` 通过。
- 2026-07-23:商品详情页将商品封面、商品图片改为上传组件,新增 `src/api/uploads.ts` 调用后端 `/uploads/images`,上传成功后保存 OSS 图片 URL。验证:`pnpm typecheck``pnpm build` 通过。
- 2026-07-23:商品详情编辑器从内置 `contenteditable` 替换为 VueQuill`package.json` 新增 `@vueup/vue-quill` 依赖并保存 HTML 内容。验证:待安装依赖后执行。
- 2026-07-23:接入登录功能,登录页支持用户名、密码、服务端验证码、账号自动聚焦、账号实时 trim、密码显示/隐藏、提交 loading 和友好错误提示;新增 `src/api/auth.ts` 与路由登录守卫,退出登录调用后端失效会话。验证:`pnpm typecheck``pnpm build` 通过。
- 2026-07-23:商品编辑基础信息新增商品描述字段,前端商品 API 类型和商品编辑表单同步 `description`。验证:`pnpm typecheck``pnpm build` 通过。
- 2026-07-23:订单管理页新增微信 JSAPI 预下单入口,新增 `src/api/payments.ts` 调用 `/payments/wechat/jsapi`,用于生成支付参数;实际微信调起仍需用户端页面和 openid。验证:`pnpm typecheck``pnpm build` 通过。
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>用户管理</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "py-web-front",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173",
"build": "vue-tsc -b && vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173",
"typecheck": "vue-tsc -b"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@vueup/vue-quill": "^1.5.5",
"ant-design-vue": "^4.2.6",
"vue": "^3.5.0",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@vitejs/plugin-vue": "^6.0.0",
"typescript": "^5.8.0",
"vite": "^7.0.0",
"vue-tsc": "^2.2.10"
}
}
+1263
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+73
View File
@@ -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',
});
}
+39
View File
@@ -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();
}
+46
View File
@@ -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',
});
}
+25
View File
@@ -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,
});
}
+102
View File
@@ -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',
});
}
+41
View File
@@ -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',
});
}
+42
View File
@@ -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',
});
}
+18
View File
@@ -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,
});
}
+57
View File
@@ -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',
});
}
+4
View File
@@ -0,0 +1,4 @@
export const appConfig = {
name: import.meta.env.VITE_APP_NAME || '自在福田',
subtitle: '管理后台',
};
+144
View File
@@ -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>
+13
View File
@@ -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');
+138
View File
@@ -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;
+279
View File
@@ -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;
}
}
+148
View File
@@ -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>
+342
View File
@@ -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>
+697
View File
@@ -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>
+24
View File
@@ -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>
+339
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.node.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true
}
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/http.ts","./src/api/orders.ts","./src/api/payments.ts","./src/api/products.ts","./src/api/rituals.ts","./src/api/temples.ts","./src/api/uploads.ts","./src/api/users.ts","./src/config/app.ts","./src/router/index.ts","./src/app.vue","./src/layouts/adminlayout.vue","./src/views/login/loginpage.vue","./src/views/orders/ordermanagement.vue","./src/views/products/productdetail.vue","./src/views/products/productmanagement.vue","./src/views/rituals/ritualmanagement.vue","./src/views/temples/templemanagement.vue","./src/views/todos/todomanagement.vue","./src/views/users/usermanagement.vue"],"version":"5.9.3"}
+26
View File
@@ -0,0 +1,26 @@
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const apiTarget = env.VITE_API_TARGET || 'http://127.0.0.1:8000';
return {
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
proxy: {
'/api': {
target: apiTarget,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
};
});