更新若干功能
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
This commit is contained in:
@@ -47,6 +47,7 @@
|
||||
"@vueuse/core": "catalog:",
|
||||
"ant-design-vue": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"exceljs": "^4.4.0",
|
||||
"jszip": "^3.10.1",
|
||||
"markdown-it": "^15.0.0",
|
||||
"pinia": "catalog:",
|
||||
|
||||
@@ -21,6 +21,9 @@ async function initSetupVbenForm() {
|
||||
Radio: 'checked',
|
||||
Switch: 'checked',
|
||||
Upload: 'fileList',
|
||||
// 自研上传组件用 v-model(modelValue),不跟 antd 的 value 走
|
||||
UploadImage: 'modelValue',
|
||||
UploadDoc: 'modelValue',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
|
||||
@@ -19,7 +19,9 @@ interface CreateGridOptions {
|
||||
* 列表页表格配置的公共部分。
|
||||
* 各模块的 table.ts 只写自己的列与请求,其余(勾选、工具栏、高度、代理)统一在这里。
|
||||
*/
|
||||
export function createGridOptions(options: CreateGridOptions): VxeGridProps<any> {
|
||||
export function createGridOptions(
|
||||
options: CreateGridOptions,
|
||||
): VxeGridProps<any> {
|
||||
const {
|
||||
columns,
|
||||
extra = {},
|
||||
@@ -56,12 +58,15 @@ export function createGridOptions(options: CreateGridOptions): VxeGridProps<any>
|
||||
showOverflow: false,
|
||||
toolbarConfig: {
|
||||
custom: { icon: 'vxe-icon-menu' },
|
||||
// 必须显式打开:适配器在找不到 #toolbar-actions 时会先写成 enabled=false,深合并后会把整栏关掉
|
||||
enabled: true,
|
||||
export: false,
|
||||
print: false,
|
||||
refresh: true,
|
||||
// @ts-ignore 适配器类型未声明 search
|
||||
// @ts-expect-error 适配器类型未声明 search
|
||||
search: true,
|
||||
slots: { buttons: 'toolbar-buttons' },
|
||||
// Vben 5.7 只认 toolbar-actions,页面插槽必须同名
|
||||
slots: { buttons: 'toolbar-actions' },
|
||||
zoom: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,10 +11,19 @@ export function toImageList(value?: null | string): string[] {
|
||||
return value ? [value] : [];
|
||||
}
|
||||
|
||||
/** 取图片数组第一项,空数组转空串 */
|
||||
export function firstImage(value?: null | string | string[]): string {
|
||||
/** 取图片数组第一项,空数组转空串;对象则取 url */
|
||||
export function firstImage(
|
||||
value?: null | Record<string, any> | string | string[],
|
||||
): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? '';
|
||||
const first = value[0];
|
||||
if (first && typeof first === 'object') {
|
||||
return String(first.url || first.uid || '');
|
||||
}
|
||||
return (first as string) ?? '';
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return String(value.url || '');
|
||||
}
|
||||
return value ?? '';
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ import { useCrudTable } from './use-crud-table';
|
||||
*/
|
||||
const props = defineProps<{
|
||||
crudApi: CrudApi;
|
||||
/** 名称列标题,如「分类名称」 */
|
||||
nameLabel: string;
|
||||
/** 页面与弹窗标题,如「工厂分类」 */
|
||||
moduleTitle: string;
|
||||
/** 名称列标题,如「分类名称」 */
|
||||
nameLabel: string;
|
||||
}>();
|
||||
|
||||
const gridOptions = createGridOptions({
|
||||
@@ -56,7 +56,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
|
||||
:name-label="nameLabel"
|
||||
/>
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
|
||||
@@ -1,72 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Modal, Upload } from 'ant-design-vue';
|
||||
import type { UploadFile } from 'ant-design-vue';
|
||||
|
||||
// 恢复使用原有的上传接口
|
||||
/**
|
||||
* 表单图片上传。Vben 默认绑 value,抽屉里常用 v-model,两边都要回写,
|
||||
* 否则套餐封面这种选填图会「框里有图、提交是空」。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Modal, Upload } from 'ant-design-vue';
|
||||
|
||||
import { uploadFile } from '#/api/core/upload';
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
// 定义接口
|
||||
interface FileItem {
|
||||
uid: string;
|
||||
name: string;
|
||||
status: 'uploading' | 'done' | 'error';
|
||||
status: 'done' | 'error' | 'uploading';
|
||||
url: string;
|
||||
}
|
||||
|
||||
// 定义props
|
||||
interface Props {
|
||||
modelValue: string[];
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
multiple: true,
|
||||
maxCount: 9,
|
||||
});
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
maxCount?: number;
|
||||
modelValue?: string[];
|
||||
multiple?: boolean;
|
||||
value?: string[];
|
||||
}>(),
|
||||
{
|
||||
maxCount: 9,
|
||||
modelValue: undefined,
|
||||
multiple: true,
|
||||
value: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]];
|
||||
'update:value': [value: string[]];
|
||||
}>();
|
||||
|
||||
const fileList = ref<FileItem[]>([]);
|
||||
|
||||
// 初始化fileList
|
||||
const initFileList = () => {
|
||||
fileList.value = props.modelValue.map((url) => ({
|
||||
/** 表单 value 与 v-model 任一有值就用,避免只听一边丢图 */
|
||||
function asUrls(input?: string[]) {
|
||||
return Array.isArray(input) ? input.filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function currentUrls() {
|
||||
const fromModel = asUrls(props.modelValue);
|
||||
return fromModel.length > 0 ? fromModel : asUrls(props.value);
|
||||
}
|
||||
|
||||
function toFileItems(urls: string[]) {
|
||||
return urls.map((url) => ({
|
||||
uid: url,
|
||||
name: url.split('/').pop() || 'file',
|
||||
status: 'done' as const,
|
||||
url,
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
// 初始化
|
||||
initFileList();
|
||||
fileList.value = toFileItems(currentUrls());
|
||||
|
||||
// 监听外部modelValue变化,同步到fileList
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
const currentUrls = fileList.value.filter(file => file.status === 'done').map(file => file.url);
|
||||
if (JSON.stringify(newVal) !== JSON.stringify(currentUrls)) {
|
||||
fileList.value = newVal.map((url) => ({
|
||||
uid: url,
|
||||
name: url.split('/').pop() || 'file',
|
||||
status: 'done' as const,
|
||||
url,
|
||||
}));
|
||||
}
|
||||
}, { deep: true });
|
||||
watch(
|
||||
() => [props.modelValue, props.value] as const,
|
||||
() => {
|
||||
const next = currentUrls();
|
||||
const shown = fileList.value
|
||||
.filter((file) => file.status === 'done')
|
||||
.map((file) => file.url);
|
||||
if (JSON.stringify(next) !== JSON.stringify(shown)) {
|
||||
fileList.value = toFileItems(next);
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// 统一通过updateModelValue函数更新modelValue
|
||||
const updateModelValue = () => {
|
||||
/** 同时回写 modelValue / value,Vben 表单和原生 v-model 都能接到 */
|
||||
function syncUrls() {
|
||||
const urls = fileList.value
|
||||
.filter((file) => file.status === 'done' && file.url)
|
||||
.map((file) => file.url);
|
||||
emit('update:modelValue', urls);
|
||||
};
|
||||
emit('update:value', urls);
|
||||
}
|
||||
|
||||
// 使用原有的uploadFile接口
|
||||
const customRequest = async (options: any) => {
|
||||
@@ -85,26 +102,26 @@ const customRequest = async (options: any) => {
|
||||
try {
|
||||
// 使用原有的uploadFile接口
|
||||
const res = await uploadFile({
|
||||
file: file,
|
||||
file,
|
||||
});
|
||||
const url =
|
||||
(typeof res === 'string' ? res : '') ||
|
||||
res?.url ||
|
||||
res?.result?.url ||
|
||||
'';
|
||||
|
||||
// 更新文件状态为完成
|
||||
const updatedFileList = fileList.value.map(item =>
|
||||
item.uid === file.uid
|
||||
? { ...item, status: 'done' as const, url: res.url }
|
||||
: item
|
||||
const updatedFileList = fileList.value.map((item) =>
|
||||
item.uid === file.uid ? { ...item, status: 'done' as const, url } : item,
|
||||
);
|
||||
|
||||
fileList.value = updatedFileList;
|
||||
updateModelValue();
|
||||
syncUrls();
|
||||
onSuccess(res);
|
||||
} catch (error) {
|
||||
console.error('上传失败', error);
|
||||
// 更新文件状态为错误
|
||||
const updatedFileList = fileList.value.map(item =>
|
||||
item.uid === file.uid
|
||||
? { ...item, status: 'error' as const }
|
||||
: item
|
||||
const updatedFileList = fileList.value.map((item) =>
|
||||
item.uid === file.uid ? { ...item, status: 'error' as const } : item,
|
||||
);
|
||||
|
||||
fileList.value = updatedFileList;
|
||||
@@ -112,10 +129,9 @@ const customRequest = async (options: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 处理文件删除
|
||||
const handleRemove = (file: UploadFile) => {
|
||||
fileList.value = fileList.value.filter((item) => item.uid !== file.uid);
|
||||
updateModelValue();
|
||||
syncUrls();
|
||||
};
|
||||
|
||||
// 预览功能
|
||||
|
||||
369
apps/web-antd/src/util/excel.ts
Normal file
369
apps/web-antd/src/util/excel.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import type { Borders, Cell, Workbook, Worksheet } from 'exceljs';
|
||||
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
/**
|
||||
* 后台 Excel 导入导出(ExcelJS)
|
||||
*
|
||||
* 后端只给结构化数据,xlsx 在浏览器里画:标题、表头、斑马纹、边框、冻结窗格。
|
||||
* 导入也在前端拆成行再 POST rows,避免 PHP 再引一套表格库。
|
||||
*/
|
||||
|
||||
export interface ExcelColumn {
|
||||
align?: 'center' | 'left' | 'right';
|
||||
header: string;
|
||||
key: string;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export interface ExcelSheetPayload {
|
||||
columns: ExcelColumn[];
|
||||
rows: Record<string, any>[];
|
||||
sheet?: string;
|
||||
/** 底部合计行,key 与 columns 对齐 */
|
||||
subtitle?: string;
|
||||
summary?: Record<string, any>;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface ExcelExportPayload extends ExcelSheetPayload {
|
||||
filename?: string;
|
||||
/** 多工作表:商品导出时带报价单 */
|
||||
sheets?: ExcelSheetPayload[];
|
||||
}
|
||||
|
||||
/** 家具后台暖金色,跟小程序主题接近 */
|
||||
const THEME = {
|
||||
border: 'E5D9C8',
|
||||
headerBg: 'B08D57',
|
||||
headerFg: 'FFFFFF',
|
||||
stripe: 'F7F1E8',
|
||||
summaryBg: 'F3E6D0',
|
||||
text: '1C1917',
|
||||
titleBg: '1C1917',
|
||||
titleFg: 'F8F4EE',
|
||||
};
|
||||
|
||||
const THIN_BORDER: Partial<Borders> = {
|
||||
bottom: { color: { argb: `FF${THEME.border}` }, style: 'thin' },
|
||||
left: { color: { argb: `FF${THEME.border}` }, style: 'thin' },
|
||||
right: { color: { argb: `FF${THEME.border}` }, style: 'thin' },
|
||||
top: { color: { argb: `FF${THEME.border}` }, style: 'thin' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 把接口返回的表格打成带样式的 xlsx 并下载(支持多工作表)
|
||||
*/
|
||||
export async function downloadStyledExcel(payload: ExcelExportPayload) {
|
||||
const sheets = normalizeSheets(payload);
|
||||
if (sheets.length === 0) {
|
||||
throw new Error('没有可导出的列');
|
||||
}
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'LGP Admin';
|
||||
workbook.created = new Date();
|
||||
sheets.forEach((item) => paintSheet(workbook, item));
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
const blob = new Blob([new Uint8Array(buffer as ArrayBuffer)], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
});
|
||||
const name = toXlsxName(payload.filename || sheets[0]?.title || '导出');
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读整个工作簿:每个工作表拆成行,跳过标题/副标题
|
||||
*/
|
||||
export async function parseExcelWorkbook(
|
||||
file: File,
|
||||
): Promise<{ name: string; rows: Record<string, any>[] }[]> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const buffer = await file.arrayBuffer();
|
||||
await workbook.xlsx.load(buffer);
|
||||
if (workbook.worksheets.length === 0) {
|
||||
throw new Error('表格是空的');
|
||||
}
|
||||
return workbook.worksheets.map((sheet) => ({
|
||||
name: sheet.name,
|
||||
rows: rowsFromSheet(sheet),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读 xlsx:默认取第一张表(工厂等单表导入)
|
||||
*/
|
||||
export async function parseExcelFile(
|
||||
file: File,
|
||||
): Promise<Record<string, any>[]> {
|
||||
const book = await parseExcelWorkbook(file);
|
||||
return book[0]?.rows ?? [];
|
||||
}
|
||||
|
||||
/** 按工作表名取行,名称对不上就返回空 */
|
||||
export function pickExcelSheet(
|
||||
book: { name: string; rows: Record<string, any>[] }[],
|
||||
names: string[],
|
||||
): Record<string, any>[] {
|
||||
const hit = book.find((item) => names.includes(item.name));
|
||||
return hit?.rows ?? [];
|
||||
}
|
||||
|
||||
/** 合并单元格时把整行底色铺满,避免只第一格有色 */
|
||||
function normalizeSheets(payload: ExcelExportPayload): ExcelSheetPayload[] {
|
||||
if (payload.sheets && payload.sheets.length > 0) {
|
||||
return payload.sheets.filter((item) => (item.columns ?? []).length > 0);
|
||||
}
|
||||
if ((payload.columns ?? []).length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
columns: payload.columns,
|
||||
rows: payload.rows ?? [],
|
||||
sheet: payload.sheet,
|
||||
subtitle: payload.subtitle,
|
||||
summary: payload.summary,
|
||||
title: payload.title,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 画一张带标题、表头、斑马纹的工作表 */
|
||||
function paintSheet(workbook: Workbook, payload: ExcelSheetPayload) {
|
||||
const columns = payload.columns ?? [];
|
||||
const rows = payload.rows ?? [];
|
||||
const sheet = workbook.addWorksheet(payload.sheet || 'Sheet1', {
|
||||
views: [{ showGridLines: false, state: 'frozen', ySplit: 3 }],
|
||||
});
|
||||
sheet.columns = columns.map((col) => ({
|
||||
key: col.key,
|
||||
width: col.width ?? Math.max(12, Math.min(36, col.header.length * 2 + 6)),
|
||||
}));
|
||||
|
||||
const lastCol = columns.length;
|
||||
const title = payload.title || payload.sheet || '导出';
|
||||
sheet.mergeCells(1, 1, 1, lastCol);
|
||||
const titleCell = sheet.getCell(1, 1);
|
||||
titleCell.value = title;
|
||||
titleCell.font = {
|
||||
bold: true,
|
||||
color: { argb: `FF${THEME.titleFg}` },
|
||||
name: 'Microsoft YaHei',
|
||||
size: 16,
|
||||
};
|
||||
titleCell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
titleCell.fill = {
|
||||
fgColor: { argb: `FF${THEME.titleBg}` },
|
||||
pattern: 'solid',
|
||||
type: 'pattern',
|
||||
};
|
||||
fillRow(sheet, 1, lastCol, `FF${THEME.titleBg}`);
|
||||
sheet.getRow(1).height = 32;
|
||||
|
||||
const subtitle =
|
||||
payload.subtitle || `导出时间 ${formatNow()} 共 ${rows.length} 条`;
|
||||
sheet.mergeCells(2, 1, 2, lastCol);
|
||||
const subCell = sheet.getCell(2, 1);
|
||||
subCell.value = subtitle;
|
||||
subCell.font = {
|
||||
color: { argb: 'FF78716C' },
|
||||
name: 'Microsoft YaHei',
|
||||
size: 10,
|
||||
};
|
||||
subCell.alignment = { horizontal: 'left', vertical: 'middle' };
|
||||
subCell.fill = {
|
||||
fgColor: { argb: 'FFF8F4EE' },
|
||||
pattern: 'solid',
|
||||
type: 'pattern',
|
||||
};
|
||||
fillRow(sheet, 2, lastCol, 'FFF8F4EE');
|
||||
sheet.getRow(2).height = 20;
|
||||
|
||||
const headerRow = sheet.getRow(3);
|
||||
headerRow.height = 24;
|
||||
columns.forEach((col, index) => {
|
||||
const cell = headerRow.getCell(index + 1);
|
||||
cell.value = col.header;
|
||||
cell.font = {
|
||||
bold: true,
|
||||
color: { argb: `FF${THEME.headerFg}` },
|
||||
name: 'Microsoft YaHei',
|
||||
size: 11,
|
||||
};
|
||||
cell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
cell.fill = {
|
||||
fgColor: { argb: `FF${THEME.headerBg}` },
|
||||
pattern: 'solid',
|
||||
type: 'pattern',
|
||||
};
|
||||
cell.border = THIN_BORDER;
|
||||
});
|
||||
|
||||
rows.forEach((row, rowIndex) => {
|
||||
const excelRow = sheet.getRow(4 + rowIndex);
|
||||
excelRow.height = 20;
|
||||
columns.forEach((col, index) => {
|
||||
const cell = excelRow.getCell(index + 1);
|
||||
cell.value = normalizeCell(row[col.key]);
|
||||
cell.font = {
|
||||
color: { argb: `FF${THEME.text}` },
|
||||
name: 'Microsoft YaHei',
|
||||
size: 10,
|
||||
};
|
||||
cell.alignment = {
|
||||
horizontal: col.align ?? 'left',
|
||||
vertical: 'middle',
|
||||
wrapText: true,
|
||||
};
|
||||
cell.border = THIN_BORDER;
|
||||
if (rowIndex % 2 === 1) {
|
||||
cell.fill = {
|
||||
fgColor: { argb: `FF${THEME.stripe}` },
|
||||
pattern: 'solid',
|
||||
type: 'pattern',
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (payload.summary) {
|
||||
const summaryRow = sheet.getRow(4 + rows.length);
|
||||
summaryRow.height = 22;
|
||||
columns.forEach((col, index) => {
|
||||
const cell = summaryRow.getCell(index + 1);
|
||||
cell.value = normalizeCell(payload.summary?.[col.key]);
|
||||
cell.font = {
|
||||
bold: true,
|
||||
color: { argb: `FF${THEME.text}` },
|
||||
name: 'Microsoft YaHei',
|
||||
size: 11,
|
||||
};
|
||||
cell.alignment = { horizontal: col.align ?? 'left', vertical: 'middle' };
|
||||
cell.fill = {
|
||||
fgColor: { argb: `FF${THEME.summaryBg}` },
|
||||
pattern: 'solid',
|
||||
type: 'pattern',
|
||||
};
|
||||
cell.border = THIN_BORDER;
|
||||
});
|
||||
}
|
||||
|
||||
if (rows.length > 0) {
|
||||
sheet.autoFilter = {
|
||||
from: { column: 1, row: 3 },
|
||||
to: { column: lastCol, row: 3 + rows.length },
|
||||
};
|
||||
}
|
||||
sheet.pageSetup = {
|
||||
fitToPage: true,
|
||||
fitToWidth: 1,
|
||||
orientation: 'landscape',
|
||||
paperSize: 9,
|
||||
};
|
||||
}
|
||||
|
||||
function rowsFromSheet(sheet: Worksheet): Record<string, any>[] {
|
||||
const matrix: string[][] = [];
|
||||
sheet.eachRow({ includeEmpty: false }, (row) => {
|
||||
const line: string[] = [];
|
||||
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
||||
line[colNumber - 1] = cellText(cell);
|
||||
});
|
||||
matrix.push(line);
|
||||
});
|
||||
const headerIndex = findHeaderRow(matrix);
|
||||
const headers = (matrix[headerIndex] ?? []).map((item) =>
|
||||
String(item).trim(),
|
||||
);
|
||||
if (headers.filter(Boolean).length === 0) {
|
||||
return [];
|
||||
}
|
||||
const rows: Record<string, any>[] = [];
|
||||
for (let i = headerIndex + 1; i < matrix.length; i += 1) {
|
||||
const cols = matrix[i] ?? [];
|
||||
if (cols.every((item) => String(item ?? '').trim() === '')) {
|
||||
continue;
|
||||
}
|
||||
const item: Record<string, any> = {};
|
||||
headers.forEach((key, index) => {
|
||||
if (!key) return;
|
||||
item[key] = String(cols[index] ?? '').trim();
|
||||
});
|
||||
const first = String(item[headers[0] ?? ''] ?? '');
|
||||
if (first === '合计') {
|
||||
continue;
|
||||
}
|
||||
rows.push(item);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function fillRow(
|
||||
sheet: Worksheet,
|
||||
rowNumber: number,
|
||||
lastCol: number,
|
||||
argb: string,
|
||||
) {
|
||||
for (let col = 1; col <= lastCol; col += 1) {
|
||||
sheet.getCell(rowNumber, col).fill = {
|
||||
fgColor: { argb },
|
||||
pattern: 'solid',
|
||||
type: 'pattern',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function findHeaderRow(matrix: string[][]): number {
|
||||
const limit = Math.min(8, matrix.length);
|
||||
for (let i = 0; i < limit; i += 1) {
|
||||
const filled = (matrix[i] ?? []).filter(
|
||||
(cell) => String(cell ?? '').trim() !== '',
|
||||
).length;
|
||||
if (filled >= 3) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function cellText(cell: Cell): string {
|
||||
const value = cell.value;
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'object' && value && 'text' in value) {
|
||||
return String((value as { text: string }).text ?? '');
|
||||
}
|
||||
if (typeof value === 'object' && value && 'result' in value) {
|
||||
return String((value as { result: unknown }).result ?? '');
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return formatNow(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function normalizeCell(value: unknown): number | string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function toXlsxName(name: string): string {
|
||||
return `${name.replace(/\.csv$/i, '.xlsx').replace(/\.xlsx$/i, '')}.xlsx`;
|
||||
}
|
||||
|
||||
function formatNow(date = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
@@ -403,3 +403,8 @@ export function downloadByData(
|
||||
tempLink.remove();
|
||||
window.URL.revokeObjectURL(blobURL);
|
||||
}
|
||||
|
||||
/** 下载后端返回的 UTF-8 CSV 文本 */
|
||||
export function downloadCsv(filename: string, csv: string) {
|
||||
downloadByData(csv, filename || 'export.csv', 'text/csv;charset=utf-8');
|
||||
}
|
||||
|
||||
@@ -9,7 +9,12 @@ import { colorcardApi } from '../api';
|
||||
export const gridOptions = createGridOptions({
|
||||
columns: [
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||
{ field: 'cover', title: '色卡图', width: 90, slots: { default: 'cover' } },
|
||||
{
|
||||
field: 'cover',
|
||||
title: '色卡图',
|
||||
width: 120,
|
||||
slots: { default: 'cover' },
|
||||
},
|
||||
{ field: 'description', align: 'left', title: '色卡说明', minWidth: 200 },
|
||||
{ field: 'card_class_name', title: '所属分类', width: 140 },
|
||||
{ field: 'company_name', title: '所属公司', width: 160 },
|
||||
|
||||
@@ -34,7 +34,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
|
||||
<Page auto-content-height title="色卡管理">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
@@ -57,7 +57,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
|
||||
/>
|
||||
</template>
|
||||
<template #cover="{ row }">
|
||||
<Image v-if="row.cover" :height="40" :src="row.cover" :width="40" />
|
||||
<Image v-if="row.cover" :height="72" :src="row.cover" :width="72" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
|
||||
@@ -32,7 +32,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
|
||||
<Page auto-content-height title="公司管理">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
|
||||
18
apps/web-antd/src/views/customer/after-sale/api/index.ts
Normal file
18
apps/web-antd/src/views/customer/after-sale/api/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
import { createCrudApi } from '#/components/crud';
|
||||
|
||||
/** 售后申请(cc_after_sale),通过只改状态,线下退款 */
|
||||
export const afterSaleApi = createCrudApi('after-sale');
|
||||
|
||||
/** 审核售后:pass=true 通过 */
|
||||
export async function auditAfterSale(data: {
|
||||
admin_remark?: string;
|
||||
id: number;
|
||||
pass: boolean;
|
||||
}) {
|
||||
return requestClient.post<any>('after-sale/audit', {
|
||||
id: data.id,
|
||||
status: data.pass ? 1 : 0,
|
||||
admin_remark: data.admin_remark ?? '',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 售后审核弹窗。通过只改状态,不自动打微信退款。
|
||||
*/
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { popupData } from '#/components/crud';
|
||||
|
||||
import { auditAfterSale } from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
defineOptions({
|
||||
name: 'AfterSaleAuditModal',
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const result = await formApi.validate();
|
||||
if (!result.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
modalApi.lock();
|
||||
try {
|
||||
await auditAfterSale({
|
||||
id: Number(values.id),
|
||||
pass: Number(values.pass) === 1,
|
||||
admin_remark: String(values.admin_remark ?? ''),
|
||||
});
|
||||
message.success('已处理');
|
||||
popupData(modalApi).gridApi?.reload();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) return;
|
||||
const data = popupData(modalApi);
|
||||
await formApi.setValues({ pass: 1, ...data.values });
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[40%]" title="审核售后">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
/** 售后状态:0=待审 1=通过 2=拒绝 */
|
||||
export const AFTER_SALE_STATUS_OPTIONS = [
|
||||
{ label: '待审核', value: 0 },
|
||||
{ label: '已通过', value: 1 },
|
||||
{ label: '已拒绝', value: 2 },
|
||||
];
|
||||
|
||||
export const AFTER_SALE_STATUS_TEXT: Record<number, string> = {
|
||||
0: '待审核',
|
||||
1: '已通过',
|
||||
2: '已拒绝',
|
||||
};
|
||||
39
apps/web-antd/src/views/customer/after-sale/config/form.ts
Normal file
39
apps/web-antd/src/views/customer/after-sale/config/form.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
/** 审核弹窗:通过/拒绝 + 备注 */
|
||||
export const modalFormProps: VbenFormProps = {
|
||||
commonConfig: {
|
||||
componentProps: { class: 'w-full' },
|
||||
formItemClass: 'col-span-12',
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '通过(线下退款)', value: 1 },
|
||||
{ label: '拒绝', value: 0 },
|
||||
],
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'pass',
|
||||
label: '审核结果',
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
{
|
||||
component: 'VbenTextarea',
|
||||
componentProps: { rows: 3, placeholder: '审核备注,选填' },
|
||||
fieldName: 'admin_remark',
|
||||
label: '备注',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-12',
|
||||
};
|
||||
29
apps/web-antd/src/views/customer/after-sale/config/search.ts
Normal file
29
apps/web-antd/src/views/customer/after-sale/config/search.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { AFTER_SALE_STATUS_OPTIONS } from './constants';
|
||||
|
||||
export const formOptions: VbenFormProps = {
|
||||
collapsed: false,
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: { placeholder: '订单 ID' },
|
||||
fieldName: 'order_id',
|
||||
label: '订单',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: AFTER_SALE_STATUS_OPTIONS,
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
},
|
||||
],
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: { content: '查询' },
|
||||
submitOnChange: true,
|
||||
submitOnEnter: false,
|
||||
};
|
||||
29
apps/web-antd/src/views/customer/after-sale/config/table.ts
Normal file
29
apps/web-antd/src/views/customer/after-sale/config/table.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { actionColumn, createGridOptions } from '#/components/crud';
|
||||
|
||||
import { afterSaleApi } from '../api';
|
||||
|
||||
export const gridOptions = createGridOptions({
|
||||
columns: [
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||
{ field: 'order_no', title: '订单号', width: 180 },
|
||||
{ field: 'user_name', title: '用户', width: 140 },
|
||||
{ field: 'reason', align: 'left', title: '原因', minWidth: 200 },
|
||||
{ field: 'amount_text', title: '申请金额', width: 110 },
|
||||
{
|
||||
field: 'images',
|
||||
title: '图片',
|
||||
width: 200,
|
||||
slots: { default: 'images' },
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
slots: { default: 'status' },
|
||||
},
|
||||
{ field: 'admin_remark', align: 'left', title: '审核备注', minWidth: 160 },
|
||||
{ field: 'created_at', title: '申请时间', width: 180 },
|
||||
actionColumn(180),
|
||||
],
|
||||
query: (params) => afterSaleApi.list(params),
|
||||
});
|
||||
112
apps/web-antd/src/views/customer/after-sale/index.vue
Normal file
112
apps/web-antd/src/views/customer/after-sale/index.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 售后管理:线下退款,后台只审状态。
|
||||
*/
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Image, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useCrudTable } from '#/components/crud';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { afterSaleApi } from './api';
|
||||
import AfterSaleAuditModal from './components/modal.vue';
|
||||
import { AFTER_SALE_STATUS_TEXT } from './config/constants';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
defineOptions({
|
||||
name: 'CustomerAfterSale',
|
||||
});
|
||||
|
||||
const { FormModal, Grid, hasSelection, removeRows, showModal } = useCrudTable({
|
||||
api: afterSaleApi,
|
||||
formOptions,
|
||||
gridOptions,
|
||||
modalComponent: AfterSaleAuditModal,
|
||||
toFormValues: (row) => ({
|
||||
admin_remark: row.admin_remark ?? '',
|
||||
id: row.id,
|
||||
pass: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
function imageList(value?: string) {
|
||||
return String(value ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function statusColor(status: number) {
|
||||
if (status === 1) return 'green';
|
||||
if (status === 2) return 'red';
|
||||
return 'orange';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="售后管理">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
disabled: !hasSelection,
|
||||
popConfirm: {
|
||||
title: '确定删除勾选的售后单吗?',
|
||||
confirm: () => removeRows(),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #images="{ row }">
|
||||
<div class="flex gap-1">
|
||||
<Image
|
||||
v-for="(src, index) in imageList(row.images)"
|
||||
:key="index"
|
||||
:height="56"
|
||||
:src="src"
|
||||
:width="56"
|
||||
/>
|
||||
<span v-if="imageList(row.images).length === 0">-</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="statusColor(row.status)">
|
||||
{{ AFTER_SALE_STATUS_TEXT[row.status] ?? '-' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '审核',
|
||||
type: 'link',
|
||||
icon: 'lucide:check',
|
||||
size: 'small',
|
||||
disabled: row.status !== 0,
|
||||
onClick: () => showModal(row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除该售后单吗?',
|
||||
confirm: () => removeRows(row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
/**
|
||||
* 经销商管理:仅 is_p=1 的微信用户
|
||||
* 微信用户列表仍展示全部(含经销商),本页专注倍率、下级、企业与专属模板绑定
|
||||
@@ -15,7 +17,6 @@ import {
|
||||
Switch,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { useCrudTable } from '#/components/crud';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
@@ -125,7 +126,7 @@ onMounted(loadTemplateOptions);
|
||||
<BindModal />
|
||||
<ChildrenDrawer />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
列表固定筛选 is_p=1;专属模板仅经销商登录后优先生效。
|
||||
</span>
|
||||
@@ -156,8 +157,12 @@ onMounted(loadTemplateOptions);
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
:value="multiplierDraft[row.id] ?? Number(row.price_number ?? 1)"
|
||||
@update:value="(val: any) => (multiplierDraft[row.id] = Number(val))"
|
||||
:value="
|
||||
multiplierDraft[row.id] ?? Number(row.price_number ?? 1)
|
||||
"
|
||||
@update:value="
|
||||
(val: any) => (multiplierDraft[row.id] = Number(val))
|
||||
"
|
||||
/>
|
||||
<Button size="small" type="primary" @click="saveMultiplier(row)">
|
||||
保存
|
||||
|
||||
@@ -9,7 +9,7 @@ import { enterpriseApi } from '../api';
|
||||
export const gridOptions = createGridOptions({
|
||||
columns: [
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||
{ field: 'logo', title: 'LOGO', width: 90, slots: { default: 'logo' } },
|
||||
{ field: 'logo', title: 'LOGO', width: 120, slots: { default: 'logo' } },
|
||||
{ field: 'name', align: 'left', title: '企业名称', minWidth: 200 },
|
||||
{ field: 'contact_name', title: '联系人', width: 110 },
|
||||
{ field: 'phone', title: '联系电话', width: 140 },
|
||||
|
||||
@@ -49,7 +49,7 @@ function openDetail(row: Record<string, any>) {
|
||||
<FormModal />
|
||||
<DetailDrawer />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
@@ -72,7 +72,7 @@ function openDetail(row: Record<string, any>) {
|
||||
/>
|
||||
</template>
|
||||
<template #logo="{ row }">
|
||||
<Image v-if="row.logo" :height="40" :src="row.logo" :width="40" />
|
||||
<Image v-if="row.logo" :height="72" :src="row.logo" :width="72" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template #settle="{ row }">
|
||||
|
||||
10
apps/web-antd/src/views/customer/feedback/api/index.ts
Normal file
10
apps/web-antd/src/views/customer/feedback/api/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
import { createCrudApi } from '#/components/crud';
|
||||
|
||||
/** 用户反馈(cc_feedback) */
|
||||
export const feedbackApi = createCrudApi('feedback');
|
||||
|
||||
/** 回复反馈 */
|
||||
export async function replyFeedback(data: { id: number; reply: string }) {
|
||||
return requestClient.post<any>('feedback/reply', data);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 反馈回复弹窗:后台不能代用户新建反馈,只能回复。
|
||||
*/
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { popupData } from '#/components/crud';
|
||||
|
||||
import { replyFeedback } from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
defineOptions({
|
||||
name: 'FeedbackReplyModal',
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const result = await formApi.validate();
|
||||
if (!result.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
modalApi.lock();
|
||||
try {
|
||||
await replyFeedback({
|
||||
id: Number(values.id),
|
||||
reply: String(values.reply ?? ''),
|
||||
});
|
||||
message.success('已回复');
|
||||
popupData(modalApi).gridApi?.reload();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) return;
|
||||
const data = popupData(modalApi);
|
||||
await formApi.setValues(data.values ?? {});
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[40%]" title="回复反馈">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
/** 反馈状态:0=待处理 1=已回复 */
|
||||
export const FEEDBACK_STATUS_OPTIONS = [
|
||||
{ label: '待处理', value: 0 },
|
||||
{ label: '已回复', value: 1 },
|
||||
];
|
||||
|
||||
export const FEEDBACK_STATUS_TEXT: Record<number, string> = {
|
||||
0: '待处理',
|
||||
1: '已回复',
|
||||
};
|
||||
27
apps/web-antd/src/views/customer/feedback/config/form.ts
Normal file
27
apps/web-antd/src/views/customer/feedback/config/form.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
/** 回复弹窗:只改 reply */
|
||||
export const modalFormProps: VbenFormProps = {
|
||||
commonConfig: {
|
||||
componentProps: { class: 'w-full' },
|
||||
formItemClass: 'col-span-12',
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
},
|
||||
{
|
||||
component: 'VbenTextarea',
|
||||
componentProps: { rows: 4, placeholder: '请填写回复内容' },
|
||||
fieldName: 'reply',
|
||||
label: '回复',
|
||||
rules: 'required',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-12',
|
||||
};
|
||||
29
apps/web-antd/src/views/customer/feedback/config/search.ts
Normal file
29
apps/web-antd/src/views/customer/feedback/config/search.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { FEEDBACK_STATUS_OPTIONS } from './constants';
|
||||
|
||||
export const formOptions: VbenFormProps = {
|
||||
collapsed: false,
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: { placeholder: '反馈内容' },
|
||||
fieldName: 'content',
|
||||
label: '内容',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: FEEDBACK_STATUS_OPTIONS,
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
},
|
||||
],
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: { content: '查询' },
|
||||
submitOnChange: true,
|
||||
submitOnEnter: false,
|
||||
};
|
||||
28
apps/web-antd/src/views/customer/feedback/config/table.ts
Normal file
28
apps/web-antd/src/views/customer/feedback/config/table.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { actionColumn, createGridOptions } from '#/components/crud';
|
||||
|
||||
import { feedbackApi } from '../api';
|
||||
|
||||
export const gridOptions = createGridOptions({
|
||||
columns: [
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||
{ field: 'user_name', title: '用户', width: 140 },
|
||||
{ field: 'user_phone', title: '手机', width: 130 },
|
||||
{ field: 'content', align: 'left', title: '反馈内容', minWidth: 240 },
|
||||
{
|
||||
field: 'images',
|
||||
title: '图片',
|
||||
width: 200,
|
||||
slots: { default: 'images' },
|
||||
},
|
||||
{ field: 'reply', align: 'left', title: '回复', minWidth: 180 },
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
slots: { default: 'status' },
|
||||
},
|
||||
{ field: 'created_at', title: '提交时间', width: 180 },
|
||||
actionColumn(160),
|
||||
],
|
||||
query: (params) => feedbackApi.list(params),
|
||||
});
|
||||
104
apps/web-antd/src/views/customer/feedback/index.vue
Normal file
104
apps/web-antd/src/views/customer/feedback/index.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 用户反馈:列表 + 回复 + 删除。反馈由小程序提交,后台不新建。
|
||||
*/
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Image, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useCrudTable } from '#/components/crud';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { feedbackApi } from './api';
|
||||
import FeedbackReplyModal from './components/modal.vue';
|
||||
import { FEEDBACK_STATUS_TEXT } from './config/constants';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
defineOptions({
|
||||
name: 'CustomerFeedback',
|
||||
});
|
||||
|
||||
const { FormModal, Grid, hasSelection, removeRows, showModal } = useCrudTable({
|
||||
api: feedbackApi,
|
||||
formOptions,
|
||||
gridOptions,
|
||||
modalComponent: FeedbackReplyModal,
|
||||
toFormValues: (row) => ({
|
||||
id: row.id,
|
||||
reply: row.reply ?? '',
|
||||
}),
|
||||
});
|
||||
|
||||
function imageList(value?: string) {
|
||||
return String(value ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="用户反馈">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
disabled: !hasSelection,
|
||||
popConfirm: {
|
||||
title: '确定删除勾选的反馈吗?',
|
||||
confirm: () => removeRows(),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #images="{ row }">
|
||||
<div class="flex gap-1">
|
||||
<Image
|
||||
v-for="(src, index) in imageList(row.images)"
|
||||
:key="index"
|
||||
:height="56"
|
||||
:src="src"
|
||||
:width="56"
|
||||
/>
|
||||
<span v-if="imageList(row.images).length === 0">-</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status === 1 ? 'green' : 'orange'">
|
||||
{{ FEEDBACK_STATUS_TEXT[row.status] ?? '-' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '回复',
|
||||
type: 'link',
|
||||
icon: 'lucide:reply',
|
||||
size: 'small',
|
||||
onClick: () => showModal(row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除该反馈吗?',
|
||||
confirm: () => removeRows(row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -26,11 +26,11 @@ export async function getListByUser(userId: number): Promise<any[]> {
|
||||
* params 里收货信息是可选的,缺省时后端用用户的昵称/手机兜底。
|
||||
*/
|
||||
export async function listToOrder(data: {
|
||||
id: number;
|
||||
delivery_type?: number;
|
||||
id: number;
|
||||
receiver_address?: string;
|
||||
receiver_name?: string;
|
||||
receiver_phone?: string;
|
||||
receiver_address?: string;
|
||||
remark?: string;
|
||||
}) {
|
||||
return requestClient.post<any>('list/to-order', data);
|
||||
@@ -48,3 +48,8 @@ export async function saveListItem(data: Record<string, any>) {
|
||||
export async function deleteListItem(ids: number[]) {
|
||||
return requestClient.post<any>('list/delete-item', { ids });
|
||||
}
|
||||
|
||||
/** 导出清单报价:后端给结构化数据,前端 ExcelJS 画 xlsx */
|
||||
export async function exportListQuote(id: number) {
|
||||
return requestClient.get<any>('list/export-quote', { params: { id } });
|
||||
}
|
||||
|
||||
@@ -3,10 +3,28 @@ import { ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Alert, Button, Descriptions, DescriptionsItem, Empty, Image, Input, message, Select, Spin, Table, Tag } from 'ant-design-vue';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Descriptions,
|
||||
DescriptionsItem,
|
||||
Empty,
|
||||
Image,
|
||||
Input,
|
||||
message,
|
||||
Select,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { popupData } from '#/components/crud';
|
||||
import { listApi, listToOrder } from '#/views/customer/list/api';
|
||||
import { downloadStyledExcel } from '#/util/excel';
|
||||
import {
|
||||
exportListQuote,
|
||||
listApi,
|
||||
listToOrder,
|
||||
} from '#/views/customer/list/api';
|
||||
|
||||
import { DELIVERY_TYPE_OPTIONS, LIST_STATUS_TEXT } from '../config/constants';
|
||||
|
||||
@@ -75,6 +93,14 @@ function resetOrderForm() {
|
||||
remark.value = '';
|
||||
}
|
||||
|
||||
async function exportQuote() {
|
||||
const id = Number(record.value.id ?? 0);
|
||||
if (id <= 0) return;
|
||||
const res = await exportListQuote(id);
|
||||
await downloadStyledExcel(res);
|
||||
message.success('已导出报价');
|
||||
}
|
||||
|
||||
async function submitToOrder() {
|
||||
const id = Number(record.value.id ?? 0);
|
||||
if (id <= 0) return;
|
||||
@@ -130,9 +156,27 @@ async function submitToOrder() {
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="合计">
|
||||
<span class="text-primary font-semibold">
|
||||
¥{{ detail.total_amount_text ?? '0' }}
|
||||
¥{{
|
||||
detail.payable_amount_text || detail.total_amount_text || '0'
|
||||
}}
|
||||
</span>
|
||||
</DescriptionsItem>
|
||||
<template v-if="Number(detail.package_id) > 0">
|
||||
<DescriptionsItem label="套餐原价">
|
||||
¥{{ detail.original_amount_text ?? '0' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="套餐价">
|
||||
¥{{ detail.package_amount_text ?? '0' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="变更差价">
|
||||
¥{{ detail.diff_amount_text ?? '0' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="应付">
|
||||
<span class="text-primary font-semibold">
|
||||
¥{{ detail.payable_amount_text ?? '0' }}
|
||||
</span>
|
||||
</DescriptionsItem>
|
||||
</template>
|
||||
</Descriptions>
|
||||
|
||||
<Table
|
||||
@@ -155,6 +199,10 @@ async function submitToOrder() {
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button @click="exportQuote">导出报价</Button>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
v-if="detail.status === 1"
|
||||
message="该清单已转订单,无需再次下单。"
|
||||
@@ -178,10 +226,7 @@ async function submitToOrder() {
|
||||
<Input v-model:value="receiverName" placeholder="收件人" />
|
||||
<Input v-model:value="receiverPhone" placeholder="联系电话" />
|
||||
</div>
|
||||
<Input
|
||||
v-model:value="receiverAddress"
|
||||
placeholder="收货地址"
|
||||
/>
|
||||
<Input v-model:value="receiverAddress" placeholder="收货地址" />
|
||||
<Select
|
||||
v-model:value="deliveryType"
|
||||
:options="DELIVERY_TYPE_OPTIONS"
|
||||
@@ -189,12 +234,8 @@ async function submitToOrder() {
|
||||
/>
|
||||
<Input v-model:value="remark" placeholder="订单备注" />
|
||||
<div class="flex gap-2">
|
||||
<Button type="primary" @click="submitToOrder">
|
||||
确认下单
|
||||
</Button>
|
||||
<Button @click="showOrderForm = false">
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" @click="submitToOrder"> 确认下单 </Button>
|
||||
<Button @click="showOrderForm = false"> 取消 </Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message, Tag } from 'ant-design-vue';
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
import { useCrudTable } from '#/components/crud';
|
||||
import UserDetailModal from '#/components/customer/user-detail-modal.vue';
|
||||
@@ -62,7 +62,7 @@ async function batchDelete() {
|
||||
<DetailDrawer />
|
||||
<UserModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
|
||||
@@ -3,11 +3,12 @@ import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
import { Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useCrudTable } from '#/components/crud';
|
||||
import UserDetailModal from '#/components/customer/user-detail-modal.vue';
|
||||
import { Icon } from '#/components/icon';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import { Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
import { getOrderStat, orderApi } from './api';
|
||||
import CustomerOrderDetailDrawer from './components/detail-drawer.vue';
|
||||
@@ -50,11 +51,11 @@ const [UserModal, userModalApi] = useVbenModal({
|
||||
/** 顶部统计:后端 order/stat 返回各状态数量与总金额 */
|
||||
const statLoading = ref(false);
|
||||
const stat = ref<{
|
||||
total: number;
|
||||
amount: number;
|
||||
amount_text: string;
|
||||
auditing: number;
|
||||
status: { status: number; count: number; amount: number }[];
|
||||
status: { amount: number; count: number; status: number }[];
|
||||
total: number;
|
||||
}>({ total: 0, amount: 0, amount_text: '0', auditing: 0, status: [] });
|
||||
|
||||
async function loadStat() {
|
||||
@@ -67,10 +68,31 @@ async function loadStat() {
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{ key: 'total', label: '订单总数', icon: 'lucide:receipt', getValue: (s: typeof stat.value) => String(s.total) },
|
||||
{ key: 'amount', label: '订单总额', icon: 'lucide:circle-dollar-sign', getValue: (s: typeof stat.value) => `¥${s.amount_text}` },
|
||||
{ key: 'auditing', label: '待审凭证', icon: 'lucide:badge-check', getValue: (s: typeof stat.value) => String(s.auditing) },
|
||||
{ key: 'pending', label: '待付款', icon: 'lucide:clock', getValue: (s: typeof stat.value) => String(s.status.find((i) => i.status === 0)?.count ?? 0) },
|
||||
{
|
||||
key: 'total',
|
||||
label: '订单总数',
|
||||
icon: 'lucide:receipt',
|
||||
getValue: (s: typeof stat.value) => String(s.total),
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: '订单总额',
|
||||
icon: 'lucide:circle-dollar-sign',
|
||||
getValue: (s: typeof stat.value) => `¥${s.amount_text}`,
|
||||
},
|
||||
{
|
||||
key: 'auditing',
|
||||
label: '待审凭证',
|
||||
icon: 'lucide:badge-check',
|
||||
getValue: (s: typeof stat.value) => String(s.auditing),
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
label: '待付款',
|
||||
icon: 'lucide:clock',
|
||||
getValue: (s: typeof stat.value) =>
|
||||
String(s.status.find((i) => i.status === 0)?.count ?? 0),
|
||||
},
|
||||
];
|
||||
|
||||
onMounted(loadStat);
|
||||
@@ -87,7 +109,11 @@ function openUser(row: Record<string, any>) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height content-class="flex flex-col gap-3" title="订单管理">
|
||||
<Page
|
||||
auto-content-height
|
||||
content-class="flex flex-col gap-3"
|
||||
title="订单管理"
|
||||
>
|
||||
<FormModal />
|
||||
<DetailDrawer />
|
||||
<UserModal />
|
||||
@@ -116,7 +142,7 @@ function openUser(row: Record<string, any>) {
|
||||
</Spin>
|
||||
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Avatar, Button, InputNumber, message, Popover, Switch, Tag } from 'ant-design-vue';
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
InputNumber,
|
||||
message,
|
||||
Popover,
|
||||
Switch,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { useCrudTable } from '#/components/crud';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
@@ -79,7 +88,7 @@ async function saveMultiplier(row: Record<string, any>) {
|
||||
<BindModal />
|
||||
<ChildrenDrawer />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
微信用户由小程序授权登录产生,后台不支持新建。
|
||||
</span>
|
||||
@@ -118,8 +127,12 @@ async function saveMultiplier(row: Record<string, any>) {
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
:value="multiplierDraft[row.id] ?? Number(row.price_number ?? 1)"
|
||||
@update:value="(val: any) => (multiplierDraft[row.id] = Number(val))"
|
||||
:value="
|
||||
multiplierDraft[row.id] ?? Number(row.price_number ?? 1)
|
||||
"
|
||||
@update:value="
|
||||
(val: any) => (multiplierDraft[row.id] = Number(val))
|
||||
"
|
||||
/>
|
||||
<Button size="small" type="primary" @click="saveMultiplier(row)">
|
||||
保存
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 分析页:用订单真实统计替换 Vben 假数据(12 万用户等)。
|
||||
*/
|
||||
import type { AnalysisOverviewItem } from '@vben/common-ui';
|
||||
import type { TabOption } from '@vben/types';
|
||||
|
||||
import {
|
||||
AnalysisChartCard,
|
||||
AnalysisChartsTabs,
|
||||
AnalysisOverview,
|
||||
} from '@vben/common-ui';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { AnalysisOverview } from '@vben/common-ui';
|
||||
import {
|
||||
SvgBellIcon,
|
||||
SvgCakeIcon,
|
||||
@@ -14,77 +14,76 @@ import {
|
||||
SvgDownloadIcon,
|
||||
} from '@vben/icons';
|
||||
|
||||
import AnalyticsTrends from './analytics-trends.vue';
|
||||
import AnalyticsVisitsData from './analytics-visits-data.vue';
|
||||
import AnalyticsVisitsSales from './analytics-visits-sales.vue';
|
||||
import AnalyticsVisitsSource from './analytics-visits-source.vue';
|
||||
import AnalyticsVisits from './analytics-visits.vue';
|
||||
import { getOrderStat } from '#/views/customer/order/api';
|
||||
|
||||
const overviewItems: AnalysisOverviewItem[] = [
|
||||
const overviewItems = ref<AnalysisOverviewItem[]>([
|
||||
{
|
||||
icon: SvgCardIcon,
|
||||
title: '用户量',
|
||||
totalTitle: '总用户量',
|
||||
totalValue: 120_000,
|
||||
value: 2000,
|
||||
title: '订单数',
|
||||
totalTitle: '全部订单',
|
||||
totalValue: 0,
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
icon: SvgCakeIcon,
|
||||
title: '访问量',
|
||||
totalTitle: '总访问量',
|
||||
totalValue: 500_000,
|
||||
value: 20_000,
|
||||
title: '成交额',
|
||||
totalTitle: '累计成交(元)',
|
||||
totalValue: 0,
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
icon: SvgDownloadIcon,
|
||||
title: '下载量',
|
||||
totalTitle: '总下载量',
|
||||
totalValue: 120_000,
|
||||
value: 8000,
|
||||
title: '清单',
|
||||
totalTitle: '全部清单',
|
||||
totalValue: 0,
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
icon: SvgBellIcon,
|
||||
title: '使用量',
|
||||
totalTitle: '总使用量',
|
||||
totalValue: 50_000,
|
||||
value: 5000,
|
||||
title: '微信用户',
|
||||
totalTitle: '全部用户',
|
||||
totalValue: 0,
|
||||
value: 0,
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
const chartTabs: TabOption[] = [
|
||||
{
|
||||
label: '流量趋势',
|
||||
value: 'trends',
|
||||
},
|
||||
{
|
||||
label: '月访问量',
|
||||
value: 'visits',
|
||||
},
|
||||
];
|
||||
onMounted(async () => {
|
||||
const data = (await getOrderStat()) ?? {};
|
||||
overviewItems.value = [
|
||||
{
|
||||
icon: SvgCardIcon,
|
||||
title: '待付款',
|
||||
totalTitle: '全部订单',
|
||||
totalValue: data.total ?? 0,
|
||||
value: data.unpaid ?? 0,
|
||||
},
|
||||
{
|
||||
icon: SvgCakeIcon,
|
||||
title: '待审凭证',
|
||||
totalTitle: '累计成交(元)',
|
||||
totalValue: data.amount_text ?? 0,
|
||||
value: data.auditing ?? 0,
|
||||
},
|
||||
{
|
||||
icon: SvgDownloadIcon,
|
||||
title: '待发货',
|
||||
totalTitle: '全部清单',
|
||||
totalValue: data.list_count ?? 0,
|
||||
value: data.paid ?? 0,
|
||||
},
|
||||
{
|
||||
icon: SvgBellIcon,
|
||||
title: '已发货',
|
||||
totalTitle: '微信用户',
|
||||
totalValue: data.wx_user_count ?? 0,
|
||||
value: data.shipped ?? 0,
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<AnalysisOverview :items="overviewItems" />
|
||||
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
|
||||
<template #trends>
|
||||
<AnalyticsTrends />
|
||||
</template>
|
||||
<template #visits>
|
||||
<AnalyticsVisits />
|
||||
</template>
|
||||
</AnalysisChartsTabs>
|
||||
|
||||
<div class="mt-5 w-full md:flex">
|
||||
<AnalysisChartCard class="mt-5 md:mt-0 md:mr-4 md:w-1/3" title="访问数量">
|
||||
<AnalyticsVisitsData />
|
||||
</AnalysisChartCard>
|
||||
<AnalysisChartCard class="mt-5 md:mt-0 md:mr-4 md:w-1/3" title="访问来源">
|
||||
<AnalyticsVisitsSource />
|
||||
</AnalysisChartCard>
|
||||
<AnalysisChartCard class="mt-5 md:mt-0 md:w-1/3" title="访问来源">
|
||||
<AnalyticsVisitsSales />
|
||||
</AnalysisChartCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,236 +1,151 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 工作台:用订单/清单/用户真实统计替换 Vben 示例数据。
|
||||
*/
|
||||
import type {
|
||||
WorkbenchProjectItem,
|
||||
WorkbenchQuickNavItem,
|
||||
WorkbenchTodoItem,
|
||||
WorkbenchTrendItem,
|
||||
} from '@vben/common-ui';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import {
|
||||
AnalysisChartCard,
|
||||
WorkbenchHeader,
|
||||
WorkbenchProject,
|
||||
WorkbenchQuickNav,
|
||||
WorkbenchTodo,
|
||||
WorkbenchTrends,
|
||||
} from '@vben/common-ui';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import AnalyticsVisitsSource from '../analytics/analytics-visits-source.vue';
|
||||
import { getOrderStat } from '#/views/customer/order/api';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
// 这是一个示例数据,实际项目中需要根据实际情况进行调整
|
||||
// url 也可以是内部路由,在 navTo 方法中识别处理,进行内部跳转
|
||||
// 例如:url: /dashboard/workspace
|
||||
const projectItems: WorkbenchProjectItem[] = [
|
||||
{
|
||||
color: '',
|
||||
content: '不要等待机会,而要创造机会。',
|
||||
date: '2021-04-01',
|
||||
group: '开源组',
|
||||
icon: 'carbon:logo-github',
|
||||
title: 'Github',
|
||||
url: 'https://github.com',
|
||||
},
|
||||
{
|
||||
color: '#3fb27f',
|
||||
content: '现在的你决定将来的你。',
|
||||
date: '2021-04-01',
|
||||
group: '算法组',
|
||||
icon: 'ion:logo-vue',
|
||||
title: 'Vue',
|
||||
url: 'https://vuejs.org',
|
||||
},
|
||||
{
|
||||
color: '#e18525',
|
||||
content: '没有什么才能比努力更重要。',
|
||||
date: '2021-04-01',
|
||||
group: '上班摸鱼',
|
||||
icon: 'ion:logo-html5',
|
||||
title: 'Html5',
|
||||
url: 'https://developer.mozilla.org/zh-CN/docs/Web/HTML',
|
||||
},
|
||||
{
|
||||
color: '#bf0c2c',
|
||||
content: '热情和欲望可以突破一切难关。',
|
||||
date: '2021-04-01',
|
||||
group: 'UI',
|
||||
icon: 'ion:logo-angular',
|
||||
title: 'Angular',
|
||||
url: 'https://angular.io',
|
||||
},
|
||||
{
|
||||
color: '#00d8ff',
|
||||
content: '健康的身体是实现目标的基石。',
|
||||
date: '2021-04-01',
|
||||
group: '技术牛',
|
||||
icon: 'bx:bxl-react',
|
||||
title: 'React',
|
||||
url: 'https://reactjs.org',
|
||||
},
|
||||
{
|
||||
color: '#EBD94E',
|
||||
content: '路是走出来的,而不是空想出来的。',
|
||||
date: '2021-04-01',
|
||||
group: '架构组',
|
||||
icon: 'ion:logo-javascript',
|
||||
title: 'Js',
|
||||
url: 'https://developer.mozilla.org/zh-CN/docs/Web/JavaScript',
|
||||
},
|
||||
];
|
||||
const stat = ref<Record<string, any>>({});
|
||||
|
||||
const projectItems = ref<WorkbenchProjectItem[]>([]);
|
||||
const todoItems = ref<WorkbenchTodoItem[]>([]);
|
||||
|
||||
// 同样,这里的 url 也可以使用以 http 开头的外部链接
|
||||
const quickNavItems: WorkbenchQuickNavItem[] = [
|
||||
{
|
||||
color: '#1fdaca',
|
||||
icon: 'ion:home-outline',
|
||||
title: '首页',
|
||||
url: '/',
|
||||
},
|
||||
{
|
||||
color: '#bf0c2c',
|
||||
icon: 'ion:grid-outline',
|
||||
title: '仪表盘',
|
||||
url: '/dashboard',
|
||||
icon: 'ion:cube-outline',
|
||||
title: '商品图册',
|
||||
url: '/goods/catalogue',
|
||||
},
|
||||
{
|
||||
color: '#e18525',
|
||||
icon: 'ion:layers-outline',
|
||||
title: '组件',
|
||||
url: '/demos/features/icons',
|
||||
title: '套餐搭配',
|
||||
url: '/goods/package',
|
||||
},
|
||||
{
|
||||
color: '#3fb27f',
|
||||
icon: 'ion:settings-outline',
|
||||
title: '系统管理',
|
||||
url: '/demos/features/login-expired', // 这里的 URL 是示例,实际项目中需要根据实际情况进行调整
|
||||
icon: 'ion:list-outline',
|
||||
title: '清单管理',
|
||||
url: '/customer/list',
|
||||
},
|
||||
{
|
||||
color: '#bf0c2c',
|
||||
icon: 'ion:receipt-outline',
|
||||
title: '订单管理',
|
||||
url: '/customer/order',
|
||||
},
|
||||
{
|
||||
color: '#4daf1bc9',
|
||||
icon: 'ion:key-outline',
|
||||
title: '权限管理',
|
||||
url: '/demos/access/page-control',
|
||||
icon: 'ion:people-outline',
|
||||
title: '微信用户',
|
||||
url: '/customer/wx-user',
|
||||
},
|
||||
{
|
||||
color: '#00d8ff',
|
||||
icon: 'ion:bar-chart-outline',
|
||||
title: '图表',
|
||||
url: '/analytics',
|
||||
icon: 'ion:chatbubble-outline',
|
||||
title: '用户反馈',
|
||||
url: '/customer/feedback',
|
||||
},
|
||||
{
|
||||
color: '#8b5cf6',
|
||||
icon: 'ion:return-down-back-outline',
|
||||
title: '售后管理',
|
||||
url: '/customer/after-sale',
|
||||
},
|
||||
];
|
||||
|
||||
const todoItems = ref<WorkbenchTodoItem[]>([
|
||||
{
|
||||
completed: false,
|
||||
content: `审查最近提交到Git仓库的前端代码,确保代码质量和规范。`,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '审查前端代码提交',
|
||||
},
|
||||
{
|
||||
completed: true,
|
||||
content: `检查并优化系统性能,降低CPU使用率。`,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '系统性能优化',
|
||||
},
|
||||
{
|
||||
completed: false,
|
||||
content: `进行系统安全检查,确保没有安全漏洞或未授权的访问。 `,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '安全检查',
|
||||
},
|
||||
{
|
||||
completed: false,
|
||||
content: `更新项目中的所有npm依赖包,确保使用最新版本。`,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '更新项目依赖',
|
||||
},
|
||||
{
|
||||
completed: false,
|
||||
content: `修复用户报告的页面UI显示问题,确保在不同浏览器中显示一致。 `,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '修复UI显示问题',
|
||||
},
|
||||
]);
|
||||
const trendItems: WorkbenchTrendItem[] = [
|
||||
{
|
||||
avatar: 'svg:avatar-1',
|
||||
content: `在 <a>开源组</a> 创建了项目 <a>Vue</a>`,
|
||||
date: '刚刚',
|
||||
title: '威廉',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-2',
|
||||
content: `关注了 <a>威廉</a> `,
|
||||
date: '1个小时前',
|
||||
title: '艾文',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-3',
|
||||
content: `发布了 <a>个人动态</a> `,
|
||||
date: '1天前',
|
||||
title: '克里斯',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-4',
|
||||
content: `发表文章 <a>如何编写一个Vite插件</a> `,
|
||||
date: '2天前',
|
||||
title: 'Vben',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-1',
|
||||
content: `回复了 <a>杰克</a> 的问题 <a>如何进行项目优化?</a>`,
|
||||
date: '3天前',
|
||||
title: '皮特',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-2',
|
||||
content: `关闭了问题 <a>如何运行项目</a> `,
|
||||
date: '1周前',
|
||||
title: '杰克',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-3',
|
||||
content: `发布了 <a>个人动态</a> `,
|
||||
date: '1周前',
|
||||
title: '威廉',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-4',
|
||||
content: `推送了代码到 <a>Github</a>`,
|
||||
date: '2021-04-01 20:00',
|
||||
title: '威廉',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-4',
|
||||
content: `发表文章 <a>如何编写使用 Admin Vben</a> `,
|
||||
date: '2021-03-01 20:00',
|
||||
title: 'Vben',
|
||||
},
|
||||
];
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// 这是一个示例方法,实际项目中需要根据实际情况进行调整
|
||||
// This is a sample method, adjust according to the actual project requirements
|
||||
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
if (nav.url?.startsWith('http')) {
|
||||
openWindow(nav.url);
|
||||
return;
|
||||
}
|
||||
if (nav.url?.startsWith('/')) {
|
||||
router.push(nav.url).catch((error) => {
|
||||
console.error('Navigation failed:', error);
|
||||
});
|
||||
} else {
|
||||
console.warn(`Unknown URL for navigation item: ${nav.title} -> ${nav.url}`);
|
||||
router.push(nav.url).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** 把订单概览铺到工作台卡片和待办 */
|
||||
async function loadStat() {
|
||||
const data = (await getOrderStat()) ?? {};
|
||||
stat.value = data;
|
||||
projectItems.value = [
|
||||
{
|
||||
color: '#3fb27f',
|
||||
content: `待付款 ${data.unpaid ?? 0} 单`,
|
||||
date: '',
|
||||
group: '订单',
|
||||
icon: 'ion:card-outline',
|
||||
title: `订单 ${data.total ?? 0} 笔`,
|
||||
url: '/customer/order',
|
||||
},
|
||||
{
|
||||
color: '#e18525',
|
||||
content: `待审凭证 ${data.auditing ?? 0} 笔`,
|
||||
date: '',
|
||||
group: '收款',
|
||||
icon: 'ion:cash-outline',
|
||||
title: `成交 ¥${data.amount_text ?? '0'}`,
|
||||
url: '/customer/order',
|
||||
},
|
||||
{
|
||||
color: '#1fdaca',
|
||||
content: '客户选品清单',
|
||||
date: '',
|
||||
group: '清单',
|
||||
icon: 'ion:list-outline',
|
||||
title: `清单 ${data.list_count ?? 0} 份`,
|
||||
url: '/customer/list',
|
||||
},
|
||||
{
|
||||
color: '#00d8ff',
|
||||
content: '小程序授权用户',
|
||||
date: '',
|
||||
group: '客户',
|
||||
icon: 'ion:people-outline',
|
||||
title: `用户 ${data.wx_user_count ?? 0} 人`,
|
||||
url: '/customer/wx-user',
|
||||
},
|
||||
];
|
||||
todoItems.value = [
|
||||
{
|
||||
completed: (data.auditing ?? 0) === 0,
|
||||
content: '转账凭证待审核,通过后订单会记为已付款。',
|
||||
date: '',
|
||||
title: `待审凭证 ${data.auditing ?? 0} 笔`,
|
||||
},
|
||||
{
|
||||
completed: (data.unpaid ?? 0) === 0,
|
||||
content: '未付款订单,可跟进客户或后台代客确认。',
|
||||
date: '',
|
||||
title: `待付款 ${data.unpaid ?? 0} 单`,
|
||||
},
|
||||
{
|
||||
completed: (data.paid ?? 0) === 0,
|
||||
content: '已付款待发货,请及时填写物流或自提点。',
|
||||
date: '',
|
||||
title: `待发货 ${data.paid ?? 0} 单`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
onMounted(loadStat);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -239,15 +154,21 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
:avatar="userStore.userInfo?.avatar || preferences.app.defaultAvatar"
|
||||
>
|
||||
<template #title>
|
||||
早安, {{ userStore.userInfo?.realName }}, 开始您一天的工作吧!
|
||||
{{ userStore.userInfo?.realName || '管理员' }},开始处理业务吧
|
||||
</template>
|
||||
<template #description>
|
||||
成交 ¥{{ stat.amount_text || '0' }} · 待审 {{ stat.auditing || 0 }} ·
|
||||
用户 {{ stat.wx_user_count || 0 }}
|
||||
</template>
|
||||
<template #description> 今日晴,20℃ - 32℃! </template>
|
||||
</WorkbenchHeader>
|
||||
|
||||
<div class="flex flex-col lg:flex-row">
|
||||
<div class="mr-4 w-full lg:w-3/5">
|
||||
<WorkbenchProject :items="projectItems" title="项目" @click="navTo" />
|
||||
<WorkbenchTrends :items="trendItems" class="mt-5" title="最新动态" />
|
||||
<WorkbenchProject
|
||||
:items="projectItems"
|
||||
title="业务概览"
|
||||
@click="navTo"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full lg:w-2/5">
|
||||
<WorkbenchQuickNav
|
||||
@@ -256,10 +177,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
title="快捷导航"
|
||||
@click="navTo"
|
||||
/>
|
||||
<WorkbenchTodo :items="todoItems" class="mt-5" title="待办事项" />
|
||||
<AnalysisChartCard class="mt-5" title="访问来源">
|
||||
<AnalyticsVisitsSource />
|
||||
</AnalysisChartCard>
|
||||
<WorkbenchTodo :items="todoItems" class="mt-5" title="待办" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { factoryImageApi } from '../api';
|
||||
export const gridOptions = createGridOptions({
|
||||
columns: [
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||
{ field: 'url', title: '产品图', width: 100, slots: { default: 'image' } },
|
||||
{ field: 'url', title: '产品图', width: 120, slots: { default: 'image' } },
|
||||
{ field: 'factory_name', align: 'left', title: '所属工厂', minWidth: 200 },
|
||||
statusColumn,
|
||||
{ field: 'created_at', title: '创建时间', width: 180 },
|
||||
|
||||
@@ -37,7 +37,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
|
||||
/>
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
@@ -60,7 +60,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
|
||||
/>
|
||||
</template>
|
||||
<template #image="{ row }">
|
||||
<Image v-if="row.url" :height="40" :src="row.url" :width="40" />
|
||||
<Image v-if="row.url" :height="72" :src="row.url" :width="72" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
import { createCrudApi } from '#/components/crud';
|
||||
|
||||
export const factoryInfoApi = createCrudApi('factory-info');
|
||||
|
||||
export async function exportFactoryExcel() {
|
||||
return requestClient.get<any>('factory-info/export');
|
||||
}
|
||||
|
||||
export async function importFactoryExcel(rows: Record<string, any>[]) {
|
||||
return requestClient.post<any>('factory-info/import', { rows });
|
||||
}
|
||||
|
||||
/** 工厂下拉 */
|
||||
export async function getFactoryOption() {
|
||||
return (await factoryInfoApi.option()) ?? [];
|
||||
|
||||
@@ -9,7 +9,7 @@ import { factoryInfoApi } from '../api';
|
||||
export const gridOptions = createGridOptions({
|
||||
columns: [
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||
{ field: 'cover', title: '封面', width: 90, slots: { default: 'cover' } },
|
||||
{ field: 'cover', title: '封面', width: 120, slots: { default: 'cover' } },
|
||||
{ field: 'name', align: 'left', title: '工厂名称', minWidth: 200 },
|
||||
{ field: 'classification_name', title: '所属分类', width: 140 },
|
||||
{ field: 'phone', title: '联系电话', width: 140 },
|
||||
|
||||
@@ -1,34 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Image, Switch } from 'ant-design-vue';
|
||||
import { Button, Image, message, Switch, Upload } from 'ant-design-vue';
|
||||
|
||||
import { toImageList, useCrudTable } from '#/components/crud';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import { downloadStyledExcel, parseExcelFile } from '#/util/excel';
|
||||
|
||||
import { factoryInfoApi } from './api';
|
||||
import { exportFactoryExcel, factoryInfoApi, importFactoryExcel } from './api';
|
||||
import FactoryAlbumDrawer from './components/album-drawer.vue';
|
||||
import FactoryInfoFormModal from './components/modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
const { FormModal, Grid, gridApi, hasSelection, removeRows, showModal, toggleStatus } =
|
||||
useCrudTable({
|
||||
api: factoryInfoApi,
|
||||
defaultValues: { status: 0 },
|
||||
formOptions,
|
||||
gridOptions,
|
||||
modalComponent: FactoryInfoFormModal,
|
||||
toFormValues: (row) => ({
|
||||
address: row.address,
|
||||
classification: row.classification,
|
||||
cover: toImageList(row.cover),
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
status: row.status ?? 0,
|
||||
}),
|
||||
});
|
||||
const {
|
||||
FormModal,
|
||||
Grid,
|
||||
gridApi,
|
||||
hasSelection,
|
||||
removeRows,
|
||||
showModal,
|
||||
toggleStatus,
|
||||
} = useCrudTable({
|
||||
api: factoryInfoApi,
|
||||
defaultValues: { status: 0 },
|
||||
formOptions,
|
||||
gridOptions,
|
||||
modalComponent: FactoryInfoFormModal,
|
||||
toFormValues: (row) => ({
|
||||
address: row.address,
|
||||
classification: row.classification,
|
||||
cover: toImageList(row.cover),
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
status: row.status ?? 0,
|
||||
}),
|
||||
});
|
||||
|
||||
const [AlbumDrawer, albumDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: FactoryAlbumDrawer,
|
||||
@@ -38,6 +46,30 @@ function openAlbum(row: Record<string, any>) {
|
||||
albumDrawerApi.setData({ gridApi, record: row });
|
||||
albumDrawerApi.open();
|
||||
}
|
||||
|
||||
async function exportFactory() {
|
||||
const res = await exportFactoryExcel();
|
||||
await downloadStyledExcel(res);
|
||||
message.success('已导出');
|
||||
}
|
||||
|
||||
async function importFactory(file: File) {
|
||||
try {
|
||||
const rows = await parseExcelFile(file);
|
||||
if (rows.length === 0) {
|
||||
message.warning('表格没有数据行');
|
||||
return false;
|
||||
}
|
||||
const res = await importFactoryExcel(rows);
|
||||
message.success(
|
||||
`导入完成:新增 ${res?.created ?? 0},更新 ${res?.updated ?? 0}`,
|
||||
);
|
||||
gridApi.reload();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '导入失败');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -45,7 +77,7 @@ function openAlbum(row: Record<string, any>) {
|
||||
<FormModal />
|
||||
<AlbumDrawer />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
@@ -54,6 +86,11 @@ function openAlbum(row: Record<string, any>) {
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: () => showModal(),
|
||||
},
|
||||
{
|
||||
label: '导出',
|
||||
icon: 'ant-design:download-outlined',
|
||||
onClick: () => exportFactory(),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
@@ -66,9 +103,16 @@ function openAlbum(row: Record<string, any>) {
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<Upload
|
||||
:before-upload="importFactory"
|
||||
:show-upload-list="false"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
>
|
||||
<Button>导入 Excel</Button>
|
||||
</Upload>
|
||||
</template>
|
||||
<template #cover="{ row }">
|
||||
<Image v-if="row.cover" :height="40" :src="row.cover" :width="40" />
|
||||
<Image v-if="row.cover" :height="72" :src="row.cover" :width="72" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { carouselApi } from '../api';
|
||||
export const gridOptions = createGridOptions({
|
||||
columns: [
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||
{ field: 'url', title: '轮播图', width: 140, slots: { default: 'image' } },
|
||||
{ field: 'url', title: '轮播图', width: 160, slots: { default: 'image' } },
|
||||
{ field: 'to_path', align: 'left', title: '跳转路径', minWidth: 260 },
|
||||
{ field: 'sort', title: '排序', width: 90 },
|
||||
statusColumn,
|
||||
|
||||
@@ -32,7 +32,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
|
||||
<Page auto-content-height title="轮播图">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
@@ -55,7 +55,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
|
||||
/>
|
||||
</template>
|
||||
<template #image="{ row }">
|
||||
<Image v-if="row.url" :height="40" :src="row.url" :width="80" />
|
||||
<Image v-if="row.url" :height="60" :src="row.url" :width="120" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Alert, Button, Input, message } from 'ant-design-vue';
|
||||
import { Alert, Button, Input, InputNumber, message } from 'ant-design-vue';
|
||||
|
||||
import { popupData } from '#/components/crud';
|
||||
import { Icon } from '#/components/icon';
|
||||
@@ -21,20 +21,20 @@ interface SpecRow {
|
||||
id?: number;
|
||||
routine: string;
|
||||
specification: string;
|
||||
/** -1 表示不限库存 */
|
||||
stock: number;
|
||||
}
|
||||
|
||||
const record = ref<Record<string, any>>({});
|
||||
const rows = ref<SpecRow[]>([]);
|
||||
|
||||
function emptyRow(): SpecRow {
|
||||
return { dimension: '', routine: '', specification: '' };
|
||||
return { dimension: '', routine: '', specification: '', stock: -1 };
|
||||
}
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onConfirm() {
|
||||
const payload = rows.value.filter(
|
||||
(row) => row.specification.trim() !== '',
|
||||
);
|
||||
const payload = rows.value.filter((row) => row.specification.trim() !== '');
|
||||
if (payload.length === 0) {
|
||||
message.warning('请至少填写一行规格');
|
||||
return;
|
||||
@@ -67,6 +67,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
id: item.id,
|
||||
routine: item.routine ?? '',
|
||||
specification: item.specification ?? '',
|
||||
stock: Number(item.stock ?? -1),
|
||||
}))
|
||||
: [emptyRow()];
|
||||
} finally {
|
||||
@@ -90,7 +91,7 @@ defineExpose(drawerApi);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer class="w-[720px]" :title="`规格报价 - ${record.title ?? ''}`">
|
||||
<Drawer class="w-[860px]" :title="`规格报价 - ${record.title ?? ''}`">
|
||||
<Alert
|
||||
class="mb-3"
|
||||
message="保存是覆盖式的:这里删掉的行提交后会一并删除。价格支持「名称:价格」写法,小程序端只对价格段乘代理商倍率。"
|
||||
@@ -98,9 +99,10 @@ defineExpose(drawerApi);
|
||||
type="info"
|
||||
/>
|
||||
<div class="mb-2 flex gap-2 px-1 text-xs text-muted-foreground">
|
||||
<span class="w-[34%]">规格名称</span>
|
||||
<span class="w-[28%]">尺寸</span>
|
||||
<span class="w-[28%]">常规价</span>
|
||||
<span class="w-[28%]">规格名称</span>
|
||||
<span class="w-[22%]">尺寸</span>
|
||||
<span class="w-[22%]">常规价</span>
|
||||
<span class="w-[16%]">库存(-1不限)</span>
|
||||
<span class="w-[10%]">操作</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -111,19 +113,20 @@ defineExpose(drawerApi);
|
||||
>
|
||||
<Input
|
||||
v-model:value="row.specification"
|
||||
class="w-[34%]"
|
||||
class="w-[28%]"
|
||||
placeholder="如 三人位"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="row.dimension"
|
||||
class="w-[28%]"
|
||||
class="w-[22%]"
|
||||
placeholder="如 2200*950*750"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="row.routine"
|
||||
class="w-[28%]"
|
||||
class="w-[22%]"
|
||||
placeholder="如 3800"
|
||||
/>
|
||||
<InputNumber v-model:value="row.stock" class="w-[16%]" :min="-1" />
|
||||
<Button
|
||||
class="w-[10%]"
|
||||
danger
|
||||
|
||||
@@ -81,6 +81,14 @@ export const modalFormProps: VbenFormProps = {
|
||||
formItemClass: 'col-span-12',
|
||||
label: '图册 PDF',
|
||||
},
|
||||
{
|
||||
component: 'UploadDoc',
|
||||
componentProps: { accept: '.mp4,.mov,.webm' },
|
||||
fieldName: 'video',
|
||||
formItemClass: 'col-span-12',
|
||||
label: '商品视频',
|
||||
help: '小程序详情页可播放;走文档上传接口,建议 mp4',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-12',
|
||||
|
||||
@@ -8,14 +8,7 @@
|
||||
*/
|
||||
import type { ProductRecord } from '#/components/product';
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { preferences } from '@vben/preferences';
|
||||
@@ -30,12 +23,19 @@ import {
|
||||
Segmented,
|
||||
Switch,
|
||||
Table,
|
||||
Upload,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
import { toImageList } from '#/components/crud';
|
||||
import { Icon } from '#/components/icon';
|
||||
import BrandAlbumLoading from '#/components/product/brand-album-loading.vue';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import {
|
||||
downloadStyledExcel,
|
||||
parseExcelWorkbook,
|
||||
pickExcelSheet,
|
||||
} from '#/util/excel';
|
||||
import { thumbUrl, watermarkUrl } from '#/util/media';
|
||||
import GoodsCategoryTree from '#/views/goods/components/category-tree.vue';
|
||||
|
||||
@@ -73,7 +73,7 @@ const CARD_WIDTH = 240;
|
||||
const waterfallRef = ref<HTMLElement | null>(null);
|
||||
let columnHeights: number[] = [];
|
||||
let lastRenderedIndex = 0;
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let resizeTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: CatalogueFormModal,
|
||||
@@ -103,7 +103,7 @@ const listParams = computed(() => ({
|
||||
}));
|
||||
|
||||
const tableColumns = [
|
||||
{ title: '封面', dataIndex: 'cover', key: 'cover', width: 110 },
|
||||
{ title: '封面', dataIndex: 'cover', key: 'cover', width: 130 },
|
||||
{ title: '编号', dataIndex: 'identifier', key: 'identifier', width: 120 },
|
||||
{ title: '介绍', dataIndex: 'alias', key: 'alias', ellipsis: true },
|
||||
{
|
||||
@@ -145,7 +145,7 @@ function onCategorySelect(id: number | undefined) {
|
||||
categoryId.value = id;
|
||||
}
|
||||
|
||||
function onModeChange(value: string | number) {
|
||||
function onModeChange(value: number | string) {
|
||||
const next = String(value) as ShowType;
|
||||
showType.value = next;
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
@@ -158,7 +158,7 @@ function onModeChange(value: string | number) {
|
||||
}
|
||||
}
|
||||
|
||||
function onStatusFilterChange(value: string | number) {
|
||||
function onStatusFilterChange(value: number | string) {
|
||||
const raw = String(value);
|
||||
statusFilter.value = raw === 'all' ? undefined : Number(raw);
|
||||
}
|
||||
@@ -174,6 +174,7 @@ function toFormValues(row?: ProductRecord) {
|
||||
id: row.id,
|
||||
identifier: row.identifier,
|
||||
pdf: row.pdf ?? '',
|
||||
video: row.video ?? '',
|
||||
price: row.price,
|
||||
status: row.status ?? 0,
|
||||
title: row.title,
|
||||
@@ -211,6 +212,42 @@ async function toggleStatus(row: ProductRecord, enabled: boolean) {
|
||||
row.status = enabled ? 0 : 1;
|
||||
}
|
||||
|
||||
/** 导出商品 xlsx(ExcelJS 美化) */
|
||||
async function exportCatalogue() {
|
||||
const res = await requestClient.get<any>('catalogue/export');
|
||||
await downloadStyledExcel(res);
|
||||
message.success('已导出');
|
||||
}
|
||||
|
||||
/** 导入商品 xlsx:第一张商品,第二张报价单按编号挂回去 */
|
||||
async function importCatalogue(file: File) {
|
||||
try {
|
||||
const book = await parseExcelWorkbook(file);
|
||||
const rows = pickExcelSheet(book, ['商品', '商品图册']);
|
||||
const productRows = rows.length > 0 ? rows : (book[0]?.rows ?? []);
|
||||
const priceSheets = pickExcelSheet(book, ['报价单', '关联报价单', '规格']);
|
||||
if (productRows.length === 0 && priceSheets.length === 0) {
|
||||
message.warning('表格没有数据行');
|
||||
return false;
|
||||
}
|
||||
const res = await requestClient.post<any>('catalogue/import', {
|
||||
price_sheets: priceSheets,
|
||||
rows: productRows,
|
||||
});
|
||||
const priceHint =
|
||||
(res?.price_created ?? 0) + (res?.price_updated ?? 0) > 0
|
||||
? `;报价单新增 ${res?.price_created ?? 0},更新 ${res?.price_updated ?? 0}`
|
||||
: '';
|
||||
message.success(
|
||||
`导入完成:新增 ${res?.created ?? 0},更新 ${res?.updated ?? 0}${priceHint}`,
|
||||
);
|
||||
refresh();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '导入失败');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
listLoading.value = true;
|
||||
try {
|
||||
@@ -438,10 +475,12 @@ onUnmounted(() => {
|
||||
allow-clear
|
||||
class="w-[240px]"
|
||||
placeholder="搜索商品名称 / 编号"
|
||||
@change="(e: any) => (e.target.value ? undefined : applyKeyword(''))"
|
||||
@change="
|
||||
(e: any) => (e.target.value ? undefined : applyKeyword(''))
|
||||
"
|
||||
@search="applyKeyword"
|
||||
/>
|
||||
<div class="ml-auto">
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
@@ -450,8 +489,20 @@ onUnmounted(() => {
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: () => showModal(),
|
||||
},
|
||||
{
|
||||
label: '导出',
|
||||
icon: 'ant-design:download-outlined',
|
||||
onClick: () => exportCatalogue(),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<Upload
|
||||
:before-upload="importCatalogue"
|
||||
:show-upload-list="false"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
>
|
||||
<Button>导入 Excel</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -509,7 +560,9 @@ onUnmounted(() => {
|
||||
<div>{{ row.routine ?? '-' }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!record.price_sheet || record.price_sheet.length === 0"
|
||||
v-if="
|
||||
!record.price_sheet || record.price_sheet.length === 0
|
||||
"
|
||||
class="price-grid__empty"
|
||||
>
|
||||
暂无规格报价
|
||||
@@ -598,7 +651,11 @@ onUnmounted(() => {
|
||||
{{ record.identifier || record.title || '未命名' }}
|
||||
</div>
|
||||
<div class="waterfall-card__actions" @click.stop>
|
||||
<Button size="small" type="link" @click="showModal(record, true)">
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
@click="showModal(record, true)"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="small" type="link" @click="openAlbum(record)">
|
||||
@@ -623,20 +680,20 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.catalogue-cover {
|
||||
height: 72px;
|
||||
width: 72px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.price-grid {
|
||||
min-width: 420px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.price-grid__header,
|
||||
@@ -647,16 +704,16 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.price-grid__header {
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted) / 35%);
|
||||
}
|
||||
|
||||
.price-grid__header > div,
|
||||
.price-grid__row > div {
|
||||
padding: 6px 10px;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
word-break: break-word;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.price-grid__header > div:last-child,
|
||||
@@ -665,8 +722,8 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.price-grid__row {
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.price-grid__empty {
|
||||
@@ -690,9 +747,9 @@ onUnmounted(() => {
|
||||
width: 240px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
@@ -700,14 +757,14 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.waterfall-card:hover {
|
||||
border-color: hsl(var(--primary) / 0.45);
|
||||
box-shadow: 0 10px 28px hsl(var(--foreground) / 0.08);
|
||||
border-color: hsl(var(--primary) / 45%);
|
||||
box-shadow: 0 10px 28px hsl(var(--foreground) / 8%);
|
||||
}
|
||||
|
||||
.waterfall-card__img {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
background: hsl(var(--muted) / 35%);
|
||||
}
|
||||
|
||||
.waterfall-card__img img {
|
||||
@@ -718,9 +775,9 @@ onUnmounted(() => {
|
||||
|
||||
.waterfall-card__empty {
|
||||
display: flex;
|
||||
height: 160px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 160px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
@@ -730,9 +787,9 @@ onUnmounted(() => {
|
||||
|
||||
.waterfall-card__id {
|
||||
overflow: hidden;
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export const gridOptions = createGridOptions({
|
||||
{ width: 50, treeNode: true },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 90 },
|
||||
{ field: 'name', align: 'left', title: '分类名称', minWidth: 200 },
|
||||
{ field: 'url', title: '分类图', width: 100, slots: { default: 'image' } },
|
||||
{ field: 'url', title: '分类图', width: 120, slots: { default: 'image' } },
|
||||
{ field: 'sort', title: '排序', width: 90 },
|
||||
statusColumn,
|
||||
{ field: 'created_at', title: '创建时间', width: 180 },
|
||||
|
||||
@@ -14,6 +14,7 @@ import { gridOptions } from './config/table';
|
||||
const {
|
||||
FormModal,
|
||||
Grid,
|
||||
gridApi,
|
||||
hasSelection,
|
||||
removeRows,
|
||||
showModal,
|
||||
@@ -38,13 +39,23 @@ const {
|
||||
function addChild(row: Record<string, any>) {
|
||||
showModal({ pid: row.id, sort: 0, status: 0 }, false);
|
||||
}
|
||||
|
||||
/** 一键展开整棵分类树,方便一次看清层级 */
|
||||
function expandAll() {
|
||||
gridApi.grid?.setAllTreeExpand(true);
|
||||
}
|
||||
|
||||
/** 一键收起,只留根节点 */
|
||||
function collapseAll() {
|
||||
gridApi.grid?.setAllTreeExpand(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="商品分类">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
@@ -53,6 +64,14 @@ function addChild(row: Record<string, any>) {
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: () => showModal(),
|
||||
},
|
||||
{
|
||||
label: '一键展开',
|
||||
onClick: expandAll,
|
||||
},
|
||||
{
|
||||
label: '一键收起',
|
||||
onClick: collapseAll,
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
@@ -67,7 +86,7 @@ function addChild(row: Record<string, any>) {
|
||||
/>
|
||||
</template>
|
||||
<template #image="{ row }">
|
||||
<Image v-if="row.url" :height="40" :src="row.url" :width="40" />
|
||||
<Image v-if="row.url" :height="72" :src="row.url" :width="72" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { imageApi } from '../api';
|
||||
export const gridOptions = createGridOptions({
|
||||
columns: [
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||
{ field: 'url', title: '图片', width: 100, slots: { default: 'image' } },
|
||||
{ field: 'url', title: '图片', width: 120, slots: { default: 'image' } },
|
||||
{ field: 'catalogue_id', title: '商品ID', width: 100 },
|
||||
{ field: 'type', title: '类型', width: 110, slots: { default: 'type' } },
|
||||
statusColumn,
|
||||
|
||||
@@ -32,7 +32,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
|
||||
<Page auto-content-height title="商品相册">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
@@ -55,7 +55,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
|
||||
/>
|
||||
</template>
|
||||
<template #image="{ row }">
|
||||
<Image v-if="row.url" :height="40" :src="row.url" :width="40" />
|
||||
<Image v-if="row.url" :height="72" :src="row.url" :width="72" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template #type="{ row }">
|
||||
|
||||
51
apps/web-antd/src/views/goods/package/api/index.ts
Normal file
51
apps/web-antd/src/views/goods/package/api/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
import { createCrudApi } from '#/components/crud';
|
||||
|
||||
const prefix = 'package/';
|
||||
|
||||
/** 套餐基础 CRUD */
|
||||
export const packageApi = createCrudApi('package');
|
||||
|
||||
/**
|
||||
* 保存搭配明细。package_amount 按元提交,后端转分并重算原价。
|
||||
*/
|
||||
export function savePackageItems(data: {
|
||||
id: number;
|
||||
items: Record<string, any>[];
|
||||
package_amount?: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}save-items`, data);
|
||||
}
|
||||
|
||||
export interface PackageCatalogSheet {
|
||||
dimension: string;
|
||||
id: number;
|
||||
selectable: boolean;
|
||||
specification: string;
|
||||
unit_price: number;
|
||||
unit_price_text: string;
|
||||
}
|
||||
|
||||
export interface PackageCatalogItem {
|
||||
cover: string;
|
||||
id: number;
|
||||
identifier: string;
|
||||
sheets: PackageCatalogSheet[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搭配搜索:商品封面 + 规格。selectable=false 的规格前端不可点。
|
||||
*/
|
||||
export async function searchPackageCatalog(params: {
|
||||
keyword: string;
|
||||
limit?: number;
|
||||
}): Promise<PackageCatalogItem[]> {
|
||||
const res = await requestClient.get<any>(`${prefix}search-catalog`, {
|
||||
params: {
|
||||
keyword: params.keyword,
|
||||
limit: params.limit ?? 20,
|
||||
},
|
||||
});
|
||||
return res?.items ?? [];
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 套餐搭配搜索:在抽屉里直接展开卡片,不走 Popover 挂到 body。
|
||||
* Vben 抽屉会拦截外部 pointerdown,挂到 body 的浮层看起来能点、实际点不到。
|
||||
*/
|
||||
import type { PackageCatalogItem, PackageCatalogSheet } from '../api';
|
||||
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import { Empty, Input, Spin } from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
import { thumbUrl } from '#/util/media';
|
||||
|
||||
import { searchPackageCatalog } from '../api';
|
||||
|
||||
defineOptions({
|
||||
name: 'PackageCatalogPicker',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 点规格时带上的数量,由外层数量框控制 */
|
||||
quantity?: number;
|
||||
}>(),
|
||||
{ quantity: 1 },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
pick: [
|
||||
payload: {
|
||||
catalogue_id: number;
|
||||
cover: string;
|
||||
price_sheet_id: number;
|
||||
quantity: number;
|
||||
specification: string;
|
||||
title: string;
|
||||
unit_yuan: number;
|
||||
},
|
||||
];
|
||||
}>();
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null);
|
||||
const open = ref(false);
|
||||
const keyword = ref('');
|
||||
const loading = ref(false);
|
||||
const items = ref<PackageCatalogItem[]>([]);
|
||||
const activeIndex = ref(-1);
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let controller: AbortController | undefined;
|
||||
|
||||
/** 远程搜商品,过期请求用 AbortController 丢掉 */
|
||||
async function fetchItems() {
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await searchPackageCatalog({
|
||||
keyword: keyword.value.trim(),
|
||||
limit: 20,
|
||||
});
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
items.value = list;
|
||||
activeIndex.value = list.length > 0 ? 0 : -1;
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFetch() {
|
||||
open.value = true;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(fetchItems, 300);
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
open.value = true;
|
||||
if (items.value.length === 0 && !loading.value) {
|
||||
fetchItems();
|
||||
}
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
open.value = false;
|
||||
activeIndex.value = -1;
|
||||
}
|
||||
|
||||
function sheetLabel(sheet: PackageCatalogSheet) {
|
||||
const spec = sheet.specification || '规格';
|
||||
return sheet.dimension ? `${spec} · ${sheet.dimension}` : spec;
|
||||
}
|
||||
|
||||
/** 只有 selectable 的规格才能加入,避免再撞上「缺少有效报价」 */
|
||||
function pickSheet(item: PackageCatalogItem, sheet: PackageCatalogSheet) {
|
||||
if (!sheet.selectable) {
|
||||
return;
|
||||
}
|
||||
emit('pick', {
|
||||
catalogue_id: item.id,
|
||||
cover: item.cover || '',
|
||||
price_sheet_id: sheet.id,
|
||||
quantity: Math.max(1, Number(props.quantity || 1)),
|
||||
specification: sheetLabel(sheet),
|
||||
title: item.identifier ? `${item.title} / ${item.identifier}` : item.title,
|
||||
unit_yuan: Number(sheet.unit_price_text || 0),
|
||||
});
|
||||
}
|
||||
|
||||
function firstSelectable(item?: PackageCatalogItem) {
|
||||
return item?.sheets.find((sheet) => sheet.selectable);
|
||||
}
|
||||
|
||||
function move(step: number) {
|
||||
const total = items.value.length;
|
||||
if (total === 0) {
|
||||
return;
|
||||
}
|
||||
activeIndex.value = (activeIndex.value + step + total) % total;
|
||||
nextTick(() => {
|
||||
rootRef.value
|
||||
?.querySelector(`[data-package-catalog="${activeIndex.value}"]`)
|
||||
?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (!open.value && ['ArrowDown', 'Enter'].includes(event.key)) {
|
||||
openPanel();
|
||||
return;
|
||||
}
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
move(1);
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
move(-1);
|
||||
break;
|
||||
}
|
||||
case 'Enter': {
|
||||
if (event.isComposing) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const item = items.value[activeIndex.value];
|
||||
const sheet = firstSelectable(item);
|
||||
if (item && sheet) {
|
||||
pickSheet(item, sheet);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Escape': {
|
||||
closePanel();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 点在搜索卡片外面再收起,点规格加入后保持展开方便连加 */
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
const target = event.target as Node | null;
|
||||
if (rootRef.value && target && !rootRef.value.contains(target)) {
|
||||
closePanel();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
|
||||
clearTimeout(timer);
|
||||
controller?.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
class="flex-1"
|
||||
placeholder="搜索商品名称 / 编号"
|
||||
@focus="openPanel"
|
||||
@input="scheduleFetch"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon icon="ant-design:search-outlined" />
|
||||
</template>
|
||||
</Input>
|
||||
<slot name="suffix"></slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="open"
|
||||
class="mt-2 rounded-lg border border-border bg-background p-2 shadow-md"
|
||||
>
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="items.length === 0" class="py-6">
|
||||
<Empty
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
description="没有匹配的商品"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex max-h-[420px] flex-col gap-2 overflow-y-auto">
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
class="flex gap-3 rounded-lg border p-2 transition-colors"
|
||||
:class="[
|
||||
index === activeIndex
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-background',
|
||||
]"
|
||||
:data-package-catalog="index"
|
||||
@mouseenter="activeIndex = index"
|
||||
>
|
||||
<div class="w-[132px] shrink-0">
|
||||
<img
|
||||
v-if="item.cover"
|
||||
:alt="item.title"
|
||||
:src="thumbUrl(item.cover, 240)"
|
||||
class="mb-1.5 h-[88px] w-full rounded-md object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="mb-1.5 flex h-[88px] items-center justify-center rounded-md bg-muted text-xs text-muted-foreground"
|
||||
>
|
||||
无封面
|
||||
</div>
|
||||
<div class="truncate text-sm font-medium" :title="item.title">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="truncate text-xs text-muted-foreground">
|
||||
{{ item.identifier || '无编号' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 space-y-1.5">
|
||||
<div
|
||||
v-if="item.sheets.length === 0"
|
||||
class="py-4 text-xs text-muted-foreground"
|
||||
>
|
||||
暂无规格
|
||||
</div>
|
||||
<button
|
||||
v-for="sheet in item.sheets"
|
||||
:key="sheet.id"
|
||||
type="button"
|
||||
:disabled="!sheet.selectable"
|
||||
class="flex w-full items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-left text-sm transition-colors"
|
||||
:class="[
|
||||
sheet.selectable
|
||||
? 'cursor-pointer border-border hover:border-primary hover:bg-primary/10'
|
||||
: 'cursor-not-allowed border-border/60 bg-muted/40 text-muted-foreground',
|
||||
]"
|
||||
:title="
|
||||
sheet.selectable ? '点击加入套餐' : '缺少有效报价,不可加入'
|
||||
"
|
||||
@click="pickSheet(item, sheet)"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ sheetLabel(sheet) }}</span>
|
||||
<span
|
||||
class="shrink-0 text-xs"
|
||||
:class="[
|
||||
sheet.selectable
|
||||
? 'font-medium text-primary'
|
||||
: 'text-muted-foreground',
|
||||
]"
|
||||
>
|
||||
{{
|
||||
sheet.selectable ? `¥${sheet.unit_price_text}` : '无报价'
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
<div class="mt-2 text-xs text-muted-foreground">
|
||||
点右侧规格加入 · 无报价不可选 · ↑↓ 切换商品,回车加入第一条有效规格
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,228 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 套餐搭配抽屉:气泡卡片选商品+规格,点报价单直接加入。
|
||||
* 改一行立刻重算原价,套餐报价按原价差额自动补,仍可手改。
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Button, Image, InputNumber, message, Table } from 'ant-design-vue';
|
||||
|
||||
import { popupData } from '#/components/crud';
|
||||
import { thumbUrl } from '#/util/media';
|
||||
|
||||
import { packageApi, savePackageItems } from '../api';
|
||||
import CatalogPicker from './catalog-picker.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'PackageItemsDrawer',
|
||||
});
|
||||
|
||||
interface ItemRow {
|
||||
catalogue_id: number;
|
||||
cover: string;
|
||||
material_key: string;
|
||||
price_sheet_id: number;
|
||||
quantity: number;
|
||||
specification: string;
|
||||
title: string;
|
||||
unit_yuan: number;
|
||||
}
|
||||
|
||||
const record = ref<Record<string, any>>({});
|
||||
const items = ref<ItemRow[]>([]);
|
||||
const quoteYuan = ref(0);
|
||||
const lastOriginal = ref(0);
|
||||
const pickQty = ref(1);
|
||||
|
||||
const originalYuan = computed(() =>
|
||||
items.value.reduce((sum, row) => sum + row.unit_yuan * row.quantity, 0),
|
||||
);
|
||||
const discountYuan = computed(() =>
|
||||
Math.max(0, originalYuan.value - quoteYuan.value),
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{ title: '封面', key: 'cover', width: 72 },
|
||||
{ title: '商品', dataIndex: 'title' },
|
||||
{ title: '规格', dataIndex: 'specification', width: 180 },
|
||||
{ title: '单价', key: 'unit', width: 90 },
|
||||
{ title: '数量', key: 'qty', width: 110 },
|
||||
{ title: '操作', key: 'action', width: 70 },
|
||||
];
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onConfirm() {
|
||||
if (items.value.some((row) => !row.price_sheet_id || row.unit_yuan <= 0)) {
|
||||
message.warning('存在缺少有效报价的规格,请删掉后再保存');
|
||||
return;
|
||||
}
|
||||
drawerApi.lock();
|
||||
try {
|
||||
await savePackageItems({
|
||||
id: Number(record.value.id),
|
||||
items: items.value.map((row, index) => ({
|
||||
catalogue_id: row.catalogue_id,
|
||||
price_sheet_id: row.price_sheet_id,
|
||||
material_key: row.material_key,
|
||||
quantity: row.quantity,
|
||||
sort: index,
|
||||
})),
|
||||
package_amount: Number(quoteYuan.value.toFixed(2)),
|
||||
});
|
||||
message.success('搭配已保存');
|
||||
popupData(drawerApi).gridApi?.reload();
|
||||
drawerApi.close();
|
||||
} finally {
|
||||
drawerApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) return;
|
||||
record.value = popupData(drawerApi).record ?? {};
|
||||
drawerApi.setState({ loading: true });
|
||||
try {
|
||||
const detail = await packageApi.detail(record.value.id);
|
||||
items.value = (detail.items || []).map((row: any) => ({
|
||||
catalogue_id: Number(row.catalogue_id),
|
||||
cover: row.cover || '',
|
||||
material_key: row.material_key || 'routine',
|
||||
price_sheet_id: Number(row.price_sheet_id),
|
||||
quantity: Number(row.quantity || 1),
|
||||
specification: row.specification || '',
|
||||
title: row.title || '',
|
||||
unit_yuan: Number(row.unit_price_text || 0),
|
||||
}));
|
||||
lastOriginal.value = originalYuan.value;
|
||||
quoteYuan.value = Number(detail.package_amount_text || 0);
|
||||
} finally {
|
||||
drawerApi.setState({ loading: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 原价变了就把差额补到套餐报价上 */
|
||||
function applyOriginalDiff() {
|
||||
const next = originalYuan.value;
|
||||
quoteYuan.value = Math.max(
|
||||
0,
|
||||
Number((quoteYuan.value + (next - lastOriginal.value)).toFixed(2)),
|
||||
);
|
||||
lastOriginal.value = next;
|
||||
}
|
||||
|
||||
function changeQty(index: number, qty: number) {
|
||||
const row = items.value[index];
|
||||
if (!row) return;
|
||||
row.quantity = Math.max(1, qty);
|
||||
applyOriginalDiff();
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
items.value.splice(index, 1);
|
||||
applyOriginalDiff();
|
||||
}
|
||||
|
||||
/** 同一规格再点一次就加数量,避免表格里堆重复行 */
|
||||
function addPicked(payload: {
|
||||
catalogue_id: number;
|
||||
cover: string;
|
||||
price_sheet_id: number;
|
||||
quantity: number;
|
||||
specification: string;
|
||||
title: string;
|
||||
unit_yuan: number;
|
||||
}) {
|
||||
if (payload.unit_yuan <= 0) {
|
||||
message.warning('该规格缺少有效报价');
|
||||
return;
|
||||
}
|
||||
const exist = items.value.find(
|
||||
(row) => row.price_sheet_id === payload.price_sheet_id,
|
||||
);
|
||||
if (exist) {
|
||||
exist.quantity += Math.max(1, payload.quantity);
|
||||
applyOriginalDiff();
|
||||
return;
|
||||
}
|
||||
items.value.push({
|
||||
catalogue_id: payload.catalogue_id,
|
||||
cover: payload.cover,
|
||||
material_key: 'routine',
|
||||
price_sheet_id: payload.price_sheet_id,
|
||||
quantity: Math.max(1, payload.quantity),
|
||||
specification: payload.specification,
|
||||
title: payload.title,
|
||||
unit_yuan: payload.unit_yuan,
|
||||
});
|
||||
applyOriginalDiff();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer class="w-[920px]" title="搭配单品">
|
||||
<div class="mb-4 grid grid-cols-3 gap-3 text-sm">
|
||||
<div class="rounded border border-border p-3">
|
||||
<div class="text-muted-foreground">原价(自动)</div>
|
||||
<div class="text-lg font-semibold">¥{{ originalYuan.toFixed(2) }}</div>
|
||||
</div>
|
||||
<div class="rounded border border-border p-3">
|
||||
<div class="text-muted-foreground">套餐报价(可改)</div>
|
||||
<InputNumber
|
||||
v-model:value="quoteYuan"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="rounded border border-border p-3">
|
||||
<div class="text-muted-foreground">优惠</div>
|
||||
<div class="text-lg font-semibold text-primary">
|
||||
¥{{ discountYuan.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<CatalogPicker :quantity="pickQty" @pick="addPicked">
|
||||
<template #suffix>
|
||||
<InputNumber v-model:value="pickQty" :min="1" class="w-[90px]" />
|
||||
</template>
|
||||
</CatalogPicker>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="items"
|
||||
:pagination="false"
|
||||
:row-key="(_row: ItemRow, index?: number) => String(index)"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record: row, index }">
|
||||
<template v-if="column.key === 'cover'">
|
||||
<Image
|
||||
v-if="row.cover"
|
||||
:height="40"
|
||||
:src="thumbUrl(row.cover, 80)"
|
||||
:width="40"
|
||||
/>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'unit'">
|
||||
¥{{ Number(row.unit_yuan).toFixed(2) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'qty'">
|
||||
<InputNumber
|
||||
:min="1"
|
||||
:value="row.quantity"
|
||||
@change="(val: any) => changeQty(index, Number(val || 1))"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button danger type="link" @click="removeItem(index)">删除</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Drawer>
|
||||
</template>
|
||||
28
apps/web-antd/src/views/goods/package/components/modal.vue
Normal file
28
apps/web-antd/src/views/goods/package/components/modal.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 套餐新增/编辑弹窗。报价按元提交,封面取第一张。
|
||||
*/
|
||||
import { firstImage, useCrudModal } from '#/components/crud';
|
||||
|
||||
import { packageApi } from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
defineOptions({
|
||||
name: 'PackageFormModal',
|
||||
});
|
||||
|
||||
const { Form, isUpdate, Modal } = useCrudModal({
|
||||
api: packageApi,
|
||||
formProps: modalFormProps,
|
||||
transform: (values) => ({
|
||||
...values,
|
||||
cover: firstImage(values.cover),
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}套餐`" class="w-[40%]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
11
apps/web-antd/src/views/goods/package/config/constants.ts
Normal file
11
apps/web-antd/src/views/goods/package/config/constants.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/** 上架状态:0=上架 1=下架,与其它业务模块一致 */
|
||||
export const PACKAGE_STATUS_OPTIONS = [
|
||||
{ label: '上架', value: 0 },
|
||||
{ label: '下架', value: 1 },
|
||||
];
|
||||
|
||||
/** 是否首页热门 */
|
||||
export const HOT_OPTIONS = [
|
||||
{ label: '热门', value: 1 },
|
||||
{ label: '普通', value: 0 },
|
||||
];
|
||||
86
apps/web-antd/src/views/goods/package/config/form.ts
Normal file
86
apps/web-antd/src/views/goods/package/config/form.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { PACKAGE_STATUS_OPTIONS } from './constants';
|
||||
|
||||
/**
|
||||
* 套餐主表单。报价按元录入,提交时字段名仍是 package_amount,后端转分。
|
||||
*/
|
||||
export const modalFormProps: VbenFormProps = {
|
||||
commonConfig: {
|
||||
componentProps: { class: 'w-full' },
|
||||
formItemClass: 'col-span-12',
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
dependencies: { show: false, triggerFields: ['id'] },
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: { placeholder: '套餐名称' },
|
||||
fieldName: 'name',
|
||||
label: '名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'UploadImage',
|
||||
componentProps: { maxCount: 1, multiple: false },
|
||||
defaultValue: [],
|
||||
fieldName: 'cover',
|
||||
label: '封面',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: { placeholder: '副标题,如 客厅四件套' },
|
||||
fieldName: 'subtitle',
|
||||
label: '副标题',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
placeholder: '独立套餐报价,单位元',
|
||||
},
|
||||
fieldName: 'package_amount',
|
||||
label: '套餐报价',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: { min: 0, placeholder: '数字越小越靠前' },
|
||||
defaultValue: 0,
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '普通', value: 0 },
|
||||
{ label: '首页热门', value: 1 },
|
||||
],
|
||||
},
|
||||
defaultValue: 0,
|
||||
fieldName: 'is_hot',
|
||||
label: '热门',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: { options: PACKAGE_STATUS_OPTIONS },
|
||||
defaultValue: 0,
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
},
|
||||
{
|
||||
component: 'VbenTextarea',
|
||||
componentProps: { rows: 2, placeholder: '备注' },
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-12',
|
||||
};
|
||||
39
apps/web-antd/src/views/goods/package/config/search.ts
Normal file
39
apps/web-antd/src/views/goods/package/config/search.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { HOT_OPTIONS, PACKAGE_STATUS_OPTIONS } from './constants';
|
||||
|
||||
export const formOptions: VbenFormProps = {
|
||||
collapsed: false,
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: { placeholder: '套餐名称' },
|
||||
fieldName: 'name',
|
||||
label: '名称',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: HOT_OPTIONS,
|
||||
placeholder: '是否热门',
|
||||
},
|
||||
fieldName: 'is_hot',
|
||||
label: '热门',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: PACKAGE_STATUS_OPTIONS,
|
||||
placeholder: '状态',
|
||||
},
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
},
|
||||
],
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: { content: '查询' },
|
||||
submitOnChange: true,
|
||||
submitOnEnter: false,
|
||||
};
|
||||
25
apps/web-antd/src/views/goods/package/config/table.ts
Normal file
25
apps/web-antd/src/views/goods/package/config/table.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
actionColumn,
|
||||
createGridOptions,
|
||||
statusColumn,
|
||||
} from '#/components/crud';
|
||||
|
||||
import { packageApi } from '../api';
|
||||
|
||||
export const gridOptions = createGridOptions({
|
||||
columns: [
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 70 },
|
||||
{ field: 'cover', title: '封面', width: 140, slots: { default: 'cover' } },
|
||||
{ field: 'name', align: 'left', title: '名称', minWidth: 160 },
|
||||
{ field: 'item_count', title: '件数', width: 70 },
|
||||
{ field: 'original_amount_text', title: '原价', width: 110 },
|
||||
{ field: 'package_amount_text', title: '套餐价', width: 110 },
|
||||
{ field: 'discount_amount_text', title: '优惠', width: 110 },
|
||||
{ field: 'is_hot', title: '热门', width: 90, slots: { default: 'hot' } },
|
||||
{ field: 'sort', title: '排序', width: 80 },
|
||||
statusColumn,
|
||||
{ field: 'created_at', title: '创建时间', width: 170 },
|
||||
actionColumn(220),
|
||||
],
|
||||
query: (params) => packageApi.list(params),
|
||||
});
|
||||
145
apps/web-antd/src/views/goods/package/index.vue
Normal file
145
apps/web-antd/src/views/goods/package/index.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 套餐搭配列表:封面、原价/套餐价/优惠、热门与上下架。
|
||||
*/
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Image, Switch } from 'ant-design-vue';
|
||||
|
||||
import { toImageList, useCrudTable } from '#/components/crud';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { packageApi } from './api';
|
||||
import PackageItemsDrawer from './components/items-drawer.vue';
|
||||
import PackageFormModal from './components/modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
defineOptions({ name: 'GoodsPackage' });
|
||||
|
||||
const {
|
||||
FormModal,
|
||||
Grid,
|
||||
gridApi,
|
||||
hasSelection,
|
||||
removeRows,
|
||||
showModal,
|
||||
toggleStatus,
|
||||
} = useCrudTable({
|
||||
api: packageApi,
|
||||
defaultValues: { sort: 0, status: 0, is_hot: 0, package_amount: 0 },
|
||||
formOptions,
|
||||
gridOptions,
|
||||
modalComponent: PackageFormModal,
|
||||
toFormValues: (row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
subtitle: row.subtitle,
|
||||
remark: row.remark,
|
||||
cover: toImageList(row.cover),
|
||||
package_amount: Number(row.package_amount_text || 0),
|
||||
sort: row.sort ?? 0,
|
||||
status: row.status ?? 0,
|
||||
is_hot: row.is_hot ?? 0,
|
||||
}),
|
||||
});
|
||||
|
||||
const [ItemsDrawer, itemsDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: PackageItemsDrawer,
|
||||
});
|
||||
|
||||
function openItems(row: Record<string, any>) {
|
||||
itemsDrawerApi.setData({ record: row, gridApi });
|
||||
itemsDrawerApi.open();
|
||||
}
|
||||
|
||||
/** 热门开关单独改,避免走上下架 */
|
||||
async function toggleHot(row: Record<string, any>, checked: boolean) {
|
||||
await packageApi.update({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
is_hot: checked ? 1 : 0,
|
||||
});
|
||||
gridApi.query();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="套餐搭配">
|
||||
<FormModal />
|
||||
<ItemsDrawer />
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增套餐',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: () => showModal(),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
disabled: !hasSelection,
|
||||
popConfirm: {
|
||||
title: '确定删除勾选的套餐吗?',
|
||||
confirm: () => removeRows(),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #cover="{ row }">
|
||||
<Image v-if="row.cover" :height="60" :src="row.cover" :width="96" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template #hot="{ row }">
|
||||
<Switch
|
||||
:checked="row.is_hot === 1"
|
||||
checked-children="热门"
|
||||
un-checked-children="普通"
|
||||
@change="(checked: any) => toggleHot(row, !!checked)"
|
||||
/>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Switch
|
||||
:checked="row.status === 0"
|
||||
checked-children="上架"
|
||||
un-checked-children="下架"
|
||||
@change="(checked: any) => toggleStatus(row, !!checked)"
|
||||
/>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '搭配',
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
onClick: () => openItems(row),
|
||||
},
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
onClick: () => showModal(row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除该套餐吗?',
|
||||
confirm: () => removeRows(row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -39,7 +39,7 @@ const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
|
||||
/>
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
|
||||
@@ -7,14 +7,7 @@
|
||||
*/
|
||||
import type { ProductRecord } from '#/components/product';
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
import { preferences } from '@vben/preferences';
|
||||
@@ -64,7 +57,7 @@ const waterfallRef = ref<HTMLElement | null>(null);
|
||||
const scrollRef = ref<HTMLElement | null>(null);
|
||||
let columnHeights: number[] = [];
|
||||
let lastRenderedIndex = 0;
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let resizeTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
|
||||
const [SpecDrawer, specDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: CatalogueSpecDrawer,
|
||||
@@ -86,7 +79,7 @@ const tableColumns = [
|
||||
title: '封面',
|
||||
dataIndex: 'cover',
|
||||
key: 'cover',
|
||||
width: 110,
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
title: '编号',
|
||||
@@ -122,7 +115,7 @@ function onCategorySelect(id: number | undefined) {
|
||||
categoryId.value = id;
|
||||
}
|
||||
|
||||
function onModeChange(value: string | number) {
|
||||
function onModeChange(value: number | string) {
|
||||
const next = String(value) as ShowType;
|
||||
showType.value = next;
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
@@ -360,7 +353,9 @@ onUnmounted(() => {
|
||||
style="display: none"
|
||||
/>
|
||||
|
||||
<div class="quote-page border-border bg-card flex min-h-0 flex-1 gap-3 overflow-hidden rounded-lg border p-3">
|
||||
<div
|
||||
class="quote-page border-border bg-card flex min-h-0 flex-1 gap-3 overflow-hidden rounded-lg border p-3"
|
||||
>
|
||||
<div class="hidden w-[220px] shrink-0 md:block">
|
||||
<GoodsCategoryTree class="h-full" @select="onCategorySelect" />
|
||||
</div>
|
||||
@@ -377,7 +372,9 @@ onUnmounted(() => {
|
||||
allow-clear
|
||||
class="w-[240px]"
|
||||
placeholder="搜索商品名称 / 编号"
|
||||
@change="(e: any) => (e.target.value ? undefined : applyKeyword(''))"
|
||||
@change="
|
||||
(e: any) => (e.target.value ? undefined : applyKeyword(''))
|
||||
"
|
||||
@search="applyKeyword"
|
||||
/>
|
||||
</div>
|
||||
@@ -416,7 +413,9 @@ onUnmounted(() => {
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'alias'">
|
||||
<span :title="record.alias">{{ record.alias || record.title || '-' }}</span>
|
||||
<span :title="record.alias">{{
|
||||
record.alias || record.title || '-'
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'price_sheet'">
|
||||
<div class="price-grid">
|
||||
@@ -435,7 +434,9 @@ onUnmounted(() => {
|
||||
<div>{{ row.routine ?? '-' }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!record.price_sheet || record.price_sheet.length === 0"
|
||||
v-if="
|
||||
!record.price_sheet || record.price_sheet.length === 0
|
||||
"
|
||||
class="price-grid__empty"
|
||||
>
|
||||
暂无规格报价
|
||||
@@ -473,7 +474,10 @@ onUnmounted(() => {
|
||||
class="waterfall-card"
|
||||
@click="openSpec(record)"
|
||||
>
|
||||
<div class="waterfall-card__img" @click.stop="openPreview(record)">
|
||||
<div
|
||||
class="waterfall-card__img"
|
||||
@click.stop="openPreview(record)"
|
||||
>
|
||||
<img
|
||||
v-if="record.cover"
|
||||
:alt="record.title"
|
||||
@@ -507,20 +511,20 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.quote-cover {
|
||||
height: 72px;
|
||||
width: 72px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.price-grid {
|
||||
min-width: 420px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.price-grid__header,
|
||||
@@ -531,16 +535,16 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.price-grid__header {
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted) / 35%);
|
||||
}
|
||||
|
||||
.price-grid__header > div,
|
||||
.price-grid__row > div {
|
||||
padding: 6px 10px;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
word-break: break-word;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.price-grid__header > div:last-child,
|
||||
@@ -549,8 +553,8 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.price-grid__row {
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.price-grid__empty {
|
||||
@@ -574,9 +578,9 @@ onUnmounted(() => {
|
||||
width: 240px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
@@ -585,14 +589,14 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.waterfall-card:hover {
|
||||
border-color: hsl(var(--primary) / 0.45);
|
||||
box-shadow: 0 10px 28px hsl(var(--foreground) / 0.08);
|
||||
border-color: hsl(var(--primary) / 45%);
|
||||
box-shadow: 0 10px 28px hsl(var(--foreground) / 8%);
|
||||
}
|
||||
|
||||
.waterfall-card__img {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
background: hsl(var(--muted) / 35%);
|
||||
}
|
||||
|
||||
.waterfall-card__img img {
|
||||
@@ -603,9 +607,9 @@ onUnmounted(() => {
|
||||
|
||||
.waterfall-card__empty {
|
||||
display: flex;
|
||||
height: 160px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 160px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
@@ -615,9 +619,9 @@ onUnmounted(() => {
|
||||
|
||||
.waterfall-card__id {
|
||||
overflow: hidden;
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,8 @@ export interface MaterialItem {
|
||||
/** 被业务表引用的次数,0 表示可回收 */
|
||||
ref_count: number;
|
||||
size: number;
|
||||
type: string;
|
||||
/** 后端存 0~6,兼容历史字符串 */
|
||||
type: number | string;
|
||||
url: string;
|
||||
width: number;
|
||||
}
|
||||
@@ -31,7 +32,7 @@ export interface MaterialListResult {
|
||||
export interface MaterialStat {
|
||||
total: number;
|
||||
total_size: number;
|
||||
types: { count: number; type: string }[];
|
||||
types: { count: number; type: number | string }[];
|
||||
unused: number;
|
||||
unused_size: number;
|
||||
}
|
||||
@@ -39,6 +40,8 @@ export interface MaterialStat {
|
||||
export interface MaterialSyncResult {
|
||||
finished: boolean;
|
||||
inserted: number;
|
||||
/** 本批从 OSS 列举到的对象数;为 0 且 finished 说明前缀或 Bucket 对不上 */
|
||||
listed: number;
|
||||
next_marker: string;
|
||||
updated: number;
|
||||
}
|
||||
@@ -85,7 +88,11 @@ export async function getMaterialStat(): Promise<MaterialStat> {
|
||||
return await requestClient.get<MaterialStat>('material/stat');
|
||||
}
|
||||
|
||||
/** 单批拉取 OSS 对象;finished 为假时带着 next_marker 再调一次 */
|
||||
/**
|
||||
* 单批拉取 OSS 对象;finished 为假时带着 next_marker 再调一次
|
||||
*
|
||||
* 七牛要先查区域再列举再入库,默认 10s 必超时,这一口单独放到 10 分钟
|
||||
*/
|
||||
export async function syncMaterialFromOss(data: {
|
||||
limit: number;
|
||||
marker: string;
|
||||
@@ -95,6 +102,7 @@ export async function syncMaterialFromOss(data: {
|
||||
return await requestClient.post<MaterialSyncResult>(
|
||||
'material/sync-from-oss',
|
||||
data,
|
||||
{ timeout: 600_000 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,25 +191,38 @@ export function formatSize(bytes?: null | number): string {
|
||||
SIZE_UNITS.length - 1,
|
||||
);
|
||||
const scaled = value / 1024 ** index;
|
||||
const text = index === 0 ? String(scaled) : scaled.toFixed(scaled >= 100 ? 0 : 1);
|
||||
const text =
|
||||
index === 0 ? String(scaled) : scaled.toFixed(scaled >= 100 ? 0 : 1);
|
||||
return `${text} ${SIZE_UNITS[index]}`;
|
||||
}
|
||||
|
||||
/** 与 FileModel::TYPE_* 对齐:0 图片 / 1 视频 / 2 音频 / 3 表格 / 4 压缩包 / 5 文档 / 6 其他 */
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
'0': '图片',
|
||||
'1': '视频',
|
||||
'2': '音频',
|
||||
'3': '表格',
|
||||
'4': '压缩包',
|
||||
'5': '文档',
|
||||
'6': '其他',
|
||||
archive: '压缩包',
|
||||
audio: '音频',
|
||||
doc: '文档',
|
||||
excel: '表格',
|
||||
image: '图片',
|
||||
other: '其他',
|
||||
video: '视频',
|
||||
};
|
||||
|
||||
/** 类型中文名;后端新增类型时原样展示,不做白名单拦截 */
|
||||
export function materialTypeText(type?: string): string {
|
||||
return TYPE_LABELS[type ?? ''] ?? (type || '未知');
|
||||
/** 类型中文名;数字、字符串两种都认,避免拉取成功后卡片上写「未知」 */
|
||||
export function materialTypeText(type?: number | string): string {
|
||||
if (type === undefined || type === null || type === '') {
|
||||
return '未知';
|
||||
}
|
||||
return TYPE_LABELS[String(type)] ?? String(type);
|
||||
}
|
||||
|
||||
/** 只有图片能出缩略图与水印预览,其余类型走图标占位 */
|
||||
/** 只有图片能出缩略图与水印预览;后端 type=0 就是图片 */
|
||||
export function isImageMaterial(item: Pick<MaterialItem, 'type'>): boolean {
|
||||
return item.type === 'image';
|
||||
return Number(item.type) === 0 || item.type === 'image';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
/**
|
||||
* 从 OSS 分批拉取对象进素材库
|
||||
*
|
||||
* 进度靠 marker 续跑:超时或点停止都不要清 marker,否则下一轮又从 Bucket 头开始扫。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
@@ -22,10 +27,13 @@ defineOptions({
|
||||
name: 'MaterialSyncDrawer',
|
||||
});
|
||||
|
||||
/** 同一 marker 超时后最多再打两遍,避免网关抖动就整页重来 */
|
||||
const MAX_BATCH_RETRY = 2;
|
||||
|
||||
const ossOptions = ref<{ label: string; value: number }[]>([]);
|
||||
const ossConfigId = ref<number | undefined>();
|
||||
const prefix = ref('');
|
||||
const limit = ref(200);
|
||||
const limit = ref(80);
|
||||
|
||||
const running = ref(false);
|
||||
const stopped = ref(false);
|
||||
@@ -37,6 +45,11 @@ const marker = ref('');
|
||||
|
||||
const processed = computed(() => inserted.value + updated.value);
|
||||
|
||||
/** 已经走过批次或留下 marker,点开始应续跑而不是清零 */
|
||||
const canContinue = computed(
|
||||
() => !finished.value && (marker.value !== '' || batches.value > 0),
|
||||
);
|
||||
|
||||
/**
|
||||
* OSS 列举接口给不出对象总数,进度条只能表达「还在动」:
|
||||
* 每批往前挪一格、封顶 95,真正跑完才置 100,真实进展看下面的数字。
|
||||
@@ -52,6 +65,13 @@ const progressStatus = computed(() => {
|
||||
return running.value ? ('active' as const) : ('normal' as const);
|
||||
});
|
||||
|
||||
const startButtonText = computed(() => {
|
||||
if (running.value) {
|
||||
return '拉取中';
|
||||
}
|
||||
return canContinue.value ? '继续' : '开始';
|
||||
});
|
||||
|
||||
function resetProgress() {
|
||||
stopped.value = false;
|
||||
finished.value = false;
|
||||
@@ -61,6 +81,60 @@ function resetProgress() {
|
||||
marker.value = '';
|
||||
}
|
||||
|
||||
/** 换存储或换前缀后旧 marker 对不上,必须从头数;初次回填配置不要清 */
|
||||
watch([ossConfigId, prefix], (_curr, prev) => {
|
||||
if (running.value || prev[0] === undefined) {
|
||||
return;
|
||||
}
|
||||
resetProgress();
|
||||
});
|
||||
|
||||
function isTimeoutError(error: unknown): boolean {
|
||||
const e = error as { code?: string; message?: string; status?: number };
|
||||
if (e?.code === 'ECONNABORTED') {
|
||||
return true;
|
||||
}
|
||||
const msg = String(e?.message ?? '').toLowerCase();
|
||||
if (msg.includes('timeout') || msg.includes('超时')) {
|
||||
return true;
|
||||
}
|
||||
return e?.status === 408 || e?.status === 504;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打一页 OSS;超时用同一个 marker 重试,成功才往前走
|
||||
*/
|
||||
async function syncOneBatch() {
|
||||
if (!ossConfigId.value) {
|
||||
throw new Error('请选择 OSS 配置');
|
||||
}
|
||||
const payload = {
|
||||
limit: limit.value,
|
||||
marker: marker.value,
|
||||
oss_config_id: ossConfigId.value,
|
||||
prefix: prefix.value.trim(),
|
||||
};
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= MAX_BATCH_RETRY; attempt += 1) {
|
||||
try {
|
||||
return await syncMaterialFromOss(payload);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (
|
||||
!isTimeoutError(error) ||
|
||||
attempt === MAX_BATCH_RETRY ||
|
||||
stopped.value
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
message.warning(
|
||||
`本批超时,正在用同一 marker 重试(${attempt + 1}/${MAX_BATCH_RETRY})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
footer: false,
|
||||
onBeforeClose() {
|
||||
@@ -75,7 +149,10 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
resetProgress();
|
||||
// 超时后重开抽屉要保住 marker,否则只能从头扫
|
||||
if (!canContinue.value) {
|
||||
resetProgress();
|
||||
}
|
||||
drawerApi.setState({ loading: true });
|
||||
try {
|
||||
const res: any = await getOssConfigList({ pageSize: 100 });
|
||||
@@ -98,16 +175,18 @@ async function start() {
|
||||
message.warning('请选择 OSS 配置');
|
||||
return;
|
||||
}
|
||||
resetProgress();
|
||||
// 跑完或从未开始才清零;超时/停止后点继续从当前 marker 续跑
|
||||
if (finished.value || !canContinue.value) {
|
||||
resetProgress();
|
||||
} else {
|
||||
stopped.value = false;
|
||||
finished.value = false;
|
||||
}
|
||||
running.value = true;
|
||||
let timedOut = false;
|
||||
try {
|
||||
while (!stopped.value) {
|
||||
const res = await syncMaterialFromOss({
|
||||
limit: limit.value,
|
||||
marker: marker.value,
|
||||
oss_config_id: ossConfigId.value,
|
||||
prefix: prefix.value.trim(),
|
||||
});
|
||||
const res = await syncOneBatch();
|
||||
inserted.value += res.inserted ?? 0;
|
||||
updated.value += res.updated ?? 0;
|
||||
batches.value += 1;
|
||||
@@ -123,10 +202,24 @@ async function start() {
|
||||
}
|
||||
marker.value = nextMarker;
|
||||
}
|
||||
} catch (error) {
|
||||
timedOut = isTimeoutError(error);
|
||||
if (timedOut) {
|
||||
message.error(
|
||||
'本批仍超时,已保留当前 marker。可把单批数量调到 40 后再点「继续」',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
running.value = false;
|
||||
}
|
||||
if (finished.value) {
|
||||
if (timedOut) {
|
||||
return;
|
||||
}
|
||||
if (finished.value && inserted.value === 0 && updated.value === 0) {
|
||||
message.warning(
|
||||
'OSS 上没有列举到对象。本站历史七牛文件在 Bucket 根目录,请把「对象前缀」留空后重试;若仍为空,检查存储配置的 Bucket 与密钥。',
|
||||
);
|
||||
} else if (finished.value) {
|
||||
message.success(`拉取完成,新增 ${inserted.value}、更新 ${updated.value}`);
|
||||
popupData(drawerApi).gridApi?.reload();
|
||||
} else if (stopped.value) {
|
||||
@@ -134,6 +227,12 @@ async function start() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 丢掉当前 marker,从 Bucket 头重新扫 */
|
||||
function restart() {
|
||||
resetProgress();
|
||||
void start();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopped.value = true;
|
||||
}
|
||||
@@ -145,7 +244,7 @@ defineExpose(drawerApi);
|
||||
<Drawer class="w-[560px]" title="从 OSS 拉取素材">
|
||||
<Alert
|
||||
class="mb-3"
|
||||
message="按前缀分批列举 OSS 对象并入库,已存在的按路径更新。中途停止不会丢进度,下次从当前 marker 继续即可。"
|
||||
message="按前缀分批列举 OSS 对象并入库,已存在的按路径更新。对象前缀留空表示扫整个 Bucket(本站历史七牛文件在根目录,不要填 uploads)。超时或中途停止都会保留 marker,点「继续」即可接着扫。"
|
||||
show-icon
|
||||
type="info"
|
||||
/>
|
||||
@@ -166,7 +265,7 @@ defineExpose(drawerApi);
|
||||
<Input
|
||||
v-model:value="prefix"
|
||||
:disabled="running"
|
||||
placeholder="如 uploads/,留空表示整个 Bucket"
|
||||
placeholder="留空拉整个 Bucket;不要填 uploads"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -175,12 +274,12 @@ defineExpose(drawerApi);
|
||||
v-model:value="limit"
|
||||
class="w-full"
|
||||
:disabled="running"
|
||||
:max="1000"
|
||||
:max="200"
|
||||
:min="10"
|
||||
:step="50"
|
||||
:step="10"
|
||||
/>
|
||||
<p class="text-muted-foreground mt-1 text-xs">
|
||||
一批太大后端容易超时,200 左右比较稳。
|
||||
一批太大容易超时,默认 80,仍超时再降到 40。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,7 +307,10 @@ defineExpose(drawerApi);
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<Button :loading="running" type="primary" @click="start">
|
||||
{{ running ? '拉取中' : '开始' }}
|
||||
{{ startButtonText }}
|
||||
</Button>
|
||||
<Button :disabled="running || !canContinue" @click="restart">
|
||||
从头开始
|
||||
</Button>
|
||||
<Button danger :disabled="!running" @click="stop">停止</Button>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,7 @@ const page = ref(1);
|
||||
const pageSize = ref(24);
|
||||
|
||||
const keyword = ref('');
|
||||
const type = ref<string | undefined>();
|
||||
const type = ref<number | string | undefined>();
|
||||
const unusedOnly = ref(false);
|
||||
|
||||
const selectedIds = ref<number[]>([]);
|
||||
@@ -229,7 +229,9 @@ function openFolderModal(row?: Record<string, any>) {
|
||||
folderModalApi.setData({
|
||||
gridApi: folderReloader,
|
||||
update: !!row,
|
||||
values: row ? { id: row.id, name: row.name, pid: row.pid ?? 0 } : { pid: 0 },
|
||||
values: row
|
||||
? { id: row.id, name: row.name, pid: row.pid ?? 0 }
|
||||
: { pid: 0 },
|
||||
});
|
||||
folderModalApi.open();
|
||||
}
|
||||
@@ -379,7 +381,9 @@ onMounted(() => {
|
||||
allow-clear
|
||||
class="w-[220px]"
|
||||
placeholder="搜索素材名称或路径,回车确认"
|
||||
@change="(e: any) => (e.target.value ? undefined : reloadFromFirstPage())"
|
||||
@change="
|
||||
(e: any) => (e.target.value ? undefined : reloadFromFirstPage())
|
||||
"
|
||||
@search="reloadFromFirstPage"
|
||||
/>
|
||||
<Select
|
||||
@@ -421,7 +425,8 @@ onMounted(() => {
|
||||
label: '扫描引用',
|
||||
icon: 'lucide:scan-search',
|
||||
popConfirm: {
|
||||
title: '扫描会遍历所有业务表的图片字段,数据量大时较慢,继续吗?',
|
||||
title:
|
||||
'扫描会遍历所有业务表的图片字段,数据量大时较慢,继续吗?',
|
||||
confirm: scanReferences,
|
||||
},
|
||||
},
|
||||
@@ -464,8 +469,8 @@ onMounted(() => {
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="group relative overflow-hidden rounded-lg border transition-all"
|
||||
:class="[
|
||||
'group relative overflow-hidden rounded-lg border transition-all',
|
||||
selectedIds.includes(item.id)
|
||||
? 'border-primary shadow-md'
|
||||
: 'hover:border-primary/40 hover:shadow-md',
|
||||
@@ -474,17 +479,17 @@ onMounted(() => {
|
||||
<Checkbox
|
||||
class="absolute right-2 top-2 z-10"
|
||||
:checked="selectedIds.includes(item.id)"
|
||||
@change="
|
||||
(e: any) => toggleSelect(item.id, !!e.target.checked)
|
||||
"
|
||||
@change="(e: any) => toggleSelect(item.id, !!e.target.checked)"
|
||||
/>
|
||||
<div class="bg-muted/40 flex h-[130px] items-center justify-center">
|
||||
<div
|
||||
class="bg-muted/40 flex h-[130px] items-center justify-center"
|
||||
>
|
||||
<Image
|
||||
v-if="isImageMaterial(item)"
|
||||
:height="130"
|
||||
:preview="{ src: watermarkUrl(item.url, watermarkText) }"
|
||||
:src="thumbUrl(item.url, 320)"
|
||||
:width="'100%'"
|
||||
width="100%"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
@@ -497,7 +502,9 @@ onMounted(() => {
|
||||
<div class="truncate text-sm" :title="item.name">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 flex items-center gap-1 text-xs">
|
||||
<div
|
||||
class="text-muted-foreground mt-1 flex items-center gap-1 text-xs"
|
||||
>
|
||||
<span>{{ materialTypeText(item.type) }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatSize(item.size) }}</span>
|
||||
|
||||
@@ -64,7 +64,7 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
border: false,
|
||||
toolbarConfig: {
|
||||
// 是否显示搜索表单控制按钮
|
||||
// @ts-ignore 正式环境时有完整的类型声明
|
||||
// @ts-expect-error 正式环境时有完整的类型声明
|
||||
search: true,
|
||||
refresh: true, // 刷新
|
||||
print: false, // 打印
|
||||
@@ -72,7 +72,7 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
// custom: true, // 自定义列
|
||||
zoom: true, // 最大化最小化
|
||||
slots: {
|
||||
buttons: 'toolbar-buttons',
|
||||
buttons: 'toolbar-actions',
|
||||
},
|
||||
custom: {
|
||||
// 自定义列-图标
|
||||
|
||||
@@ -84,7 +84,7 @@ const onDeptSelect = (id?: number) => {
|
||||
<FormModal />
|
||||
<DeptTree @select="onDeptSelect" />
|
||||
<Grid class="min-w-0 flex-1">
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Input, Select, Spin, Switch, message } from 'ant-design-vue';
|
||||
import { Input, message, Select, Spin, Switch } from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
@@ -60,7 +60,7 @@ const DRIVER_ICONS: Record<string, string> = {
|
||||
const loading = ref(false);
|
||||
const list = ref<OssCard[]>([]);
|
||||
const drivers = ref<DriverOption[]>([]);
|
||||
const editingId = ref<number | null>(null);
|
||||
const editingId = ref<null | number>(null);
|
||||
|
||||
const form = reactive({
|
||||
driver: undefined as string | undefined,
|
||||
@@ -80,13 +80,13 @@ const form = reactive({
|
||||
/** 表单可按驱动显隐的字段 */
|
||||
type OssFormField =
|
||||
| 'access_key'
|
||||
| 'secret_key'
|
||||
| 'endpoint'
|
||||
| 'region'
|
||||
| 'bucket'
|
||||
| 'domain'
|
||||
| 'endpoint'
|
||||
| 'path_prefix'
|
||||
| 'remark';
|
||||
| 'region'
|
||||
| 'remark'
|
||||
| 'secret_key';
|
||||
|
||||
interface DriverFieldSchema {
|
||||
/** 该驱动需要展示的字段(按顺序) */
|
||||
@@ -167,8 +167,9 @@ const DRIVER_FIELD_SCHEMA: Record<string, DriverFieldSchema> = {
|
||||
required: ['access_key', 'secret_key', 'bucket', 'domain'],
|
||||
placeholders: {
|
||||
domain: '外链域名,含协议,如 https://cdn.example.com',
|
||||
path_prefix: '历史文件在根目录,请留空',
|
||||
},
|
||||
hint: '七牛云:需配置外链访问域名(含协议)。',
|
||||
hint: '七牛云:需配置外链访问域名(含协议)。本站历史文件在 Bucket 根目录,路径前缀请留空,不要填 uploads。',
|
||||
},
|
||||
huawei: {
|
||||
fields: [
|
||||
@@ -306,10 +307,7 @@ function showField(key: OssFormField) {
|
||||
function isRequired(key: OssFormField) {
|
||||
const req = fieldSchema.value?.required ?? [];
|
||||
if (!req.includes(key)) return false;
|
||||
if (
|
||||
editingId.value &&
|
||||
(key === 'access_key' || key === 'secret_key')
|
||||
) {
|
||||
if (editingId.value && (key === 'access_key' || key === 'secret_key')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -331,10 +329,7 @@ const [FormModal, formModalApi] = useVbenModal({
|
||||
if (schema) {
|
||||
for (const key of schema.required) {
|
||||
// 编辑时密钥留空 = 不改,跳过必填
|
||||
if (
|
||||
editingId.value &&
|
||||
(key === 'access_key' || key === 'secret_key')
|
||||
) {
|
||||
if (editingId.value && (key === 'access_key' || key === 'secret_key')) {
|
||||
continue;
|
||||
}
|
||||
const val = String((form as any)[key] ?? '').trim();
|
||||
@@ -372,8 +367,8 @@ const [FormModal, formModalApi] = useVbenModal({
|
||||
formModalApi.close();
|
||||
await loadList();
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
formModalApi.unlock();
|
||||
}
|
||||
@@ -394,8 +389,8 @@ async function loadList() {
|
||||
drivers.value = Array.isArray(driverRes)
|
||||
? driverRes
|
||||
: (driverRes?.items ?? driverRes?.options ?? []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('加载 OSS 配置失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -447,7 +442,7 @@ function openEdit(row: OssCard) {
|
||||
* 若列表尚未加载则先拉取,找不到则提示
|
||||
*/
|
||||
async function openEditById(id: number) {
|
||||
if (!list.value.length) {
|
||||
if (list.value.length === 0) {
|
||||
await loadList();
|
||||
}
|
||||
let row = list.value.find((item) => item.id === id);
|
||||
@@ -472,8 +467,8 @@ async function handleDelete(row: OssCard) {
|
||||
message.success('已删除');
|
||||
await loadList();
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,9 +561,8 @@ onMounted(() => {
|
||||
<div class="key-card__secret">
|
||||
<Icon icon="lucide:lock-keyhole" :size="14" />
|
||||
<span>
|
||||
AK {{ item.has_access_key ? '••••••••' : '未配置' }}
|
||||
·
|
||||
SK {{ item.has_secret_key ? '••••••••' : '未配置' }}
|
||||
AK {{ item.has_access_key ? '••••••••' : '未配置' }} · SK
|
||||
{{ item.has_secret_key ? '••••••••' : '未配置' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -783,27 +777,27 @@ onMounted(() => {
|
||||
|
||||
.oss-keys__hero {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid hsl(var(--primary) / 0.18);
|
||||
margin-bottom: 20px;
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--primary) / 0.14),
|
||||
hsl(var(--primary) / 0.03) 42%,
|
||||
hsl(var(--primary) / 14%),
|
||||
hsl(var(--primary) / 3%) 42%,
|
||||
transparent 70%
|
||||
),
|
||||
hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--primary) / 18%);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.oss-keys__eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -825,27 +819,27 @@ onMounted(() => {
|
||||
|
||||
.oss-keys__add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground, 0 0% 100%));
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary-foreground, 0 0% 100%));
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 24px hsl(var(--primary) / 0.28);
|
||||
background: hsl(var(--primary));
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 10px 24px hsl(var(--primary) / 28%);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.oss-keys__add:hover {
|
||||
box-shadow: 0 14px 28px hsl(var(--primary) / 35%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 14px 28px hsl(var(--primary) / 0.35);
|
||||
}
|
||||
|
||||
.oss-keys__grid {
|
||||
@@ -856,16 +850,16 @@ onMounted(() => {
|
||||
|
||||
.key-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 220px;
|
||||
padding: 20px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
overflow: hidden;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
box-shadow: 0 12px 32px hsl(var(--foreground) / 0.05);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 12px 32px hsl(var(--foreground) / 5%);
|
||||
transition:
|
||||
transform 0.22s ease,
|
||||
border-color 0.22s ease,
|
||||
@@ -873,19 +867,18 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.key-card:hover {
|
||||
border-color: hsl(var(--primary) / 35%);
|
||||
box-shadow: 0 18px 40px hsl(var(--primary) / 12%);
|
||||
transform: translateY(-3px);
|
||||
border-color: hsl(var(--primary) / 0.35);
|
||||
box-shadow: 0 18px 40px hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
.key-card.is-default {
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
background:
|
||||
linear-gradient(
|
||||
165deg,
|
||||
hsl(var(--primary) / 0.1),
|
||||
hsl(var(--card, var(--background))) 46%
|
||||
);
|
||||
background: linear-gradient(
|
||||
165deg,
|
||||
hsl(var(--primary) / 10%),
|
||||
hsl(var(--card, var(--background))) 46%
|
||||
);
|
||||
border-color: hsl(var(--primary) / 40%);
|
||||
}
|
||||
|
||||
.key-card.is-off {
|
||||
@@ -898,22 +891,22 @@ onMounted(() => {
|
||||
right: -20%;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
hsl(var(--primary) / 0.16),
|
||||
hsl(var(--primary) / 16%),
|
||||
transparent 68%
|
||||
);
|
||||
pointer-events: none;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.key-card__head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.key-card__brand {
|
||||
@@ -924,19 +917,19 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.key-card__avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
overflow: hidden;
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--muted) / 45%);
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px hsl(var(--border)),
|
||||
0 8px 18px hsl(var(--foreground) / 0.06);
|
||||
flex-shrink: 0;
|
||||
0 8px 18px hsl(var(--foreground) / 6%);
|
||||
}
|
||||
|
||||
.key-card__title {
|
||||
@@ -945,9 +938,9 @@ onMounted(() => {
|
||||
|
||||
.key-card__name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key-card__name-row h4 {
|
||||
@@ -959,20 +952,20 @@ onMounted(() => {
|
||||
|
||||
.key-card__star {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--warning, 38 92% 50%));
|
||||
background: hsl(var(--warning, 38 92% 50%) / 0.14);
|
||||
background: hsl(var(--warning, 38 92% 50%) / 14%);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.key-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
@@ -980,64 +973,64 @@ onMounted(() => {
|
||||
|
||||
.key-card__meta code {
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
font-size: 11px;
|
||||
background: hsl(var(--muted) / 45%);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.key-card__ops {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.key-card__op {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--background) / 0.55);
|
||||
color: hsl(var(--muted-foreground));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
background: hsl(var(--background) / 55%);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.key-card__op:hover {
|
||||
color: hsl(var(--primary));
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
background: hsl(var(--primary) / 10%);
|
||||
border-color: hsl(var(--primary) / 40%);
|
||||
}
|
||||
|
||||
.key-card__op.is-danger:hover {
|
||||
color: hsl(0 72% 55%);
|
||||
border-color: hsl(0 72% 55% / 0.4);
|
||||
background: hsl(0 72% 55% / 0.1);
|
||||
color: hsl(0deg 72% 55%);
|
||||
background: hsl(0deg 72% 55% / 10%);
|
||||
border-color: hsl(0deg 72% 55% / 40%);
|
||||
}
|
||||
|
||||
.key-card__secret {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
border: 1px dashed hsl(var(--border));
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
letter-spacing: 0.04em;
|
||||
background: hsl(var(--muted) / 35%);
|
||||
border: 1px dashed hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.key-card__remark {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
min-height: 40px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: hsl(var(--muted-foreground));
|
||||
@@ -1054,49 +1047,49 @@ onMounted(() => {
|
||||
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.status-chip.is-on {
|
||||
color: hsl(142 55% 42%);
|
||||
background: hsl(142 55% 42% / 0.14);
|
||||
color: hsl(142deg 55% 42%);
|
||||
background: hsl(142deg 55% 42% / 14%);
|
||||
}
|
||||
|
||||
.status-chip.is-warn {
|
||||
color: hsl(var(--warning, 38 92% 45%));
|
||||
background: hsl(var(--warning, 38 92% 50%) / 0.14);
|
||||
background: hsl(var(--warning, 38 92% 50%) / 14%);
|
||||
}
|
||||
|
||||
.status-chip.is-off {
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
background: hsl(var(--muted) / 45%);
|
||||
}
|
||||
|
||||
.oss-keys__empty {
|
||||
padding: 56px 20px;
|
||||
border-radius: 22px;
|
||||
border: 1px dashed hsl(var(--border));
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted) / 0.15);
|
||||
text-align: center;
|
||||
background: hsl(var(--muted) / 15%);
|
||||
border: 1px dashed hsl(var(--border));
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.oss-keys__empty-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 12px;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 12px;
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.12);
|
||||
background: hsl(var(--primary) / 12%);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.oss-keys__empty h4 {
|
||||
@@ -1114,8 +1107,8 @@ onMounted(() => {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
padding: 8px 4px 4px;
|
||||
align-items: start;
|
||||
padding: 8px 4px 4px;
|
||||
}
|
||||
|
||||
.key-form__item {
|
||||
@@ -1144,7 +1137,7 @@ onMounted(() => {
|
||||
|
||||
.key-form .req {
|
||||
margin-left: 2px;
|
||||
color: hsl(0 72% 55%);
|
||||
color: hsl(0deg 72% 55%);
|
||||
}
|
||||
|
||||
.key-form__hint {
|
||||
@@ -1156,15 +1149,15 @@ onMounted(() => {
|
||||
|
||||
.key-form__switches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
margin-top: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.key-form__switches > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 系统配置 · 套餐功能:按小程序应用开关。
|
||||
* 数据仍写在 nl_wx_app.package_enabled,两品牌共用库,不能做成一条全局 kv。
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Empty, message, Spin, Switch } from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
import { wxAppApi } from '#/views/wx/app/api';
|
||||
|
||||
defineOptions({
|
||||
name: 'PackageFeatureManage',
|
||||
});
|
||||
|
||||
interface WxAppRow {
|
||||
app_id?: string;
|
||||
code?: string;
|
||||
id: number;
|
||||
name?: string;
|
||||
package_enabled?: number;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const savingId = ref<null | number>(null);
|
||||
const list = ref<WxAppRow[]>([]);
|
||||
|
||||
/** 拉应用列表,用来按品牌开关套餐 */
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await wxAppApi.list({ page: 1, pageSize: 50 });
|
||||
list.value = res?.items ?? [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 只改 package_enabled,其它字段走应用配置页 */
|
||||
async function togglePackage(row: WxAppRow, checked: boolean) {
|
||||
savingId.value = row.id;
|
||||
const next = checked ? 1 : 0;
|
||||
const prev = row.package_enabled ?? 0;
|
||||
row.package_enabled = next;
|
||||
try {
|
||||
await wxAppApi.update({
|
||||
id: row.id,
|
||||
package_enabled: next,
|
||||
});
|
||||
message.success(checked ? '已启用套餐' : '已关闭套餐');
|
||||
} catch {
|
||||
row.package_enabled = prev;
|
||||
} finally {
|
||||
savingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadList);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pkg-feature">
|
||||
<div class="pkg-feature__tip">
|
||||
<Icon icon="lucide:info" :size="16" />
|
||||
<div>
|
||||
开启后,该品牌小程序底栏出现「套餐」,首页分类下方展示热门套餐。
|
||||
搭配内容在「商品中心 → 套餐搭配」维护,首页样式可在装修模板里调。
|
||||
两品牌共用一个库,所以按应用分别开关,不能做成一条全局配置。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="list.length > 0" class="pkg-feature__list">
|
||||
<div v-for="row in list" :key="row.id" class="pkg-feature__card">
|
||||
<div class="pkg-feature__logo">
|
||||
<Icon icon="lucide:layout-grid" :size="22" />
|
||||
</div>
|
||||
<div class="pkg-feature__body">
|
||||
<div class="pkg-feature__name">
|
||||
{{ row.name || row.code || '未命名应用' }}
|
||||
</div>
|
||||
<div class="pkg-feature__meta">
|
||||
<code>{{ row.code || '-' }}</code>
|
||||
<span v-if="row.app_id">{{ row.app_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
:checked="row.package_enabled === 1"
|
||||
:loading="savingId === row.id"
|
||||
checked-children="开"
|
||||
un-checked-children="关"
|
||||
@change="(checked: any) => togglePackage(row, !!checked)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Empty
|
||||
v-else-if="!loading"
|
||||
class="pkg-feature__empty"
|
||||
description="还没有应用记录,请先到「小程序 → 应用配置」新增或从 .env 初始化"
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pkg-feature {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.pkg-feature__tip {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--primary) / 8%);
|
||||
border: 1px solid hsl(var(--primary) / 15%);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.pkg-feature__tip :deep(svg) {
|
||||
flex-shrink: 0;
|
||||
margin-top: 3px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.pkg-feature__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pkg-feature__card {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 16px 18px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.pkg-feature__logo {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 10%);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.pkg-feature__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pkg-feature__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.pkg-feature__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.pkg-feature__meta code {
|
||||
padding: 0 6px;
|
||||
background: hsl(var(--muted) / 45%);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pkg-feature__empty {
|
||||
padding: 40px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 系统配置页:左侧自写菜单;右侧按模块渲染 AI / OSS / 接口管理
|
||||
* 系统配置页:左侧自写菜单;右侧按模块渲染 AI / OSS / 接口 / 套餐
|
||||
* 左侧菜单与各模块 Tab 均持久化到 localStorage,刷新后回到上次位置
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
@@ -12,10 +12,11 @@ import { Icon } from '#/components/icon';
|
||||
import AiManage from './components/AiManage.vue';
|
||||
import ApiEndpointManage from './components/ApiEndpointManage.vue';
|
||||
import OssManage from './components/OssManage.vue';
|
||||
import PackageFeatureManage from './components/PackageFeatureManage.vue';
|
||||
|
||||
defineOptions({ name: 'SystemConfig' });
|
||||
|
||||
type ConfigMenuKey = 'ai' | 'oss' | 'api';
|
||||
type ConfigMenuKey = 'ai' | 'api' | 'oss' | 'package';
|
||||
|
||||
interface ConfigMenuItem {
|
||||
key: ConfigMenuKey;
|
||||
@@ -47,6 +48,12 @@ const menus: ConfigMenuItem[] = [
|
||||
desc: '操作日志开关',
|
||||
icon: 'lucide:route',
|
||||
},
|
||||
{
|
||||
key: 'package',
|
||||
title: '套餐功能',
|
||||
desc: '小程序套餐开关',
|
||||
icon: 'lucide:layout-grid',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -118,6 +125,7 @@ function selectMenu(key: ConfigMenuKey) {
|
||||
<AiManage v-if="activeKey === 'ai'" />
|
||||
<OssManage v-else-if="activeKey === 'oss'" />
|
||||
<ApiEndpointManage v-else-if="activeKey === 'api'" />
|
||||
<PackageFeatureManage v-else-if="activeKey === 'package'" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -131,24 +139,24 @@ function selectMenu(key: ConfigMenuKey) {
|
||||
background:
|
||||
radial-gradient(
|
||||
1200px 480px at 12% -10%,
|
||||
hsl(var(--primary) / 0.12),
|
||||
hsl(var(--primary) / 12%),
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
900px 420px at 100% 0%,
|
||||
hsl(var(--primary) / 0.06),
|
||||
hsl(var(--primary) / 6%),
|
||||
transparent 55%
|
||||
),
|
||||
hsl(var(--background));
|
||||
}
|
||||
|
||||
.sys-config__aside {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card, var(--background)) / 0.72);
|
||||
backdrop-filter: blur(12px);
|
||||
width: 260px;
|
||||
padding: 20px 14px;
|
||||
background: hsl(var(--card, var(--background)) / 72%);
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.sys-config__brand {
|
||||
@@ -159,19 +167,19 @@ function selectMenu(key: ConfigMenuKey) {
|
||||
}
|
||||
|
||||
.sys-config__brand-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
color: hsl(var(--primary));
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
hsl(var(--primary) / 0.18),
|
||||
hsl(var(--primary) / 0.05)
|
||||
hsl(var(--primary) / 18%),
|
||||
hsl(var(--primary) / 5%)
|
||||
);
|
||||
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.18);
|
||||
border-radius: 14px;
|
||||
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 18%);
|
||||
}
|
||||
|
||||
.sys-config__brand-title {
|
||||
@@ -198,40 +206,40 @@ function selectMenu(key: ConfigMenuKey) {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
color: hsl(var(--foreground));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.2s ease;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.sys-config__nav-item:hover {
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
background: hsl(var(--muted) / 35%);
|
||||
}
|
||||
|
||||
.sys-config__nav-item.is-active {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border-color: hsl(var(--primary) / 0.35);
|
||||
box-shadow: 0 8px 24px hsl(var(--primary) / 0.08);
|
||||
background: hsl(var(--primary) / 10%);
|
||||
border-color: hsl(var(--primary) / 35%);
|
||||
box-shadow: 0 8px 24px hsl(var(--primary) / 8%);
|
||||
}
|
||||
|
||||
.sys-config__nav-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
flex-shrink: 0;
|
||||
background: hsl(var(--muted) / 45%);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.sys-config__nav-item.is-active .sys-config__nav-icon {
|
||||
background: hsl(var(--primary) / 0.16);
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 16%);
|
||||
}
|
||||
|
||||
.sys-config__nav-text {
|
||||
@@ -247,11 +255,11 @@ function selectMenu(key: ConfigMenuKey) {
|
||||
}
|
||||
|
||||
.sys-config__nav-desc {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sys-config__main {
|
||||
@@ -269,8 +277,8 @@ function selectMenu(key: ConfigMenuKey) {
|
||||
|
||||
.sys-config__header p {
|
||||
margin: 6px 0 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.sys-config__body {
|
||||
|
||||
@@ -30,8 +30,18 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||
{ field: 'name', align: 'left', title: '部门名称', minWidth: 180 },
|
||||
{ field: 'admin_count', title: '账号数', width: 100 },
|
||||
{ field: 'status', title: '状态', width: 100, slots: { default: 'status' } },
|
||||
{ field: 'color', title: '标签颜色', width: 120, slots: { default: 'color' } },
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
slots: { default: 'status' },
|
||||
},
|
||||
{
|
||||
field: 'color',
|
||||
title: '标签颜色',
|
||||
width: 120,
|
||||
slots: { default: 'color' },
|
||||
},
|
||||
{ field: 'desc', title: '备注', minWidth: 200 },
|
||||
{ field: 'created_at', title: '创建时间', width: 180 },
|
||||
{
|
||||
@@ -65,14 +75,14 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
height: 'auto',
|
||||
border: false,
|
||||
toolbarConfig: {
|
||||
// @ts-ignore 正式环境时有完整的类型声明
|
||||
// @ts-expect-error 正式环境时有完整的类型声明
|
||||
search: true,
|
||||
refresh: true,
|
||||
print: false,
|
||||
export: false,
|
||||
zoom: true,
|
||||
slots: {
|
||||
buttons: 'toolbar-buttons',
|
||||
buttons: 'toolbar-actions',
|
||||
},
|
||||
custom: {
|
||||
icon: 'vxe-icon-menu',
|
||||
|
||||
@@ -94,7 +94,7 @@ const handleStatus = (row: any, checked: boolean) => {
|
||||
<Page auto-content-height title="部门管理">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
|
||||
@@ -78,7 +78,7 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
border: false,
|
||||
toolbarConfig: {
|
||||
// 是否显示搜索表单控制按钮
|
||||
// @ts-ignore 正式环境时有完整的类型声明
|
||||
// @ts-expect-error 正式环境时有完整的类型声明
|
||||
search: true,
|
||||
refresh: true, // 刷新
|
||||
print: false, // 打印
|
||||
@@ -86,7 +86,7 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
// custom: true, // 自定义列
|
||||
zoom: true, // 最大化最小化
|
||||
slots: {
|
||||
buttons: 'toolbar-buttons',
|
||||
buttons: 'toolbar-actions',
|
||||
},
|
||||
custom: {
|
||||
// 自定义列-图标
|
||||
|
||||
@@ -75,7 +75,7 @@ const collapseAll = () => {
|
||||
<Page auto-content-height title="菜单管理">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
|
||||
@@ -28,7 +28,12 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||
{ field: 'name', align: 'left', title: '名称' },
|
||||
{ field: 'value', title: '角色代码', slots: { default: 'color' } },
|
||||
{ field: 'status', title: '状态', width: 110, slots: { default: 'status' } },
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 110,
|
||||
slots: { default: 'status' },
|
||||
},
|
||||
{ field: 'desc', title: '备注' },
|
||||
{ field: 'created_at', title: '创建时间' },
|
||||
{ type: 'html', title: '操作', width: 260, slots: { default: 'action' } },
|
||||
@@ -51,7 +56,7 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
border: false,
|
||||
toolbarConfig: {
|
||||
// 是否显示搜索表单控制按钮
|
||||
// @ts-ignore 正式环境时有完整的类型声明
|
||||
// @ts-expect-error 正式环境时有完整的类型声明
|
||||
search: true,
|
||||
refresh: true, // 刷新
|
||||
print: false, // 打印
|
||||
@@ -59,7 +64,7 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
// custom: true, // 自定义列
|
||||
zoom: true, // 最大化最小化
|
||||
slots: {
|
||||
buttons: 'toolbar-buttons',
|
||||
buttons: 'toolbar-actions',
|
||||
},
|
||||
custom: {
|
||||
// 自定义列-图标
|
||||
|
||||
@@ -105,7 +105,7 @@ const deleteApi = (row: any) => {
|
||||
<AuthMenu ref="authMenuRef" />
|
||||
<AuthEndpoint ref="authEndpointRef" />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
|
||||
@@ -97,6 +97,33 @@ export const modalFormProps: VbenFormProps = {
|
||||
fieldName: 'mch_private_key',
|
||||
label: '商户私钥',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '关闭', value: 0 },
|
||||
{ label: '开启', value: 1 },
|
||||
],
|
||||
},
|
||||
defaultValue: 0,
|
||||
fieldName: 'package_enabled',
|
||||
label: '启用套餐',
|
||||
help: '开启后该品牌小程序底栏多「套餐」,首页分类下出现热门套餐',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: { placeholder: '付款成功订阅消息模板 ID' },
|
||||
fieldName: 'subscribe_pay_tpl',
|
||||
label: '付款模板',
|
||||
help: '关键词建议:订单编号 / 金额 / 付款成功。需与微信后台模板字段对齐',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: { placeholder: '发货订阅消息模板 ID' },
|
||||
fieldName: 'subscribe_ship_tpl',
|
||||
label: '发货模板',
|
||||
help: '关键词建议:订单编号 / 已发货 / 时间',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: { options: APP_STATUS_OPTIONS },
|
||||
|
||||
@@ -18,6 +18,12 @@ export const gridOptions = createGridOptions({
|
||||
{ field: 'app_id', title: '小程序 AppID', width: 200 },
|
||||
{ field: 'mch_id', title: '商户号', width: 140 },
|
||||
{ field: 'template_code', title: '默认模板', width: 140 },
|
||||
{
|
||||
field: 'package_enabled',
|
||||
title: '套餐',
|
||||
width: 90,
|
||||
slots: { default: 'package' },
|
||||
},
|
||||
{
|
||||
field: 'secrets',
|
||||
title: '密钥配置',
|
||||
|
||||
@@ -7,16 +7,16 @@ import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Alert, Button, Switch, Tag, message } from 'ant-design-vue';
|
||||
import { Alert, Button, message, Switch, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useCrudTable } from '#/components/crud';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { initWxAppFromEnv, wxAppApi } from './api';
|
||||
import WxAppFormModal from './components/modal.vue';
|
||||
import { SECRET_FIELDS } from './config/constants';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
import WxAppFormModal from './components/modal.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'WxApp',
|
||||
@@ -39,6 +39,9 @@ const { FormModal, Grid, gridApi, removeRows, showModal, toggleStatus } =
|
||||
mch_serial_no: row.mch_serial_no,
|
||||
template_code: row.template_code,
|
||||
notify_url: row.notify_url,
|
||||
package_enabled: row.package_enabled ?? 0,
|
||||
subscribe_pay_tpl: row.subscribe_pay_tpl ?? '',
|
||||
subscribe_ship_tpl: row.subscribe_ship_tpl ?? '',
|
||||
status: row.status,
|
||||
remark: row.remark,
|
||||
// 把 *_set 一起带进弹窗,用于展示密钥配置状态
|
||||
@@ -66,7 +69,10 @@ async function handleInitFromEnv() {
|
||||
gridApi.reload();
|
||||
if (res?.id && !res?.app_secret_set) {
|
||||
const detail = await wxAppApi.detail(res.id);
|
||||
showModal(detail ?? { id: res.id, code: res.code, app_id: res.app_id }, true);
|
||||
showModal(
|
||||
detail ?? { id: res.id, code: res.code, app_id: res.app_id },
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,15 +89,17 @@ onMounted(refreshEmptyHint);
|
||||
<Alert v-if="emptyHint" class="mb-3" show-icon type="warning">
|
||||
<template #message>
|
||||
还没有任何应用记录。登录/code2session 会报「AppSecret 未配置」。可点「从
|
||||
.env 初始化」建壳,或手动「新增应用」填写
|
||||
code(如 brm)/ AppID / AppSecret。
|
||||
.env 初始化」建壳,或手动「新增应用」填写 code(如 brm)/ AppID /
|
||||
AppSecret。
|
||||
</template>
|
||||
<template #action>
|
||||
<Button type="primary" @click="handleInitFromEnv">从 .env 初始化</Button>
|
||||
<Button type="primary" @click="handleInitFromEnv">
|
||||
从 .env 初始化
|
||||
</Button>
|
||||
</template>
|
||||
</Alert>
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
@@ -129,6 +137,11 @@ onMounted(refreshEmptyHint);
|
||||
</Tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #package="{ row }">
|
||||
<Tag :color="row.package_enabled === 1 ? 'green' : 'default'">
|
||||
{{ row.package_enabled === 1 ? '已开启' : '关闭' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Switch
|
||||
:checked="row.status === 0"
|
||||
|
||||
@@ -25,25 +25,63 @@ export async function exportTemplates(ids: number[]) {
|
||||
|
||||
/** 导入模板 JSON */
|
||||
export async function importTemplates(data: {
|
||||
payload: any;
|
||||
overwrite?: boolean;
|
||||
payload: any;
|
||||
}) {
|
||||
return requestClient.post<any>('wx-template/import', data);
|
||||
}
|
||||
|
||||
/** 用内置 20 套预设初始化模板库 */
|
||||
/** 用内置 5 套预设初始化模板库(4 店面轻奢 + 刊页,顺带灌卡片方案) */
|
||||
export async function initPresets(overwrite = false) {
|
||||
return requestClient.post<any>('wx-template/init-presets', { overwrite });
|
||||
}
|
||||
|
||||
export const wxCardSchemeApi = createCrudApi('wx-card-scheme');
|
||||
|
||||
/** 卡片方案列表(含 layers) */
|
||||
export async function getCardSchemeList() {
|
||||
return requestClient.get<any>('wx-card-scheme/list', {
|
||||
params: { page: 1, pageSize: 100 },
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存当前卡片方案图层 */
|
||||
export async function updateCardScheme(id: number, data: Record<string, any>) {
|
||||
return requestClient.post<any>('wx-card-scheme/update', { id, ...data });
|
||||
}
|
||||
|
||||
/** 自由设计后另存为新方案 */
|
||||
export async function saveCardSchemeAs(data: {
|
||||
code?: string;
|
||||
layers: any[];
|
||||
name: string;
|
||||
}) {
|
||||
return requestClient.post<any>('wx-card-scheme/save-as', data);
|
||||
}
|
||||
|
||||
/** 灌 30 套内置卡片预设 */
|
||||
export async function initCardSchemes(overwrite = false) {
|
||||
return requestClient.post<any>('wx-card-scheme/init-presets', { overwrite });
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板的 schema(令牌与布局的可选项)。
|
||||
* 后端 WxTemplateController::schema 直接返回 TOKEN_SCHEMA + LAYOUT_SCHEMA 常量,
|
||||
* 前端编辑表单按这个 schema 渲染下拉选项。
|
||||
* 模板 schema:工作室样式画廊按 studio 字段渲染,不再写死变体
|
||||
*/
|
||||
export async function getTemplateSchema(): Promise<{
|
||||
tokens: Record<string, string[]>;
|
||||
card_families?: Record<string, string>;
|
||||
labels: Record<string, Record<string, string>>;
|
||||
layout: Record<string, Record<string, string[]>>;
|
||||
page_presets?: Record<string, any[]>;
|
||||
pages: Record<string, string[]>;
|
||||
studio: Array<{
|
||||
key: string;
|
||||
modules: Array<{
|
||||
type: string;
|
||||
variants: Array<{ label: string; value: string }>;
|
||||
}>;
|
||||
}>;
|
||||
tokens: Record<string, string[]>;
|
||||
variants: Record<string, string[]>;
|
||||
}> {
|
||||
return requestClient.get('wx-template/schema');
|
||||
}
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 装修模板卡片预览:用 swatch + layout_hint 画迷你首页,方便肉眼区分风格
|
||||
* preview 图优先;没有图时用色板拼出轮播/分类/商品骨架
|
||||
* 模板列表上的迷你手机:按 layout_hint 画接近小程序首页的缩略预览
|
||||
* 有 preview 图就用图;否则用家具样图,不再用「轮播 / 类目」色块
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import {
|
||||
PREVIEW_CATS,
|
||||
PREVIEW_GOODS,
|
||||
PREVIEW_PHOTOS,
|
||||
previewFallback,
|
||||
} from '../preview-assets';
|
||||
|
||||
defineOptions({ name: 'WxTemplateCardPreview' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
layoutHint?: {
|
||||
category?: string;
|
||||
hero?: string;
|
||||
product?: string;
|
||||
};
|
||||
preview?: string;
|
||||
styleTag?: string;
|
||||
swatch?: {
|
||||
primary?: string;
|
||||
accent?: string;
|
||||
bg?: string;
|
||||
border?: string;
|
||||
primary?: string;
|
||||
surface?: string;
|
||||
text?: string;
|
||||
border?: string;
|
||||
};
|
||||
layoutHint?: {
|
||||
hero?: string;
|
||||
category?: string;
|
||||
product?: string;
|
||||
};
|
||||
}>(),
|
||||
{
|
||||
@@ -42,101 +49,94 @@ const colors = computed(() => ({
|
||||
border: props.swatch?.border || '#E7DFD3',
|
||||
}));
|
||||
|
||||
const heroLabel = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
fullscreen: '全屏头图',
|
||||
split: '左右分栏',
|
||||
carousel: '轮播',
|
||||
banner: '横幅',
|
||||
};
|
||||
return map[props.layoutHint?.hero || ''] || '头图';
|
||||
});
|
||||
const hero = computed(() => props.layoutHint?.hero || 'carousel');
|
||||
const category = computed(() => props.layoutHint?.category || 'grid');
|
||||
const product = computed(() => props.layoutHint?.product || 'grid');
|
||||
const productCols = computed(() => (product.value === 'list' ? 1 : 2));
|
||||
const cats = computed(() =>
|
||||
category.value === 'sidebar'
|
||||
? PREVIEW_CATS.slice(0, 3)
|
||||
: PREVIEW_CATS.slice(0, 3),
|
||||
);
|
||||
const goods = computed(() => PREVIEW_GOODS.slice(0, productCols.value * 2));
|
||||
|
||||
const categoryLabel = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
scroll: '横向分类',
|
||||
sidebar: '侧栏分类',
|
||||
grid: '宫格分类',
|
||||
card: '卡片分类',
|
||||
};
|
||||
return map[props.layoutHint?.category || ''] || '分类';
|
||||
});
|
||||
|
||||
const productLabel = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
magazine: '杂志流',
|
||||
waterfall: '瀑布流',
|
||||
grid: '双列商品',
|
||||
list: '列表商品',
|
||||
};
|
||||
return map[props.layoutHint?.product || ''] || '商品';
|
||||
});
|
||||
|
||||
const productCols = computed(() => {
|
||||
const p = props.layoutHint?.product || 'grid';
|
||||
return p === 'list' ? 1 : 2;
|
||||
});
|
||||
function onImgError(event: Event, index = 1) {
|
||||
const img = event.target as HTMLImageElement;
|
||||
if (img.dataset.fallback === '1') return;
|
||||
img.dataset.fallback = '1';
|
||||
img.src = previewFallback(index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="preview-root">
|
||||
<img v-if="preview" :alt="styleTag || 'preview'" class="preview-img" :src="preview" />
|
||||
<div v-else class="mock" :style="{ background: colors.bg, color: colors.text }">
|
||||
<div class="mock__phone">
|
||||
<div
|
||||
class="mock__hero"
|
||||
:style="{
|
||||
background: `linear-gradient(135deg, ${colors.primary}, ${colors.accent})`,
|
||||
}"
|
||||
>
|
||||
<span>{{ heroLabel }}</span>
|
||||
</div>
|
||||
<div class="mock__cats">
|
||||
<span
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
class="mock__chip"
|
||||
:style="{
|
||||
background: colors.surface,
|
||||
borderColor: colors.border,
|
||||
color: colors.text,
|
||||
}"
|
||||
>
|
||||
{{ n === 1 ? categoryLabel : '类目' }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mock__products"
|
||||
:style="{ gridTemplateColumns: `repeat(${productCols}, 1fr)` }"
|
||||
>
|
||||
<div
|
||||
v-for="n in productCols * 2"
|
||||
:key="n"
|
||||
class="mock__card"
|
||||
:style="{
|
||||
background: colors.surface,
|
||||
borderColor: colors.border,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="mock__thumb"
|
||||
:style="{ background: n % 2 ? colors.primary : colors.accent }"
|
||||
></div>
|
||||
<div class="mock__line" :style="{ background: colors.border }"></div>
|
||||
<div
|
||||
class="mock__price"
|
||||
:style="{ background: colors.primary, opacity: 0.85 }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock__hint">{{ productLabel }}</div>
|
||||
<img
|
||||
v-if="preview"
|
||||
:alt="styleTag || 'preview'"
|
||||
class="preview-img"
|
||||
:src="preview"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="phone"
|
||||
:style="{ background: colors.bg, color: colors.text }"
|
||||
>
|
||||
<div class="phone__nav" :style="{ background: colors.surface }">
|
||||
<b>家具优选</b>
|
||||
<i class="phone__capsule" :style="{ borderColor: colors.border }"></i>
|
||||
</div>
|
||||
<div class="mock__swatches">
|
||||
<i :style="{ background: colors.primary }" title="主色"></i>
|
||||
<i :style="{ background: colors.accent }" title="辅色"></i>
|
||||
<i :style="{ background: colors.bg }" title="背景"></i>
|
||||
<i :style="{ background: colors.surface }" title="表面"></i>
|
||||
<i :style="{ background: colors.text }" title="文字"></i>
|
||||
<div class="phone__hero" :class="`hero--${hero}`">
|
||||
<img :src="PREVIEW_PHOTOS[0]" alt="" @error="onImgError($event, 1)" />
|
||||
<span
|
||||
v-if="hero === 'split'"
|
||||
class="phone__split"
|
||||
:style="{ color: colors.text, background: colors.surface }"
|
||||
>
|
||||
ATELIER
|
||||
</span>
|
||||
</div>
|
||||
<div class="phone__cats" :class="`cats--${category}`">
|
||||
<div
|
||||
v-for="(item, index) in cats"
|
||||
:key="item.name"
|
||||
class="phone__cat"
|
||||
:style="{ background: colors.surface, borderColor: colors.border }"
|
||||
>
|
||||
<img
|
||||
:src="item.cover"
|
||||
alt=""
|
||||
@error="onImgError($event, index + 1)"
|
||||
/>
|
||||
<em>{{ item.name }}</em>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="product !== 'hidden'"
|
||||
class="phone__goods"
|
||||
:style="{ gridTemplateColumns: `repeat(${productCols}, 1fr)` }"
|
||||
>
|
||||
<div
|
||||
v-for="item in goods"
|
||||
:key="item.title"
|
||||
class="phone__good"
|
||||
:style="{ background: colors.surface, borderColor: colors.border }"
|
||||
>
|
||||
<img :src="item.cover" alt="" @error="onImgError($event, 1)" />
|
||||
<em>{{ item.title }}</em>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="phone__tab"
|
||||
:style="{
|
||||
background: colors.surface,
|
||||
borderColor: colors.border,
|
||||
color: colors.primary,
|
||||
}"
|
||||
>
|
||||
<span>首页</span>
|
||||
<span :style="{ color: colors.text, opacity: 0.45 }">图册</span>
|
||||
<span :style="{ color: colors.text, opacity: 0.45 }">清单</span>
|
||||
<span :style="{ color: colors.text, opacity: 0.45 }">我的</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,116 +144,175 @@ const productCols = computed(() => {
|
||||
|
||||
<style scoped>
|
||||
.preview-root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mock {
|
||||
.phone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 10px 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mock__phone {
|
||||
.phone__nav {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mock__hero {
|
||||
display: flex;
|
||||
height: 42px;
|
||||
flex-shrink: 0;
|
||||
align-items: flex-end;
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.phone__nav b {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-shadow: 0 1px 2px rgb(0 0 0 / 35%);
|
||||
}
|
||||
|
||||
.mock__cats {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mock__chip {
|
||||
flex-shrink: 0;
|
||||
.phone__capsule {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
width: 28px;
|
||||
height: 10px;
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
padding: 2px 7px;
|
||||
font-size: 9px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mock__products {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.mock__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
.phone__hero {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
height: 52px;
|
||||
margin: 4px 8px 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid;
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.mock__thumb {
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
opacity: 0.55;
|
||||
.phone__hero img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mock__line,
|
||||
.mock__price {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
.hero--fullscreen,
|
||||
.hero--caption {
|
||||
height: 64px;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.mock__line {
|
||||
width: 70%;
|
||||
.hero--banner {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.mock__price {
|
||||
width: 40%;
|
||||
.hero--split img {
|
||||
width: calc(100% - 12px);
|
||||
}
|
||||
|
||||
.mock__hint {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.mock__swatches {
|
||||
.phone__split {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
font-size: 6px;
|
||||
letter-spacing: 1px;
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.mock__swatches i {
|
||||
.phone__cats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px;
|
||||
padding: 5px 8px 0;
|
||||
}
|
||||
|
||||
.cats--scroll,
|
||||
.cats--pills {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cats--scroll .phone__cat,
|
||||
.cats--pills .phone__cat {
|
||||
min-width: 42px;
|
||||
}
|
||||
|
||||
.cats--card,
|
||||
.cats--mosaic {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.cats--sidebar {
|
||||
grid-template-columns: 22px 1fr 1fr;
|
||||
}
|
||||
|
||||
.phone__cat {
|
||||
overflow: hidden;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.phone__cat img {
|
||||
display: block;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 999px;
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.phone__cat em {
|
||||
display: block;
|
||||
padding: 2px 0;
|
||||
font-size: 8px;
|
||||
font-style: normal;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.phone__goods {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
padding: 5px 8px 0;
|
||||
}
|
||||
|
||||
.phone__good {
|
||||
overflow: hidden;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.phone__good img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.phone__good em {
|
||||
display: block;
|
||||
padding: 2px 3px;
|
||||
font-size: 8px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.phone__tab {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
height: 16px;
|
||||
margin-top: auto;
|
||||
font-size: 7px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
border-top: 1px solid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,7 +37,9 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.lock();
|
||||
try {
|
||||
const res = await doImport({ payload, overwrite: overwrite.value });
|
||||
message.success(`导入完成:新增 ${res.inserted},更新 ${res.updated},跳过 ${res.skipped}`);
|
||||
message.success(
|
||||
`导入完成:新增 ${res.inserted},更新 ${res.updated},跳过 ${res.skipped}`,
|
||||
);
|
||||
popupData(modalApi).gridApi?.reload();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
@@ -56,14 +58,14 @@ const [Modal, modalApi] = useVbenModal({
|
||||
<Modal class="w-[640px]" title="导入模板">
|
||||
<Alert
|
||||
class="mb-3"
|
||||
message="粘贴之前导出的模板 JSON。导入是 strict 模式:令牌键与布局枚举值不在白名单内的会被拒绝。"
|
||||
message="粘贴导出 JSON。strict 白名单:非法 tokens/layout 会被拒绝。日常请走「装修工作室」改色板与区块,勿按 template.code 特判页面。"
|
||||
show-icon
|
||||
type="info"
|
||||
/>
|
||||
<Input.Textarea
|
||||
v-model:value="jsonText"
|
||||
:rows="10"
|
||||
placeholder='{"version":1,"templates":[...]}'
|
||||
placeholder="{"version":1,"templates":[...]}"
|
||||
/>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<Switch v-model:checked="overwrite" />
|
||||
|
||||
@@ -29,16 +29,17 @@ import {
|
||||
setTemplateStatus,
|
||||
wxTemplateApi,
|
||||
} from './api';
|
||||
import WxTemplateCardPreview from './components/card-preview.vue';
|
||||
import WxTemplateImportModal from './components/import-modal.vue';
|
||||
import WxTemplateFormModal from './components/modal.vue';
|
||||
import WxTemplateCardPreview from './components/card-preview.vue';
|
||||
import WxTemplateStudio from './studio/index.vue';
|
||||
|
||||
defineOptions({ name: 'WxTemplate' });
|
||||
|
||||
const loading = ref(false);
|
||||
const items = ref<Record<string, any>[]>([]);
|
||||
const keyword = ref('');
|
||||
const statusFilter = ref<'all' | '0' | '1'>('all');
|
||||
const statusFilter = ref<'0' | '1' | 'all'>('all');
|
||||
const isEmpty = ref(false);
|
||||
|
||||
const statusOptions = [
|
||||
@@ -50,7 +51,10 @@ const statusOptions = [
|
||||
const filtered = computed(() => {
|
||||
const q = keyword.value.trim().toLowerCase();
|
||||
return items.value.filter((row) => {
|
||||
if (statusFilter.value !== 'all' && String(row.status) !== statusFilter.value) {
|
||||
if (
|
||||
statusFilter.value !== 'all' &&
|
||||
String(row.status) !== statusFilter.value
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!q) {
|
||||
@@ -76,6 +80,9 @@ const [FormModal, formModalApi] = useVbenModal({
|
||||
const [ImportModal, importModalApi] = useVbenModal({
|
||||
connectedComponent: WxTemplateImportModal,
|
||||
});
|
||||
const [StudioModal, studioModalApi] = useVbenModal({
|
||||
connectedComponent: WxTemplateStudio,
|
||||
});
|
||||
|
||||
/** 导入弹窗仍约定 gridApi.reload */
|
||||
const gridApiBridge = { reload: load };
|
||||
@@ -165,6 +172,12 @@ function openImport() {
|
||||
importModalApi.open();
|
||||
}
|
||||
|
||||
/** 打开装修工作室:色板 / 区块 / 画布 */
|
||||
function openStudio(row: Record<string, any>) {
|
||||
studioModalApi.setData({ id: row.id, onSaved: load });
|
||||
studioModalApi.open();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
@@ -172,20 +185,23 @@ onMounted(load);
|
||||
<Page
|
||||
auto-content-height
|
||||
content-class="flex min-h-0 flex-col"
|
||||
description="卡片单选设为品牌默认主题;经销商专属模板请到「经销商管理」单独绑定。"
|
||||
description="只保留 4 套轻奢(素胚 / 珠贝白 / 胡桃木 / 香槟金)。点「装修」进工作室改色板、拖区块、编辑 canvas 卡片;经销商专属请到「经销商管理」绑定。"
|
||||
title="小程序装修模板"
|
||||
>
|
||||
<FormModal />
|
||||
<ImportModal />
|
||||
<StudioModal />
|
||||
|
||||
<Alert v-if="isEmpty" class="mb-3" show-icon type="warning">
|
||||
<template #message>
|
||||
模板库为空。建表不会灌数据,请初始化内置 20 套预设(
|
||||
模板库为空。请初始化内置 4 套轻奢预设(
|
||||
<code>config/wx_templates.php</code>
|
||||
)。
|
||||
</template>
|
||||
<template #action>
|
||||
<Button type="primary" @click="handleInitPresets">立即初始化预设</Button>
|
||||
<Button type="primary" @click="handleInitPresets">
|
||||
立即初始化预设
|
||||
</Button>
|
||||
</template>
|
||||
</Alert>
|
||||
|
||||
@@ -214,7 +230,8 @@ onMounted(load);
|
||||
label: '初始化预设',
|
||||
icon: 'lucide:sparkles',
|
||||
popConfirm: {
|
||||
title: '用内置 20 套覆盖初始化?同 code 会被覆盖。',
|
||||
title:
|
||||
'用内置 4 套轻奢覆盖初始化?仅同 code 会被覆盖,并灌 15 套卡片方案。',
|
||||
confirm: handleInitPresets,
|
||||
},
|
||||
},
|
||||
@@ -261,7 +278,11 @@ onMounted(load);
|
||||
:style-tag="row.style_tag"
|
||||
:swatch="row.swatch"
|
||||
/>
|
||||
<Tag v-if="row.is_default === 1" class="template-card__badge" color="green">
|
||||
<Tag
|
||||
v-if="row.is_default === 1"
|
||||
class="template-card__badge"
|
||||
color="green"
|
||||
>
|
||||
当前默认
|
||||
</Tag>
|
||||
</div>
|
||||
@@ -281,6 +302,9 @@ onMounted(load);
|
||||
{{ row.status === 0 ? '启用' : '停用' }}
|
||||
</Tag>
|
||||
<div class="template-card__actions">
|
||||
<Button size="small" type="link" @click="openStudio(row)">
|
||||
装修
|
||||
</Button>
|
||||
<Button size="small" type="link" @click="showModal(row, true)">
|
||||
编辑
|
||||
</Button>
|
||||
@@ -320,11 +344,11 @@ onMounted(load);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 14px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 14px;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
@@ -332,13 +356,13 @@ onMounted(load);
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
border-color: hsl(var(--primary) / 0.45);
|
||||
box-shadow: 0 10px 28px hsl(var(--foreground) / 0.08);
|
||||
border-color: hsl(var(--primary) / 45%);
|
||||
box-shadow: 0 10px 28px hsl(var(--foreground) / 8%);
|
||||
}
|
||||
|
||||
.template-card.is-selected {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 1px hsl(var(--primary) / 0.35);
|
||||
box-shadow: 0 0 0 1px hsl(var(--primary) / 35%);
|
||||
}
|
||||
|
||||
.template-card.is-disabled {
|
||||
@@ -351,13 +375,13 @@ onMounted(load);
|
||||
left: 12px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: hsl(var(--card, var(--background)) / 90%);
|
||||
border: 1.5px solid hsl(var(--border));
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--card, var(--background)) / 0.9);
|
||||
}
|
||||
|
||||
.template-card.is-selected .template-card__radio {
|
||||
@@ -365,10 +389,10 @@ onMounted(load);
|
||||
}
|
||||
|
||||
.template-card__dot {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
border-radius: 999px;
|
||||
height: 8px;
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.template-card.is-selected .template-card__dot {
|
||||
@@ -379,15 +403,15 @@ onMounted(load);
|
||||
position: relative;
|
||||
height: 168px;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
background: hsl(var(--muted) / 35%);
|
||||
}
|
||||
|
||||
.template-card__badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
margin: 0;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.template-card__body {
|
||||
@@ -399,19 +423,19 @@ onMounted(load);
|
||||
}
|
||||
|
||||
.template-card__name {
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.template-card__meta {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.template-card__layout {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
letter-spacing: 0.02em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
53
apps/web-antd/src/views/wx/template/preview-assets.ts
Normal file
53
apps/web-antd/src/views/wx/template/preview-assets.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 工作室 / 模板列表共用的预览样图
|
||||
* 七牛实拍优先,SVG 兜底,避免 Unsplash 在国内打不开后只剩色块
|
||||
*/
|
||||
function svgUri(markup: string) {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`;
|
||||
}
|
||||
|
||||
const ROOM = (inner: string, wall = '#EFE6D8', floor = '#D8C9B4') =>
|
||||
svgUri(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid slice"><rect width="800" height="600" fill="${wall}"/><rect y="420" width="800" height="180" fill="${floor}"/><rect y="418" width="800" height="4" fill="#C9B79A"/>${inner}</svg>`,
|
||||
);
|
||||
|
||||
export const PREVIEW_PHOTOS = [
|
||||
'http://qiniu.boerman.top/b_a2d78f027b3d26c0b0621655726c0c54.jpg',
|
||||
ROOM(
|
||||
`<rect x="120" y="250" width="560" height="36" rx="8" fill="#8C6A3F"/><rect x="90" y="286" width="620" height="150" rx="18" fill="#B08D57"/><rect x="110" y="300" width="180" height="70" rx="10" fill="#D8C4A0"/><rect x="510" y="300" width="180" height="70" rx="10" fill="#D8C4A0"/><rect x="330" y="180" width="140" height="70" rx="6" fill="#C4B39A"/>`,
|
||||
'#F3EBE0',
|
||||
'#CDB89A',
|
||||
),
|
||||
ROOM(
|
||||
`<rect x="180" y="300" width="440" height="18" fill="#6B5340"/><rect x="200" y="318" width="400" height="90" rx="4" fill="#E8D9C4"/><rect x="160" y="250" width="28" height="170" fill="#5C4636"/><rect x="612" y="250" width="28" height="170" fill="#5C4636"/><rect x="340" y="210" width="120" height="90" fill="#F7F1E8"/>`,
|
||||
'#EDE4D6',
|
||||
'#C6B396',
|
||||
),
|
||||
ROOM(
|
||||
`<rect x="160" y="220" width="480" height="200" rx="8" fill="#C9B396"/><rect x="180" y="200" width="440" height="40" rx="8" fill="#A89070"/><rect x="200" y="248" width="400" height="24" fill="#F4EEE4"/><rect x="80" y="300" width="60" height="120" rx="6" fill="#8C6A3F"/>`,
|
||||
'#E8DFD2',
|
||||
'#D2C0A6',
|
||||
),
|
||||
];
|
||||
|
||||
export const PREVIEW_CATS = [
|
||||
{ name: '客厅', cover: PREVIEW_PHOTOS[0] },
|
||||
{ name: '餐厅', cover: PREVIEW_PHOTOS[1] },
|
||||
{ name: '卧室', cover: PREVIEW_PHOTOS[2] },
|
||||
{ name: '书房', cover: PREVIEW_PHOTOS[3] },
|
||||
];
|
||||
|
||||
export const PREVIEW_GOODS = [
|
||||
{ title: 'BR-1208', cover: PREVIEW_PHOTOS[0] },
|
||||
{ title: 'WL-3301', cover: PREVIEW_PHOTOS[1] },
|
||||
{ title: 'BR-4412', cover: PREVIEW_PHOTOS[2] },
|
||||
{ title: 'WL-2098', cover: PREVIEW_PHOTOS[3] },
|
||||
];
|
||||
|
||||
/** 图片失败时换成对应 SVG,保证小手机始终有家具画面 */
|
||||
export function previewFallback(index = 1) {
|
||||
return (
|
||||
PREVIEW_PHOTOS[Math.min(Math.max(index, 1), PREVIEW_PHOTOS.length - 1)] ||
|
||||
PREVIEW_PHOTOS[1]
|
||||
);
|
||||
}
|
||||
587
apps/web-antd/src/views/wx/template/studio/canvas-card.vue
Normal file
587
apps/web-antd/src/views/wx/template/studio/canvas-card.vue
Normal file
@@ -0,0 +1,587 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CardLayer, ThemeTokens } from './types';
|
||||
|
||||
/**
|
||||
* 卡片画布渲染:后台预览与编辑共用
|
||||
* 图层坐标按 340×420 设计稿,外层用 scale 缩放到预览宽度
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { resolveColor } from './helpers';
|
||||
|
||||
defineOptions({ name: 'WxStudioCanvasCard' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
bind?: { cover?: string; gallery?: string; name?: string; price?: string };
|
||||
caption?: string;
|
||||
/** 工作室编辑框带描边;真机预览里关掉,避免套一层灰框 */
|
||||
chrome?: boolean;
|
||||
editable?: boolean;
|
||||
layers: CardLayer[];
|
||||
selectedId?: string;
|
||||
tokens: ThemeTokens;
|
||||
width?: number;
|
||||
}>(),
|
||||
{
|
||||
bind: () => ({
|
||||
cover: '',
|
||||
name: '型号 SAMPLE',
|
||||
price: '¥ 1,280',
|
||||
gallery: '',
|
||||
}),
|
||||
selectedId: '',
|
||||
editable: false,
|
||||
width: 170,
|
||||
chrome: true,
|
||||
caption: '',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
move: [id: string, x: number, y: number];
|
||||
select: [id: string];
|
||||
}>();
|
||||
|
||||
const CANVAS_W = 340;
|
||||
const CANVAS_H = 420;
|
||||
const scale = computed(() => props.width / CANVAS_W);
|
||||
|
||||
const sorted = computed(() =>
|
||||
[...(props.layers || [])]
|
||||
.filter((l) => l.visible !== false)
|
||||
.sort((a, b) => (a.z || 0) - (b.z || 0)),
|
||||
);
|
||||
|
||||
function layerStyle(layer: CardLayer) {
|
||||
return {
|
||||
left: `${layer.x}px`,
|
||||
top: `${layer.y}px`,
|
||||
width: `${layer.w}px`,
|
||||
height: `${layer.h}px`,
|
||||
transform: `rotate(${layer.rotate || 0}deg)`,
|
||||
zIndex: layer.z || 1,
|
||||
};
|
||||
}
|
||||
|
||||
function fillOf(layer: CardLayer) {
|
||||
return resolveColor(layer.fill, props.tokens);
|
||||
}
|
||||
|
||||
/** 配饰色只吃主题 token,缺 tone 时用主色,绝不回落到纯黑 */
|
||||
function toneOf(layer: CardLayer) {
|
||||
return resolveColor(layer.tone || 'primary', props.tokens);
|
||||
}
|
||||
|
||||
function textOf(layer: CardLayer) {
|
||||
if (layer.bind === 'name') return props.bind?.name || '型号';
|
||||
// 列表预览会传入空报价,不要回落到 ¥ —
|
||||
if (layer.bind === 'price') return props.bind?.price || '';
|
||||
if (props.caption && (!layer.bind || layer.bind === 'custom'))
|
||||
return props.caption;
|
||||
if (layer.bind === 'custom') return layer.text || '';
|
||||
return layer.text || '';
|
||||
}
|
||||
|
||||
const FACE_ZH: Record<string, string> = {
|
||||
sans: 'PingFang SC, Microsoft YaHei, sans-serif',
|
||||
serif: 'Songti SC, Noto Serif SC, serif',
|
||||
kai: 'Kaiti SC, STKaiti, serif',
|
||||
script: 'Kaiti SC, STKaiti, serif',
|
||||
};
|
||||
const FACE_EN: Record<string, string> = {
|
||||
sans: '-apple-system, Helvetica, sans-serif',
|
||||
serif: 'Georgia, Times New Roman, serif',
|
||||
kai: 'Georgia, serif',
|
||||
script: 'Snell Roundhand, Segoe Script, cursive',
|
||||
};
|
||||
|
||||
function faceOf(layer: CardLayer, kind: 'en' | 'zh') {
|
||||
const key = kind === 'zh' ? layer.font_zh || 'sans' : layer.font_en || 'sans';
|
||||
return kind === 'zh'
|
||||
? FACE_ZH[key] || FACE_ZH.sans
|
||||
: FACE_EN[key] || FACE_EN.sans;
|
||||
}
|
||||
|
||||
function textRuns(layer: CardLayer) {
|
||||
const raw = textOf(layer);
|
||||
const parts: { kind: 'en' | 'zh'; text: string }[] = [];
|
||||
raw.split(/([\u3400-\u9FFF]+)/).forEach((chunk) => {
|
||||
if (!chunk) return;
|
||||
parts.push({
|
||||
text: chunk,
|
||||
kind: /[\u3400-\u9FFF]/.test(chunk) ? 'zh' : 'en',
|
||||
});
|
||||
});
|
||||
return parts.length > 0 ? parts : [{ text: raw, kind: 'en' as const }];
|
||||
}
|
||||
|
||||
function photoOf(layer: CardLayer) {
|
||||
if (layer.bind === 'gallery')
|
||||
return props.bind?.gallery || props.bind?.cover || '';
|
||||
if (layer.src) return layer.src;
|
||||
return props.bind?.cover || '';
|
||||
}
|
||||
|
||||
function fontSize(layer: CardLayer) {
|
||||
const map: Record<string, string> = {
|
||||
title: '18px',
|
||||
body: '14px',
|
||||
caption: '12px',
|
||||
price: '16px',
|
||||
};
|
||||
return map[layer.font || 'body'] || '14px';
|
||||
}
|
||||
|
||||
function textColor(layer: CardLayer) {
|
||||
if (layer.font === 'price')
|
||||
return props.tokens.color?.price || props.tokens.color?.primary;
|
||||
return props.tokens.color?.text || '#1C1917';
|
||||
}
|
||||
|
||||
let drag: null | {
|
||||
id: string;
|
||||
ox: number;
|
||||
oy: number;
|
||||
sx: number;
|
||||
sy: number;
|
||||
} = null;
|
||||
|
||||
function onPointerDown(event: PointerEvent, layer: CardLayer) {
|
||||
if (!props.editable || layer.locked) {
|
||||
emit('select', layer.id);
|
||||
return;
|
||||
}
|
||||
emit('select', layer.id);
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
target.setPointerCapture(event.pointerId);
|
||||
drag = {
|
||||
id: layer.id,
|
||||
ox: event.clientX,
|
||||
oy: event.clientY,
|
||||
sx: layer.x,
|
||||
sy: layer.y,
|
||||
};
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!drag) return;
|
||||
const dx = (event.clientX - drag.ox) / scale.value;
|
||||
const dy = (event.clientY - drag.oy) / scale.value;
|
||||
emit('move', drag.id, Math.round(drag.sx + dx), Math.round(drag.sy + dy));
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
drag = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="canvas-shell"
|
||||
:class="{ 'is-bare': !chrome }"
|
||||
:style="{
|
||||
width: `${width}px`,
|
||||
height: `${CANVAS_H * scale}px`,
|
||||
background: tokens.color?.bg || '#FAF7F2',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="canvas-stage"
|
||||
:style="{ transform: `scale(${scale})`, transformOrigin: 'top left' }"
|
||||
>
|
||||
<div
|
||||
v-for="layer in sorted"
|
||||
:key="layer.id"
|
||||
class="layer"
|
||||
:class="{
|
||||
'is-selected': editable && selectedId === layer.id,
|
||||
'is-locked': layer.locked,
|
||||
}"
|
||||
:style="layerStyle(layer)"
|
||||
@pointerdown="onPointerDown($event, layer)"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
>
|
||||
<div
|
||||
v-if="layer.type === 'shape'"
|
||||
class="fill"
|
||||
:style="{ background: fillOf(layer) }"
|
||||
></div>
|
||||
<img
|
||||
v-else-if="layer.type === 'photo' && photoOf(layer)"
|
||||
class="photo"
|
||||
:src="photoOf(layer)"
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
v-else-if="layer.type === 'photo'"
|
||||
class="photo-ph"
|
||||
:style="{ background: tokens.color?.primary || '#B08D57' }"
|
||||
></div>
|
||||
<div
|
||||
v-else-if="layer.type === 'text'"
|
||||
class="txt"
|
||||
:style="{ color: textColor(layer), fontSize: fontSize(layer) }"
|
||||
>
|
||||
<span
|
||||
v-for="(run, ri) in textRuns(layer)"
|
||||
:key="ri"
|
||||
:style="{ fontFamily: faceOf(layer, run.kind) }"
|
||||
>{{ run.text }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="layer.type === 'deco'"
|
||||
class="deco"
|
||||
:class="`deco--${layer.asset || 'gold-corner'}`"
|
||||
:style="{ color: toneOf(layer) }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.canvas-shell {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.canvas-shell.is-bare {
|
||||
background: transparent !important;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.canvas-stage {
|
||||
position: relative;
|
||||
width: 340px;
|
||||
height: 420px;
|
||||
}
|
||||
|
||||
.layer {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.layer.is-selected {
|
||||
outline: 1.5px dashed hsl(var(--primary));
|
||||
}
|
||||
|
||||
.fill,
|
||||
.photo,
|
||||
.photo-ph,
|
||||
.txt,
|
||||
.deco {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.photo {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.photo-ph {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.txt {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: 'Songti SC', 'Noto Serif SC', serif;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deco {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.deco--paper-edge {
|
||||
background: repeating-linear-gradient(
|
||||
-8deg,
|
||||
color-mix(in srgb, currentcolor 10%, transparent) 0 2px,
|
||||
transparent 2px 7px
|
||||
);
|
||||
border: 1px dashed currentcolor;
|
||||
box-shadow:
|
||||
inset 0 0 0 7px color-mix(in srgb, currentcolor 16%, transparent),
|
||||
inset 0 0 18px color-mix(in srgb, currentcolor 12%, transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.deco--pearl-line,
|
||||
.deco--folio-line {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, currentcolor 8%, transparent),
|
||||
transparent 40%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px currentcolor,
|
||||
inset 0 0 0 5px color-mix(in srgb, currentcolor 14%, white),
|
||||
inset 0 0 0 6px currentcolor;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.deco--double-frame {
|
||||
box-shadow:
|
||||
inset 0 0 0 2px currentcolor,
|
||||
inset 0 0 0 7px color-mix(in srgb, currentcolor 10%, transparent),
|
||||
inset 0 0 0 9px currentcolor,
|
||||
0 0 0 1px color-mix(in srgb, currentcolor 40%, transparent);
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.deco--walnut-frame {
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, currentcolor 18%, transparent) 0 3px,
|
||||
transparent 3px 8px
|
||||
);
|
||||
border: 12px solid currentcolor;
|
||||
box-shadow:
|
||||
inset 0 0 0 3px color-mix(in srgb, currentcolor 45%, white),
|
||||
inset 0 0 16px color-mix(in srgb, currentcolor 25%, transparent),
|
||||
0 2px 0 color-mix(in srgb, currentcolor 40%, transparent);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.deco--gold-foil {
|
||||
background: linear-gradient(
|
||||
125deg,
|
||||
color-mix(in srgb, currentcolor 8%, transparent) 0%,
|
||||
color-mix(in srgb, currentcolor 28%, white) 36%,
|
||||
color-mix(in srgb, currentcolor 12%, transparent) 62%,
|
||||
currentcolor 100%
|
||||
);
|
||||
border: 1px solid currentcolor;
|
||||
box-shadow:
|
||||
inset 0 0 0 5px color-mix(in srgb, currentcolor 22%, transparent),
|
||||
0 1px 0 color-mix(in srgb, currentcolor 40%, white);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.deco--washi-1 {
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
currentcolor 0 3px,
|
||||
color-mix(in srgb, currentcolor 35%, transparent) 3px 6px
|
||||
);
|
||||
border-radius: 1px;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.deco--botanical {
|
||||
background:
|
||||
radial-gradient(circle at 30% 30%, currentcolor 0 18%, transparent 19%),
|
||||
radial-gradient(circle at 70% 60%, currentcolor 0 14%, transparent 15%);
|
||||
border-radius: 70% 30% 60% 40%;
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.deco--museum-mat {
|
||||
box-shadow:
|
||||
inset 0 0 0 18px color-mix(in srgb, currentcolor 22%, transparent),
|
||||
inset 0 0 0 19px currentcolor;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.deco--wax-seal {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 32% 28%,
|
||||
color-mix(in srgb, currentcolor 55%, white),
|
||||
currentcolor 58%
|
||||
),
|
||||
repeating-conic-gradient(
|
||||
from 20deg,
|
||||
currentcolor 0 12deg,
|
||||
color-mix(in srgb, currentcolor 70%, white) 12deg 24deg
|
||||
);
|
||||
border-radius: 999px;
|
||||
box-shadow:
|
||||
inset 0 0 0 4px color-mix(in srgb, currentcolor 45%, white),
|
||||
0 3px 6px color-mix(in srgb, currentcolor 28%, transparent);
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.deco--ribbon {
|
||||
background: currentcolor;
|
||||
opacity: 0.55;
|
||||
filter: drop-shadow(
|
||||
0 2px 2px color-mix(in srgb, currentcolor 25%, transparent)
|
||||
);
|
||||
clip-path: polygon(0 42%, 50% 0, 100% 42%, 82% 100%, 50% 68%, 18% 100%);
|
||||
}
|
||||
|
||||
.deco--newsprint {
|
||||
background: repeating-linear-gradient(
|
||||
-12deg,
|
||||
currentcolor 0 1px,
|
||||
transparent 1px 7px
|
||||
);
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.deco--gold-corner,
|
||||
.deco--corner-bracket {
|
||||
background:
|
||||
linear-gradient(currentcolor, currentcolor) top left / 18px 2px no-repeat,
|
||||
linear-gradient(currentcolor, currentcolor) top left / 2px 18px no-repeat,
|
||||
linear-gradient(currentcolor, currentcolor) top right / 18px 2px no-repeat,
|
||||
linear-gradient(currentcolor, currentcolor) top right / 2px 18px no-repeat,
|
||||
linear-gradient(currentcolor, currentcolor) bottom left / 18px 2px no-repeat,
|
||||
linear-gradient(currentcolor, currentcolor) bottom left / 2px 18px no-repeat,
|
||||
linear-gradient(currentcolor, currentcolor) bottom right / 18px 2px
|
||||
no-repeat,
|
||||
linear-gradient(currentcolor, currentcolor) bottom right / 2px 18px
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
.deco--corner-bracket {
|
||||
background:
|
||||
linear-gradient(currentcolor, currentcolor) top left / 22px 3px no-repeat,
|
||||
linear-gradient(currentcolor, currentcolor) top left / 3px 22px no-repeat,
|
||||
linear-gradient(currentcolor, currentcolor) bottom right / 22px 3px
|
||||
no-repeat,
|
||||
linear-gradient(currentcolor, currentcolor) bottom right / 3px 22px
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
.deco--handwriting {
|
||||
border-bottom: 1px solid currentcolor;
|
||||
opacity: 0.55;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
.deco--arch-mat {
|
||||
border: 2px solid currentcolor;
|
||||
border-radius: 140px 140px 8px 8px;
|
||||
box-shadow: inset 0 0 0 8px color-mix(in srgb, currentcolor 12%, transparent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.deco--inset-shadow {
|
||||
box-shadow:
|
||||
inset 0 2px 10px color-mix(in srgb, currentcolor 28%, transparent),
|
||||
inset 0 0 0 1px currentcolor;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.deco--torn-edge {
|
||||
box-shadow: inset 0 0 0 1px currentcolor;
|
||||
opacity: 0.35;
|
||||
clip-path: polygon(2% 3%, 98% 0, 100% 97%, 0 100%, 3% 52%);
|
||||
}
|
||||
|
||||
.deco--film-sprocket {
|
||||
background:
|
||||
repeating-linear-gradient(currentcolor 0 6px, transparent 6px 18px) left /
|
||||
8px 100% no-repeat,
|
||||
repeating-linear-gradient(currentcolor 0 6px, transparent 6px 18px) right /
|
||||
8px 100% no-repeat;
|
||||
box-shadow: inset 0 0 0 1px currentcolor;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.deco--emboss-plate {
|
||||
border: 1px solid currentcolor;
|
||||
border-radius: 2px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, currentcolor 35%, white),
|
||||
0 2px 6px color-mix(in srgb, currentcolor 18%, transparent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.deco--circle-crop {
|
||||
border: 3px solid currentcolor;
|
||||
border-radius: 999px;
|
||||
box-shadow: inset 0 0 0 6px color-mix(in srgb, currentcolor 12%, transparent);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.deco--vignette {
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
transparent 42%,
|
||||
currentcolor 100%
|
||||
);
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.deco--folio-number {
|
||||
letter-spacing: 1px;
|
||||
border: 1px solid currentcolor;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.deco--stamp-ring {
|
||||
border: 2px dashed currentcolor;
|
||||
border-radius: 999px;
|
||||
box-shadow: inset 0 0 0 6px color-mix(in srgb, currentcolor 20%, transparent);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.deco--lace-corner {
|
||||
background:
|
||||
radial-gradient(circle at 0 0, currentcolor 0 12px, transparent 13px),
|
||||
radial-gradient(circle at 100% 0, currentcolor 0 12px, transparent 13px),
|
||||
radial-gradient(circle at 0 100%, currentcolor 0 12px, transparent 13px),
|
||||
radial-gradient(circle at 100% 100%, currentcolor 0 12px, transparent 13px),
|
||||
radial-gradient(
|
||||
circle at 8px 8px,
|
||||
transparent 7px,
|
||||
currentcolor 8px 9px,
|
||||
transparent 10px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at calc(100% - 8px) 8px,
|
||||
transparent 7px,
|
||||
currentcolor 8px 9px,
|
||||
transparent 10px
|
||||
);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentcolor 35%, transparent);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.deco--thin-rule {
|
||||
height: 1px;
|
||||
background: currentcolor;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.deco--shadow-deck {
|
||||
background: color-mix(in srgb, currentcolor 18%, transparent);
|
||||
box-shadow: 4px 6px 0 color-mix(in srgb, currentcolor 22%, transparent);
|
||||
}
|
||||
|
||||
.deco--cap-rail {
|
||||
background: linear-gradient(
|
||||
currentcolor,
|
||||
color-mix(in srgb, currentcolor 55%, white)
|
||||
);
|
||||
box-shadow: 0 2px 0 color-mix(in srgb, currentcolor 30%, transparent);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.deco--gutter-fold {
|
||||
background: linear-gradient(90deg, transparent, currentcolor, transparent);
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.deco--pearl-bead {
|
||||
border: 6px dotted currentcolor;
|
||||
border-radius: 18px;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.deco--vert-caption {
|
||||
border-left: 1px solid currentcolor;
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
229
apps/web-antd/src/views/wx/template/studio/helpers.ts
Normal file
229
apps/web-antd/src/views/wx/template/studio/helpers.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import type {
|
||||
CardLayer,
|
||||
SectionItem,
|
||||
StudioModule,
|
||||
ThemeChrome,
|
||||
ThemeLayout,
|
||||
ThemeTokens,
|
||||
} from './types';
|
||||
|
||||
import { PAGE_VARIANT_FALLBACK, VARIANT_LABELS } from './types';
|
||||
|
||||
/** 把 token 名或色值解析成可上屏的颜色 */
|
||||
export function resolveColor(
|
||||
fill: string | undefined,
|
||||
tokens: ThemeTokens,
|
||||
): string {
|
||||
if (!fill) return tokens.color?.surface || '#FFFFFF';
|
||||
if (fill.startsWith('#') || fill.startsWith('rgb')) return fill;
|
||||
return tokens.color?.[fill] || tokens.color?.surface || '#FFFFFF';
|
||||
}
|
||||
|
||||
/** 合并底栏 chrome,保留动画和独立两色,避免只抄 tabbar/navbar 把配置丢掉 */
|
||||
export function normalizeChrome(chrome?: ThemeChrome): ThemeChrome {
|
||||
const next: ThemeChrome = {
|
||||
tabbar: chrome?.tabbar || 'plain',
|
||||
navbar: chrome?.navbar || 'solid',
|
||||
tabbar_anim: chrome?.tabbar_anim || 'slide',
|
||||
};
|
||||
if (chrome?.tabbar_color) next.tabbar_color = chrome.tabbar_color;
|
||||
if (chrome?.tabbar_color_on) next.tabbar_color_on = chrome.tabbar_color_on;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 缺 pages 时按旧枚举补一套默认区块,避免工作室空列表 */
|
||||
export function ensurePages(layout: ThemeLayout): ThemeLayout {
|
||||
const next: ThemeLayout = {
|
||||
...layout,
|
||||
pages: { ...layout.pages },
|
||||
};
|
||||
const home = layout.home || {};
|
||||
const product = layout.product || {};
|
||||
const list = layout.list || {};
|
||||
const mine = layout.mine || {};
|
||||
const scheme = layout.card_scheme || 'champagne-foil';
|
||||
const goodsList = normalizeGoodsList(home.product || 'waterfall');
|
||||
const defaults: Record<string, SectionItem[]> = {
|
||||
home: [
|
||||
sec('search', 'search', 'bar'),
|
||||
sec('hero', 'hero', home.hero || 'carousel', { height: 480 }),
|
||||
sec('category', 'category', home.category || 'grid', { cols: 3 }),
|
||||
sec('package', 'package', home.package || 'card'),
|
||||
sec('product', 'product', goodsList, { card_scheme: scheme }, false),
|
||||
],
|
||||
package: [sec('search', 'search', 'bar'), sec('list', 'list', 'card')],
|
||||
catalog: [
|
||||
sec('filter', 'filter', 'chip'),
|
||||
sec('list', 'list', goodsList, { card_scheme: scheme }),
|
||||
],
|
||||
search: [
|
||||
sec('search', 'search', 'bar'),
|
||||
sec('list', 'list', goodsList, { card_scheme: scheme }),
|
||||
],
|
||||
product: [
|
||||
sec('gallery', 'gallery', product.gallery || 'swiper'),
|
||||
sec('info', 'info', 'plain'),
|
||||
sec('spec', 'spec', 'plain'),
|
||||
sec('price', 'price', product.price || 'inline'),
|
||||
sec('action', 'action', product.action || 'fixed'),
|
||||
],
|
||||
cart: [sec('list', 'list', list.style || 'card')],
|
||||
mine: [
|
||||
sec('header', 'header', mine.header || 'plain'),
|
||||
sec('menu', 'menu', mine.menu || 'list'),
|
||||
],
|
||||
};
|
||||
for (const [key, sections] of Object.entries(defaults)) {
|
||||
if (next.pages?.[key]?.sections?.length) {
|
||||
const mapped = next.pages![key]!.sections.map((item) =>
|
||||
remapLegacySection(key, item),
|
||||
);
|
||||
const have = new Set(mapped.map((item) => item.type));
|
||||
for (const extra of sections) {
|
||||
if (have.has(extra.type)) continue;
|
||||
// 首页套餐插在分类后、商品流前,避免旧模板补段时掉到最底下
|
||||
if (key === 'home' && extra.type === 'package') {
|
||||
const catIdx = mapped.findIndex((item) => item.type === 'category');
|
||||
mapped.splice(catIdx !== -1 ? catIdx + 1 : mapped.length, 0, extra);
|
||||
} else {
|
||||
mapped.push(extra);
|
||||
}
|
||||
have.add(extra.type);
|
||||
}
|
||||
next.pages![key] = { ...next.pages![key], sections: mapped };
|
||||
} else {
|
||||
next.pages![key] = { sections };
|
||||
}
|
||||
}
|
||||
if (!next.card_scheme) next.card_scheme = scheme;
|
||||
if (!next.page_presets) next.page_presets = {};
|
||||
next.chrome = normalizeChrome(next.chrome);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 一键套用整页预设:写 sections + 页级 skin/density/frame + 卡片方案 */
|
||||
export function applyPagePreset(
|
||||
layout: ThemeLayout,
|
||||
page: string,
|
||||
preset: {
|
||||
card_scheme?: string;
|
||||
code: string;
|
||||
density?: string;
|
||||
frame?: string;
|
||||
sections?: SectionItem[];
|
||||
skin?: string;
|
||||
},
|
||||
): ThemeLayout {
|
||||
const pages = { ...layout.pages };
|
||||
const current = pages[page] || { sections: [] };
|
||||
pages[page] = {
|
||||
...current,
|
||||
sections: (preset.sections || []).map((item, index) => ({
|
||||
id: item.id || `${item.type}-${index}`,
|
||||
type: item.type,
|
||||
variant: item.variant,
|
||||
visible: item.visible !== false,
|
||||
props: item.props || {},
|
||||
})),
|
||||
skin: preset.skin || current.skin || 'shop',
|
||||
density: preset.density || 'regular',
|
||||
frame: preset.frame || 'none',
|
||||
card_scheme:
|
||||
preset.card_scheme || current.card_scheme || layout.card_scheme,
|
||||
};
|
||||
return syncEnums({
|
||||
...layout,
|
||||
pages,
|
||||
page_presets: { ...layout.page_presets, [page]: preset.code },
|
||||
});
|
||||
}
|
||||
|
||||
/** 旧杂志卡带报价,列表页收成瀑布 */
|
||||
function normalizeGoodsList(variant: string): string {
|
||||
if (
|
||||
variant === 'magazine' ||
|
||||
variant === 'card' ||
|
||||
variant === 'table' ||
|
||||
variant === 'timeline'
|
||||
) {
|
||||
return 'waterfall';
|
||||
}
|
||||
return variant || 'waterfall';
|
||||
}
|
||||
|
||||
function remapLegacySection(page: string, item: SectionItem): SectionItem {
|
||||
const allowed = PAGE_VARIANT_FALLBACK[page]?.[item.type];
|
||||
if (!allowed) return item;
|
||||
if (allowed.includes(item.variant)) return item;
|
||||
return { ...item, variant: allowed[0] || item.variant };
|
||||
}
|
||||
|
||||
function sec(
|
||||
id: string,
|
||||
type: string,
|
||||
variant: string,
|
||||
props: Record<string, number | string> = {},
|
||||
visible = true,
|
||||
): SectionItem {
|
||||
return { id, type, variant, visible, props };
|
||||
}
|
||||
|
||||
/** 区块改完后回写旧枚举,未升级的小程序仍能认 hero/category */
|
||||
export function syncEnums(layout: ThemeLayout): ThemeLayout {
|
||||
const pick = (page: string, type: string, fallback: string) => {
|
||||
const found = layout.pages?.[page]?.sections?.find((s) => s.type === type);
|
||||
return found?.variant || fallback;
|
||||
};
|
||||
return {
|
||||
...layout,
|
||||
home: {
|
||||
...layout.home,
|
||||
hero: pick('home', 'hero', 'carousel'),
|
||||
category: pick('home', 'category', 'grid'),
|
||||
package: pick('home', 'package', 'card'),
|
||||
product: pick('home', 'product', 'waterfall'),
|
||||
},
|
||||
product: {
|
||||
...layout.product,
|
||||
gallery: pick('product', 'gallery', 'swiper'),
|
||||
price: pick('product', 'price', 'inline'),
|
||||
action: pick('product', 'action', 'fixed'),
|
||||
},
|
||||
list: {
|
||||
...layout.list,
|
||||
style: pick('cart', 'list', 'card'),
|
||||
},
|
||||
mine: {
|
||||
...layout.mine,
|
||||
header: pick('mine', 'header', 'plain'),
|
||||
menu: pick('mine', 'menu', 'list'),
|
||||
},
|
||||
package: {
|
||||
...layout.package,
|
||||
list: pick('package', 'list', 'card'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function newLayerId(prefix: string): string {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export function cloneLayers(layers: CardLayer[]): CardLayer[] {
|
||||
return JSON.parse(JSON.stringify(layers || [])) as CardLayer[];
|
||||
}
|
||||
|
||||
/** 当前页某模块的可选样式:优先用接口 studio,否则本地兜底 */
|
||||
export function moduleVariants(
|
||||
page: string,
|
||||
type: string,
|
||||
studio: StudioModule[] | undefined,
|
||||
): { label: string; value: string }[] {
|
||||
const fromApi = studio?.find((m) => m.type === type)?.variants;
|
||||
if (fromApi?.length) return fromApi;
|
||||
const allowed =
|
||||
PAGE_VARIANT_FALLBACK[page]?.[type] ||
|
||||
Object.keys(VARIANT_LABELS[type] || {});
|
||||
const labels = VARIANT_LABELS[type] || {};
|
||||
return allowed.map((value) => ({ value, label: labels[value] || value }));
|
||||
}
|
||||
1216
apps/web-antd/src/views/wx/template/studio/index.vue
Normal file
1216
apps/web-antd/src/views/wx/template/studio/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
2011
apps/web-antd/src/views/wx/template/studio/phone-preview.vue
Normal file
2011
apps/web-antd/src/views/wx/template/studio/phone-preview.vue
Normal file
File diff suppressed because it is too large
Load Diff
304
apps/web-antd/src/views/wx/template/studio/types.ts
Normal file
304
apps/web-antd/src/views/wx/template/studio/types.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
/** 装修工作室共用类型:页面区块 + 卡片图层 */
|
||||
|
||||
export interface ThemeTokens {
|
||||
color?: Record<string, string>;
|
||||
font?: Record<string, string>;
|
||||
radius?: Record<string, string>;
|
||||
shadow?: Record<string, string>;
|
||||
space?: Record<string, string>;
|
||||
motion?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SectionItem {
|
||||
id: string;
|
||||
type: string;
|
||||
variant: string;
|
||||
visible: boolean;
|
||||
props: Record<string, number | string>;
|
||||
}
|
||||
|
||||
export interface PageSections {
|
||||
sections: SectionItem[];
|
||||
}
|
||||
|
||||
export interface PageMeta extends PageSections {
|
||||
skin?: string;
|
||||
density?: string;
|
||||
frame?: string;
|
||||
card_scheme?: string;
|
||||
card_caption?: string;
|
||||
}
|
||||
|
||||
export interface ThemeChrome {
|
||||
tabbar?: string;
|
||||
tabbar_anim?: string;
|
||||
tabbar_color?: string;
|
||||
tabbar_color_on?: string;
|
||||
navbar?: string;
|
||||
}
|
||||
|
||||
export interface ThemeLayout {
|
||||
home?: Record<string, string>;
|
||||
product?: Record<string, string>;
|
||||
list?: Record<string, string>;
|
||||
mine?: Record<string, string>;
|
||||
package?: Record<string, string>;
|
||||
effect?: Record<string, string>;
|
||||
chrome?: ThemeChrome;
|
||||
card_scheme?: string;
|
||||
page_presets?: Record<string, string>;
|
||||
pages?: Record<string, PageMeta>;
|
||||
}
|
||||
|
||||
export interface CardLayer {
|
||||
id: string;
|
||||
type: 'deco' | 'photo' | 'shape' | 'text';
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
rotate?: number;
|
||||
z?: number;
|
||||
visible?: boolean;
|
||||
locked?: boolean;
|
||||
bind?: string;
|
||||
font?: string;
|
||||
asset?: string;
|
||||
fill?: string;
|
||||
tone?: string;
|
||||
text?: string;
|
||||
src?: string;
|
||||
font_zh?: string;
|
||||
font_en?: string;
|
||||
}
|
||||
|
||||
export interface CardScheme {
|
||||
id?: number;
|
||||
code: string;
|
||||
name: string;
|
||||
layers: CardLayer[];
|
||||
is_preset?: number;
|
||||
preview?: string;
|
||||
family?: string;
|
||||
}
|
||||
|
||||
export interface PagePresetItem {
|
||||
code: string;
|
||||
name: string;
|
||||
family?: string;
|
||||
skin?: string;
|
||||
card_scheme?: string;
|
||||
density?: string;
|
||||
frame?: string;
|
||||
hero?: string;
|
||||
gallery?: string;
|
||||
list?: string;
|
||||
sections?: SectionItem[];
|
||||
}
|
||||
|
||||
export interface StudioVariant {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface StudioModule {
|
||||
type: string;
|
||||
variants: StudioVariant[];
|
||||
}
|
||||
|
||||
export interface StudioPage {
|
||||
key: string;
|
||||
modules: StudioModule[];
|
||||
}
|
||||
|
||||
export const PAGE_KEYS = [
|
||||
{ key: 'home', label: '首页' },
|
||||
{ key: 'package', label: '套餐' },
|
||||
{ key: 'catalog', label: '列表' },
|
||||
{ key: 'search', label: '搜索' },
|
||||
{ key: 'product', label: '详情' },
|
||||
{ key: 'cart', label: '清单' },
|
||||
{ key: 'mine', label: '我的' },
|
||||
] as const;
|
||||
|
||||
export const SECTION_TITLES: Record<string, string> = {
|
||||
search: '搜索',
|
||||
hero: '头图 / 轮播',
|
||||
category: '分类',
|
||||
package: '热门套餐',
|
||||
product: '商品流(可选)',
|
||||
filter: '顶栏筛选',
|
||||
list: '列表',
|
||||
gallery: '相册',
|
||||
info: '标题简介',
|
||||
spec: '规格报价',
|
||||
price: '报价位置',
|
||||
action: '加入清单',
|
||||
header: '头部',
|
||||
menu: '菜单',
|
||||
};
|
||||
|
||||
/** 兜底中文名:接口 schema 没回来时仍能画画廊 */
|
||||
export const VARIANT_LABELS: Record<string, Record<string, string>> = {
|
||||
hero: {
|
||||
banner: '横幅',
|
||||
carousel: '轮播',
|
||||
split: '分栏',
|
||||
fullscreen: '全屏',
|
||||
stack: '堆叠',
|
||||
coverflow: '封面流',
|
||||
fade: '叠化',
|
||||
peek: '露边',
|
||||
cube: '立方',
|
||||
caption: '刊名条',
|
||||
},
|
||||
category: {
|
||||
grid: '宫格',
|
||||
scroll: '横滑',
|
||||
card: '卡片',
|
||||
sidebar: '侧栏',
|
||||
pills: '胶囊',
|
||||
mosaic: '马赛克',
|
||||
featured: '首图',
|
||||
tile: '瓷砖',
|
||||
contents: '刊页目录',
|
||||
},
|
||||
package: {
|
||||
card: '卡片',
|
||||
scroll: '横滑',
|
||||
featured: '首图',
|
||||
magazine: '刊名条',
|
||||
list: '单列',
|
||||
},
|
||||
product: { waterfall: '瀑布', list: '单列', grid: '双列' },
|
||||
list: {
|
||||
card: '卡片',
|
||||
table: '表格',
|
||||
timeline: '时间线',
|
||||
waterfall: '瀑布',
|
||||
grid: '双列',
|
||||
list: '单列',
|
||||
masonry: '砌石',
|
||||
featured: '首图',
|
||||
magazine: '刊名条',
|
||||
shelf: '货架',
|
||||
compact: '紧凑',
|
||||
airy: '疏朗',
|
||||
mosaic: '马赛克',
|
||||
duo: '对开',
|
||||
ticket: '票根',
|
||||
stacked: '层叠',
|
||||
},
|
||||
gallery: {
|
||||
swiper: '轮播',
|
||||
stack: '叠图',
|
||||
fullbleed: '全出血',
|
||||
peek: '露边',
|
||||
fade: '叠化',
|
||||
coverflow: '封面流',
|
||||
mosaic: '拼贴',
|
||||
filmstrip: '胶卷',
|
||||
},
|
||||
action: { fixed: '钉在底部', inline: '跟正文走', split: '左右拆开' },
|
||||
header: {
|
||||
gradient: '渐变',
|
||||
image: '头图',
|
||||
plain: '素底',
|
||||
split: '分栏',
|
||||
editorial: '编辑',
|
||||
},
|
||||
menu: {
|
||||
grid: '宫格',
|
||||
list: '列表',
|
||||
card: '卡片',
|
||||
tile: '瓷砖',
|
||||
compact: '紧凑',
|
||||
},
|
||||
filter: { chip: '分类条', bar: '仅搜索', sidebar: '侧栏', hidden: '隐藏' },
|
||||
search: { bar: '搜索条', overlay: '压在封面', pill: '胶囊', float: '悬浮' },
|
||||
info: { plain: '常规', editorial: '编辑', split: '分栏', overlay: '压字' },
|
||||
spec: { plain: '规格表', table: '表格', chips: '芯片', cards: '卡片' },
|
||||
price: { inline: '跟规格走', card: '独立报价卡', sticky: '吸顶报价' },
|
||||
};
|
||||
|
||||
export const PAGE_VARIANT_FALLBACK: Record<string, Record<string, string[]>> = {
|
||||
home: {
|
||||
product: ['waterfall', 'grid', 'list'],
|
||||
package: ['card', 'scroll', 'featured', 'magazine'],
|
||||
},
|
||||
package: {
|
||||
search: ['bar', 'pill', 'overlay', 'float'],
|
||||
list: ['card', 'featured', 'magazine', 'list'],
|
||||
},
|
||||
catalog: {
|
||||
list: [
|
||||
'waterfall',
|
||||
'grid',
|
||||
'list',
|
||||
'masonry',
|
||||
'featured',
|
||||
'shelf',
|
||||
'compact',
|
||||
'airy',
|
||||
'mosaic',
|
||||
'duo',
|
||||
],
|
||||
},
|
||||
search: {
|
||||
list: [
|
||||
'waterfall',
|
||||
'grid',
|
||||
'list',
|
||||
'masonry',
|
||||
'featured',
|
||||
'shelf',
|
||||
'compact',
|
||||
'airy',
|
||||
'mosaic',
|
||||
'duo',
|
||||
],
|
||||
},
|
||||
cart: { list: ['card', 'table', 'timeline', 'compact', 'ticket', 'stacked'] },
|
||||
};
|
||||
|
||||
export const SCHEME_FAMILY_LABEL: Record<string, string> = {
|
||||
lux: '轻奢',
|
||||
editorial: '欧美',
|
||||
atelier: '手作',
|
||||
};
|
||||
|
||||
export const CHROME_TABBAR_OPTIONS = [
|
||||
{ value: 'plain', label: '常规' },
|
||||
{ value: 'line', label: '滑块' },
|
||||
{ value: 'pill', label: '胶囊' },
|
||||
{ value: 'dot', label: '圆点' },
|
||||
] as const;
|
||||
|
||||
export const CHROME_TABBAR_ANIM_OPTIONS = [
|
||||
{ value: 'none', label: '无' },
|
||||
{ value: 'fade', label: '淡入' },
|
||||
{ value: 'slide', label: '滑动' },
|
||||
{ value: 'spring', label: '回弹' },
|
||||
] as const;
|
||||
|
||||
export const CHROME_NAVBAR_OPTIONS = [
|
||||
{ value: 'solid', label: '实底' },
|
||||
{ value: 'line', label: '细线' },
|
||||
] as const;
|
||||
|
||||
export const LAYER_FACE_OPTIONS = [
|
||||
{ value: 'sans', label: '黑体' },
|
||||
{ value: 'serif', label: '宋体' },
|
||||
{ value: 'kai', label: '楷体' },
|
||||
{ value: 'script', label: '手写' },
|
||||
] as const;
|
||||
|
||||
export const PALETTE_FIELDS = [
|
||||
{ key: 'primary', label: '主色' },
|
||||
{ key: 'accent', label: '辅色' },
|
||||
{ key: 'bg', label: '背景' },
|
||||
{ key: 'surface', label: '表面' },
|
||||
{ key: 'text', label: '正文' },
|
||||
{ key: 'border', label: '边线' },
|
||||
{ key: 'price', label: '价格(仅详情)' },
|
||||
] as const;
|
||||
@@ -4,8 +4,17 @@ export default defineConfig(async () => {
|
||||
return {
|
||||
application: {},
|
||||
vite: {
|
||||
// exceljs 默认入口带 Node fs,浏览器走打包好的 UMD
|
||||
optimizeDeps: {
|
||||
include: ['exceljs'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
exceljs: 'exceljs/dist/exceljs.min.js',
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 14002,
|
||||
port: 14_002,
|
||||
proxy: {
|
||||
'/api': {
|
||||
changeOrigin: true,
|
||||
|
||||
325
pnpm-lock.yaml
generated
325
pnpm-lock.yaml
generated
@@ -758,6 +758,9 @@ importers:
|
||||
dayjs:
|
||||
specifier: 'catalog:'
|
||||
version: 1.11.21
|
||||
exceljs:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
jszip:
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.1
|
||||
@@ -3753,6 +3756,12 @@ packages:
|
||||
resolution: {integrity: sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
|
||||
|
||||
'@fast-csv/format@4.3.5':
|
||||
resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==}
|
||||
|
||||
'@fast-csv/parse@4.3.6':
|
||||
resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==}
|
||||
|
||||
'@floating-ui/core@1.8.0':
|
||||
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
|
||||
|
||||
@@ -5629,6 +5638,9 @@ packages:
|
||||
'@types/node@12.20.55':
|
||||
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
|
||||
|
||||
'@types/node@14.18.63':
|
||||
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
|
||||
|
||||
'@types/node@24.13.3':
|
||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||
|
||||
@@ -6483,10 +6495,22 @@ packages:
|
||||
dmg-builder: 26.15.3
|
||||
electron-builder-squirrel-windows: 26.15.3
|
||||
|
||||
archiver-utils@2.1.0:
|
||||
resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
archiver-utils@3.0.4:
|
||||
resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
archiver-utils@5.0.2:
|
||||
resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
archiver@5.3.2:
|
||||
resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
archiver@7.0.1:
|
||||
resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -6671,9 +6695,16 @@ packages:
|
||||
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
big-integer@1.6.52:
|
||||
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
binary@0.3.0:
|
||||
resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==}
|
||||
|
||||
bindings@1.5.0:
|
||||
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
|
||||
|
||||
@@ -6683,6 +6714,12 @@ packages:
|
||||
birpc@4.1.0:
|
||||
resolution: {integrity: sha512-O8L9vALWGqdEe0cG4HJckauw3WeJETlJnDRPUYpgwB7wrU43b/5NGMdVjdVcRo+4ROgd3ih2wha1glDe4HRVgw==}
|
||||
|
||||
bl@4.1.0:
|
||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||
|
||||
bluebird@3.4.7:
|
||||
resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
|
||||
|
||||
bluebird@3.7.2:
|
||||
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
|
||||
|
||||
@@ -6716,6 +6753,9 @@ packages:
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
buffer-crc32@0.2.13:
|
||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||
|
||||
buffer-crc32@1.0.0:
|
||||
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -6730,9 +6770,20 @@ packages:
|
||||
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
buffer-indexof-polyfill@1.0.2:
|
||||
resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
buffer@6.0.3:
|
||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||
|
||||
buffers@0.1.1:
|
||||
resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==}
|
||||
engines: {node: '>=0.2.0'}
|
||||
|
||||
builder-util-runtime@9.7.0:
|
||||
resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -6813,6 +6864,9 @@ packages:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chainsaw@0.1.0:
|
||||
resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==}
|
||||
|
||||
chalk-template@1.1.2:
|
||||
resolution: {integrity: sha512-2bxTP2yUH7AJj/VAXfcA+4IcWGdQ87HwBANLt5XxGTeomo8yG0y95N1um9i5StvhT/Bl0/2cARA5v1PpPXUxUA==}
|
||||
engines: {node: '>=14.16'}
|
||||
@@ -6997,6 +7051,10 @@ packages:
|
||||
compatx@0.2.0:
|
||||
resolution: {integrity: sha512-6gLRNt4ygsi5NyMVhceOCFv14CIdDFN7fQjX1U4+47qVE/+kjPoXMK65KWK+dWxmFzMTuKazoQ9sch6pM0p5oA==}
|
||||
|
||||
compress-commons@4.1.2:
|
||||
resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
compress-commons@6.0.2:
|
||||
resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -7096,6 +7154,10 @@ packages:
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
crc32-stream@4.0.3:
|
||||
resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
crc32-stream@6.0.0:
|
||||
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -7878,6 +7940,10 @@ packages:
|
||||
evtd@0.2.4:
|
||||
resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==}
|
||||
|
||||
exceljs@4.4.0:
|
||||
resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==}
|
||||
engines: {node: '>=8.3.0'}
|
||||
|
||||
execa@10.0.1:
|
||||
resolution: {integrity: sha512-ge98qjkRK4IB7tL7Ju/6qmm5LHoH1eEMt5FNZrz3f4UIYhF28lggX20z3FaX1sgc67msLEn0N0BscOs29iuwyw==}
|
||||
engines: {node: '>=22'}
|
||||
@@ -7903,6 +7969,10 @@ packages:
|
||||
extendable-error@0.1.7:
|
||||
resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==}
|
||||
|
||||
fast-csv@4.3.6:
|
||||
resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -8036,6 +8106,9 @@ packages:
|
||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
fs-constants@1.0.0:
|
||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||
|
||||
fs-extra@10.1.0:
|
||||
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -8073,6 +8146,11 @@ packages:
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fstream@1.0.12:
|
||||
resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==}
|
||||
engines: {node: '>=0.6'}
|
||||
deprecated: This package is no longer supported.
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
@@ -9092,6 +9170,9 @@ packages:
|
||||
linkifyjs@4.3.3:
|
||||
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
|
||||
|
||||
listenercount@1.0.1:
|
||||
resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==}
|
||||
|
||||
listhen@1.10.1:
|
||||
resolution: {integrity: sha512-6nt/86SkqUQSLW1ofz8MxC6RhRMqOl3ONISe6qqvJ3xj09aJWQx6DhgSZpugs3PX4PXdOas/WD6A9jx6J2N19A==}
|
||||
hasBin: true
|
||||
@@ -9137,9 +9218,21 @@ packages:
|
||||
lodash.debounce@4.0.8:
|
||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||
|
||||
lodash.defaults@4.2.0:
|
||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||
|
||||
lodash.difference@4.5.0:
|
||||
resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==}
|
||||
|
||||
lodash.escaperegexp@4.1.2:
|
||||
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
|
||||
|
||||
lodash.flatten@4.4.0:
|
||||
resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==}
|
||||
|
||||
lodash.groupby@4.6.0:
|
||||
resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==}
|
||||
|
||||
lodash.includes@4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
|
||||
@@ -9150,9 +9243,15 @@ packages:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||
|
||||
lodash.isfunction@3.0.9:
|
||||
resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==}
|
||||
|
||||
lodash.isinteger@4.0.4:
|
||||
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||
|
||||
lodash.isnil@4.0.0:
|
||||
resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==}
|
||||
|
||||
lodash.isnumber@3.0.3:
|
||||
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||
|
||||
@@ -9162,6 +9261,9 @@ packages:
|
||||
lodash.isstring@4.0.1:
|
||||
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||
|
||||
lodash.isundefined@3.0.1:
|
||||
resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
@@ -9174,6 +9276,12 @@ packages:
|
||||
lodash.truncate@4.4.2:
|
||||
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
|
||||
|
||||
lodash.union@4.6.0:
|
||||
resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==}
|
||||
|
||||
lodash.uniq@4.5.0:
|
||||
resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==}
|
||||
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
||||
@@ -10124,6 +10232,10 @@ packages:
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
readable-stream@4.7.0:
|
||||
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
@@ -10490,6 +10602,10 @@ packages:
|
||||
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
|
||||
saxes@5.0.1:
|
||||
resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
scroll-into-view-if-needed@2.2.31:
|
||||
resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==}
|
||||
|
||||
@@ -10957,6 +11073,10 @@ packages:
|
||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar-stream@2.2.0:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar-stream@3.2.0:
|
||||
resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==}
|
||||
|
||||
@@ -11069,6 +11189,9 @@ packages:
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
traverse@0.3.9:
|
||||
resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==}
|
||||
|
||||
tree-kill@1.2.2:
|
||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||
hasBin: true
|
||||
@@ -11481,6 +11604,9 @@ packages:
|
||||
unwasm@0.5.3:
|
||||
resolution: {integrity: sha512-keBgTSfp3r6+s9ZcSma+0chwxQdmLbB5+dAD9vjtB21UTMYuKAxHXCU1K2CbCtnP09EaWeRvACnXk0EJtUx+hw==}
|
||||
|
||||
unzipper@0.10.14:
|
||||
resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==}
|
||||
|
||||
unzipper@0.12.5:
|
||||
resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==}
|
||||
|
||||
@@ -11510,6 +11636,10 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
hasBin: true
|
||||
|
||||
valibot@1.4.2:
|
||||
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
|
||||
peerDependencies:
|
||||
@@ -12000,6 +12130,9 @@ packages:
|
||||
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
xmlchars@2.2.0:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
@@ -12081,6 +12214,10 @@ packages:
|
||||
yuku-parser@0.8.4:
|
||||
resolution: {integrity: sha512-sw41wouvT5rUmLIp87hmvm5vtF+MRSI3x6yjq6xqpYmtkQj+Ht6N7xRQ8lMhLv8N7JAzughGj0Rfi0jQRSu9HQ==}
|
||||
|
||||
zip-stream@4.1.1:
|
||||
resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
zip-stream@6.0.1:
|
||||
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -13848,6 +13985,25 @@ snapshots:
|
||||
|
||||
'@faker-js/faker@10.5.0': {}
|
||||
|
||||
'@fast-csv/format@4.3.5':
|
||||
dependencies:
|
||||
'@types/node': 14.18.63
|
||||
lodash.escaperegexp: 4.1.2
|
||||
lodash.isboolean: 3.0.3
|
||||
lodash.isequal: 4.5.0
|
||||
lodash.isfunction: 3.0.9
|
||||
lodash.isnil: 4.0.0
|
||||
|
||||
'@fast-csv/parse@4.3.6':
|
||||
dependencies:
|
||||
'@types/node': 14.18.63
|
||||
lodash.escaperegexp: 4.1.2
|
||||
lodash.groupby: 4.6.0
|
||||
lodash.isfunction: 3.0.9
|
||||
lodash.isnil: 4.0.0
|
||||
lodash.isundefined: 3.0.1
|
||||
lodash.uniq: 4.5.0
|
||||
|
||||
'@floating-ui/core@1.8.0':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.12
|
||||
@@ -15502,6 +15658,8 @@ snapshots:
|
||||
|
||||
'@types/node@12.20.55': {}
|
||||
|
||||
'@types/node@14.18.63': {}
|
||||
|
||||
'@types/node@24.13.3':
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
@@ -16524,6 +16682,32 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
archiver-utils@2.1.0:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
graceful-fs: 4.2.11
|
||||
lazystream: 1.0.1
|
||||
lodash.defaults: 4.2.0
|
||||
lodash.difference: 4.5.0
|
||||
lodash.flatten: 4.4.0
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.union: 4.6.0
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 2.3.8
|
||||
|
||||
archiver-utils@3.0.4:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
graceful-fs: 4.2.11
|
||||
lazystream: 1.0.1
|
||||
lodash.defaults: 4.2.0
|
||||
lodash.difference: 4.5.0
|
||||
lodash.flatten: 4.4.0
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.union: 4.6.0
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 3.6.2
|
||||
|
||||
archiver-utils@5.0.2:
|
||||
dependencies:
|
||||
glob: 10.4.5
|
||||
@@ -16534,6 +16718,16 @@ snapshots:
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
|
||||
archiver@5.3.2:
|
||||
dependencies:
|
||||
archiver-utils: 2.1.0
|
||||
async: 3.2.6
|
||||
buffer-crc32: 0.2.13
|
||||
readable-stream: 3.6.2
|
||||
readdir-glob: 1.1.3
|
||||
tar-stream: 2.2.0
|
||||
zip-stream: 4.1.1
|
||||
|
||||
archiver@7.0.1:
|
||||
dependencies:
|
||||
archiver-utils: 5.0.2
|
||||
@@ -16728,8 +16922,15 @@ snapshots:
|
||||
dependencies:
|
||||
is-windows: 1.0.2
|
||||
|
||||
big-integer@1.6.52: {}
|
||||
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
binary@0.3.0:
|
||||
dependencies:
|
||||
buffers: 0.1.1
|
||||
chainsaw: 0.1.0
|
||||
|
||||
bindings@1.5.0:
|
||||
dependencies:
|
||||
file-uri-to-path: 1.0.0
|
||||
@@ -16738,6 +16939,14 @@ snapshots:
|
||||
|
||||
birpc@4.1.0: {}
|
||||
|
||||
bl@4.1.0:
|
||||
dependencies:
|
||||
buffer: 5.7.1
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
bluebird@3.4.7: {}
|
||||
|
||||
bluebird@3.7.2: {}
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
@@ -16781,6 +16990,8 @@ snapshots:
|
||||
node-releases: 2.0.53
|
||||
update-browserslist-db: 1.3.0(browserslist@4.28.8)
|
||||
|
||||
buffer-crc32@0.2.13: {}
|
||||
|
||||
buffer-crc32@1.0.0: {}
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
@@ -16791,11 +17002,20 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 26.2.0
|
||||
|
||||
buffer-indexof-polyfill@1.0.2: {}
|
||||
|
||||
buffer@5.7.1:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
buffer@6.0.3:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
buffers@0.1.1: {}
|
||||
|
||||
builder-util-runtime@9.7.0(supports-color@10.2.2):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
@@ -16903,6 +17123,10 @@ snapshots:
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chainsaw@0.1.0:
|
||||
dependencies:
|
||||
traverse: 0.3.9
|
||||
|
||||
chalk-template@1.1.2:
|
||||
dependencies:
|
||||
chalk: 5.6.2
|
||||
@@ -17074,6 +17298,13 @@ snapshots:
|
||||
|
||||
compatx@0.2.0: {}
|
||||
|
||||
compress-commons@4.1.2:
|
||||
dependencies:
|
||||
buffer-crc32: 0.2.13
|
||||
crc32-stream: 4.0.3
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 3.6.2
|
||||
|
||||
compress-commons@6.0.2:
|
||||
dependencies:
|
||||
crc-32: 1.2.2
|
||||
@@ -17177,6 +17408,11 @@ snapshots:
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crc32-stream@4.0.3:
|
||||
dependencies:
|
||||
crc-32: 1.2.2
|
||||
readable-stream: 3.6.2
|
||||
|
||||
crc32-stream@6.0.0:
|
||||
dependencies:
|
||||
crc-32: 1.2.2
|
||||
@@ -18135,6 +18371,18 @@ snapshots:
|
||||
|
||||
evtd@0.2.4: {}
|
||||
|
||||
exceljs@4.4.0:
|
||||
dependencies:
|
||||
archiver: 5.3.2
|
||||
dayjs: 1.11.21
|
||||
fast-csv: 4.3.6
|
||||
jszip: 3.10.1
|
||||
readable-stream: 3.6.2
|
||||
saxes: 5.0.1
|
||||
tmp: 0.2.7
|
||||
unzipper: 0.10.14
|
||||
uuid: 8.3.2
|
||||
|
||||
execa@10.0.1:
|
||||
dependencies:
|
||||
'@sindresorhus/merge-streams': 4.0.0
|
||||
@@ -18177,6 +18425,11 @@ snapshots:
|
||||
|
||||
extendable-error@0.1.7: {}
|
||||
|
||||
fast-csv@4.3.6:
|
||||
dependencies:
|
||||
'@fast-csv/format': 4.3.5
|
||||
'@fast-csv/parse': 4.3.6
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-equals@6.0.2: {}
|
||||
@@ -18308,6 +18561,8 @@ snapshots:
|
||||
|
||||
fresh@2.0.0: {}
|
||||
|
||||
fs-constants@1.0.0: {}
|
||||
|
||||
fs-extra@10.1.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -18353,6 +18608,13 @@ snapshots:
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
fstream@1.0.12:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
inherits: 2.0.4
|
||||
mkdirp: 0.5.6
|
||||
rimraf: 2.6.3
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
function.prototype.name@1.2.0:
|
||||
@@ -19353,6 +19615,8 @@ snapshots:
|
||||
|
||||
linkifyjs@4.3.3: {}
|
||||
|
||||
listenercount@1.0.1: {}
|
||||
|
||||
listhen@1.10.1(@parcel/watcher@2.6.0):
|
||||
dependencies:
|
||||
'@parcel/watcher-wasm': 2.6.0
|
||||
@@ -19412,22 +19676,36 @@ snapshots:
|
||||
|
||||
lodash.debounce@4.0.8: {}
|
||||
|
||||
lodash.defaults@4.2.0: {}
|
||||
|
||||
lodash.difference@4.5.0: {}
|
||||
|
||||
lodash.escaperegexp@4.1.2: {}
|
||||
|
||||
lodash.flatten@4.4.0: {}
|
||||
|
||||
lodash.groupby@4.6.0: {}
|
||||
|
||||
lodash.includes@4.3.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
|
||||
lodash.isequal@4.5.0: {}
|
||||
|
||||
lodash.isfunction@3.0.9: {}
|
||||
|
||||
lodash.isinteger@4.0.4: {}
|
||||
|
||||
lodash.isnil@4.0.0: {}
|
||||
|
||||
lodash.isnumber@3.0.3: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.isstring@4.0.1: {}
|
||||
|
||||
lodash.isundefined@3.0.1: {}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
optional: true
|
||||
|
||||
@@ -19437,6 +19715,10 @@ snapshots:
|
||||
|
||||
lodash.truncate@4.4.2: {}
|
||||
|
||||
lodash.union@4.6.0: {}
|
||||
|
||||
lodash.uniq@4.5.0: {}
|
||||
|
||||
lodash@4.18.1: {}
|
||||
|
||||
log-symbols@7.0.1:
|
||||
@@ -20563,6 +20845,12 @@ snapshots:
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readable-stream@3.6.2:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readable-stream@4.7.0:
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
@@ -20960,6 +21248,10 @@ snapshots:
|
||||
|
||||
sax@1.6.1: {}
|
||||
|
||||
saxes@5.0.1:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
scroll-into-view-if-needed@2.2.31:
|
||||
dependencies:
|
||||
compute-scroll-into-view: 1.0.20
|
||||
@@ -21496,6 +21788,14 @@ snapshots:
|
||||
|
||||
tapable@2.3.3: {}
|
||||
|
||||
tar-stream@2.2.0:
|
||||
dependencies:
|
||||
bl: 4.1.0
|
||||
end-of-stream: 1.4.5
|
||||
fs-constants: 1.0.0
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
tar-stream@3.2.0:
|
||||
dependencies:
|
||||
b4a: 1.8.1
|
||||
@@ -21625,6 +21925,8 @@ snapshots:
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
traverse@0.3.9: {}
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
|
||||
treemate@0.3.11: {}
|
||||
@@ -22010,6 +22312,19 @@ snapshots:
|
||||
pathe: 2.0.3
|
||||
pkg-types: 2.3.1
|
||||
|
||||
unzipper@0.10.14:
|
||||
dependencies:
|
||||
big-integer: 1.6.52
|
||||
binary: 0.3.0
|
||||
bluebird: 3.4.7
|
||||
buffer-indexof-polyfill: 1.0.2
|
||||
duplexer2: 0.1.4
|
||||
fstream: 1.0.12
|
||||
graceful-fs: 4.2.11
|
||||
listenercount: 1.0.1
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
unzipper@0.12.5:
|
||||
dependencies:
|
||||
bluebird: 3.7.2
|
||||
@@ -22049,6 +22364,8 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
valibot@1.4.2(typescript@6.0.3):
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
@@ -22652,6 +22969,8 @@ snapshots:
|
||||
|
||||
xmlbuilder@15.1.1: {}
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
@@ -22774,6 +23093,12 @@ snapshots:
|
||||
'@yuku-parser/binding-win32-arm64': 0.8.4
|
||||
'@yuku-parser/binding-win32-x64': 0.8.4
|
||||
|
||||
zip-stream@4.1.1:
|
||||
dependencies:
|
||||
archiver-utils: 3.0.4
|
||||
compress-commons: 4.1.2
|
||||
readable-stream: 3.6.2
|
||||
|
||||
zip-stream@6.0.1:
|
||||
dependencies:
|
||||
archiver-utils: 5.0.2
|
||||
|
||||
Reference in New Issue
Block a user