feat: GlassCard双视图、数据看板重构、地址粘贴识别、AI平台与素材库增强

This commit is contained in:
李琦
2026-10-19 16:20:07 +08:00
parent 50fc55925f
commit 98eb751392
180 changed files with 32500 additions and 7812 deletions

View File

@@ -44,6 +44,26 @@ alwaysApply: true
- HTTP 请求统一用 `requestClient`(来自 `#/api/request`),**禁止直接 axios / fetch**
- 行操作和工具栏按钮统一用 `<TableAction>` 组件(来自 `#/components/table-action`)
- 业务选择器(员工、医生、门店、推广员、地区等)必须复用 `#/components/form/components/*-picker.vue`,不要重写
- **卡片统一用液态玻璃组件**:新增/改动的卡片一律用 `GlassCard`(`#/components/glass-card`),禁止再手写一套 `backdrop-filter` 玻璃样式或新起 antd `Card`;历史页面暂不强制回改
- 列表页需要「列表/卡片」双视图时:`useViewMode` + `ViewModeSwitch`(`#/components/view-mode-switch`)切换,卡片模式用 `GlassCardList`(分页拉数)+ `#card` 插槽里放 `GlassCard`;搜索表单抽到页面顶部两种视图共用(参考 `business/order/product-order/index.vue`)
- **GlassCard 底部操作按钮(强制)**:统一用 `CardActions`(`#/components/table-action`),默认即玻璃胶囊(`variant` 默认 `glass`:半透明底 + blur + 1px 同色系边框微光;主操作 primary、危险 destructive);布局默认 `layout="fill"` 等分沾满 footer,需要居中时显式 `layout="center"`;尺寸用 `size`:`xs` 超级小 / `sm` 小 / `md` 中(默认) / `lg` 大 / `half` 沾满一半 / `full` 沾满整行。放在 `GlassCard` 的 `#footer` 插槽里,**禁止**再在卡片里用 antd `Button` / `TableAction` 的 link 文字链或灰框 default 按钮;禁止包一层 `flex justify-end` 把按钮挤到一角。示例:
```vue
<GlassCard ...>
<!-- 内容 -->
<template #footer>
<CardActions
size="md"
:max-visible="3"
:actions="[
{ label: '绑定等级', icon: 'lucide:link-2', onClick: ... },
{ label: '编辑', icon: 'lucide:pencil', onClick: ... },
{ label: '删除', icon: 'lucide:trash-2', danger: true, popConfirm: ... },
]"
/>
</template>
</GlassCard>
```
## API 层规范

View File

@@ -1,6 +1,6 @@
{
"name": "@vben/desktop",
"version": "0.1.1",
"version": "0.1.3",
"private": true,
"description": "萧康云医 PC 桌面端(Electron 壳,打包加载 web-antd 生产构建产物)",
"author": "萧康云医",

View File

@@ -30,9 +30,26 @@ export interface FileTypeItem {
sort: number;
}
/** 扫描写入的引用位置(详情展示,含表/字段注释) */
export interface FileGalleryRefLoc {
connection: string;
connection_text: string;
table: string;
/** 表 COMMENT,可能为空 */
table_comment?: string;
column: string;
/** 字段 COMMENT,可能为空 */
column_comment?: string;
count: number;
label: string;
}
export interface FileGalleryDetail extends FileGalleryItem {
source_text?: string;
updated_at?: string;
ref_scanned_at?: number | string;
/** 引用位置列表,未扫描或未命中为空数组 */
ref_locs?: FileGalleryRefLoc[];
}
export interface FileGalleryStats {
@@ -43,6 +60,8 @@ export interface FileGalleryStats {
recyclable_size: number;
recyclable_size_text: string;
has_scanned: boolean;
/** 上次 OSS 同步完成日期 YYYYMMDD,空表示尚未同步 */
oss_last_sync_date?: string;
}
export interface FileGalleryListParams {
@@ -53,6 +72,9 @@ export interface FileGalleryListParams {
group_id?: number;
keyword?: string;
exclude_qrcode?: number;
/** all|referenced|unreferenced */
ref_filter?: string;
/** @deprecated 用 ref_filter=unreferenced */
unref_only?: number;
folder_id?: number;
unfiled?: number;
@@ -97,31 +119,36 @@ export async function getFileTypes() {
return requestClient.get<FileTypeItem[]>(`${prefix}file-types`);
}
/** 规划 OSS 同步日期分组(full 全部 / incremental 从库内最新日期起) */
export async function planFileGalleryOssSync(data?: { mode?: 'full' | 'incremental' }) {
/** 规划 OSS 同步日期分组(full 全部 / incremental 从上次同步日期起) */
export async function planFileGalleryOssSync(data?: {
mode?: 'full' | 'incremental';
}) {
return requestClient.post<{
last_sync_date: string;
mode: 'full' | 'incremental';
prefixes: string[];
total: number;
since_date: string;
total: number;
}>(`${prefix}sync-oss-plan`, data ?? {});
}
/** 从 OSS 同步一批(按日期 prefix + marker;全部组结束后 finalize=1) */
/** 从 OSS 同步一批(按日期 prefix + marker;全部组结束后 finalize=1 并落库 last_sync_date) */
export async function syncFileGalleryFromOss(data?: {
finalize?: number;
last_sync_date?: string;
limit?: number;
marker?: string;
prefix?: string;
finalize?: number;
}) {
return requestClient.post<{
done: boolean;
folder_created: number;
last_sync_date?: string;
next_marker: string;
prefix: string;
scanned: number;
skipped: number;
synced: number;
next_marker: string;
done: boolean;
folder_created: number;
prefix: string;
}>(`${prefix}sync-oss`, data ?? {});
}
@@ -157,18 +184,57 @@ export async function markFileQrcode(id: number, is_qrcode: number) {
);
}
/** 扫描业务表 URL 引用(两库全表文本列) */
export async function scanFileGalleryRefs() {
/** 扫描计划中的单表:pending 未开始 / scanning 扫描中 / done 完成 */
export interface FileRefScanTableItem {
connection: string;
table: string;
/** 兼容字段,等同 total_columns */
columns: number;
total_columns?: number;
done_columns?: number;
failed: number;
status?: 'done' | 'pending' | 'scanning';
}
/** 预览将扫描的表清单(全部 pending,不开始扫) */
export async function planFileGalleryScanRefs() {
return requestClient.get<{
column_total: number;
plan_tables: FileRefScanTableItem[];
table_count: number;
}>(`${prefix}scan-refs-plan`);
}
/** 分批扫描业务表 URL 引用;循环传 job_id 直到 done,支持断点续扫 */
export async function scanFileGalleryRefs(data?: {
batch_size?: number;
job_id?: string;
}) {
return requestClient.post<{
elapsed_ms: number;
batch_size: number;
batch_tables?: FileRefScanTableItem[];
current_table?: string;
done: boolean;
done_table_count?: number;
elapsed_ms?: number;
job_id: string;
mysql_columns: number;
mysql_failed: number;
old_mysql_columns: number;
old_mysql_failed: number;
referenced: number;
scanned_files: number;
unreferenced: number;
}>(`${prefix}scan-refs`);
pending_table_count?: number;
phase: string;
plan_tables?: FileRefScanTableItem[];
processed: number;
progress: number;
referenced?: number;
scanned_files?: number;
scanned_tables?: FileRefScanTableItem[];
scanning_table_count?: number;
table_count?: number;
total: number;
unreferenced?: number;
}>(`${prefix}scan-refs`, data ?? {});
}
/** 批量加入分组(移动到文件夹) */

View File

@@ -0,0 +1,516 @@
<script lang="ts" setup>
/**
* PC 管理端:粘贴识别收货地址区块
* 粘贴并识别合一;覆盖已填字段前弹确认(自定义 Modal,非浏览器原生 confirm)
*/
import { computed, onBeforeUnmount, ref } from 'vue';
import { Button, Input, Modal } from 'ant-design-vue';
import { getAddressSurnames } from '#/util/address-dict-api';
import {
ADDRESS_PASTE_EXAMPLES,
confidenceMark,
ensureSurnamesLoaded,
recognizeAddress,
} from '#/util/address-recognizer.js';
export type AddressRecognizeResult = {
area: string;
city: string;
confidence: {
detail: number;
name: number;
phone: number;
region: number;
};
detail: string;
name: string;
phone: string;
province: string;
};
export type AddressFilledFlags = {
detail?: boolean;
name?: boolean;
phone?: boolean;
region?: boolean;
};
export type AddressCurrentValues = {
detail?: string;
name?: string;
phone?: string;
region?: string;
};
export type OverwriteDiffRow = {
label: string;
newVal: string;
oldVal: string;
};
const props = withDefaults(
defineProps<{
/** 当前表单文案,覆盖确认展示「旧 → 新」 */
currentValues?: AddressCurrentValues;
/** 表单已填标记,用于判断是否需要覆盖确认 */
filled?: AddressFilledFlags;
}>(),
{
filled: () => ({
name: false,
phone: false,
region: false,
detail: false,
}),
currentValues: () => ({
name: '',
phone: '',
region: '',
detail: '',
}),
},
);
const emit = defineEmits<{
recognized: [AddressRecognizeResult];
}>();
const pasteText = ref('');
const recognizing = ref(false);
const hasPreview = ref(false);
const examplesVisible = ref(false);
const overwriteVisible = ref(false);
const pendingResult = ref<AddressRecognizeResult | null>(null);
const overwriteRows = ref<OverwriteDiffRow[]>([]);
const preview = ref<AddressRecognizeResult>({
name: '',
phone: '',
province: '',
city: '',
area: '',
detail: '',
confidence: { name: 0, phone: 0, region: 0, detail: 0 },
});
let debounceTimer: null | ReturnType<typeof setTimeout> = null;
const regionText = computed(() =>
[preview.value.province, preview.value.city, preview.value.area]
.filter(Boolean)
.join(''),
);
const examples = ADDRESS_PASTE_EXAMPLES;
function displayOrEmpty(v?: string) {
const t = String(v || '').trim();
return t || '空';
}
function markOf(score: number) {
return confidenceMark(score);
}
function markClass(score: number) {
const s = Number(score) || 0;
if (s >= 0.8) return 'mark-ok';
if (s >= 0.5) return 'mark-mid';
return 'mark-bad';
}
/** 粘贴并识别:有输入则识别,否则读剪贴板再识别 */
async function onPasteAndRecognize() {
const raw = String(pasteText.value || '').trim();
if (raw) {
runRecognize(raw);
return;
}
try {
const text = await navigator.clipboard.readText();
if (!text?.trim()) return;
pasteText.value = text;
runRecognize(text);
} catch {
// 浏览器可能拦截剪贴板
}
}
function onClickExample(ex: string) {
pasteText.value = ex;
examplesVisible.value = false;
runRecognize(ex);
}
/** 识别结果会覆盖哪些已填字段(含旧→新) */
function collectOverwriteRows(
result: AddressRecognizeResult,
): OverwriteDiffRow[] {
const filled = props.filled || {};
const cur = props.currentValues || {};
const rows: OverwriteDiffRow[] = [];
if (result.name && filled.name) {
rows.push({
label: '姓名',
oldVal: displayOrEmpty(cur.name),
newVal: displayOrEmpty(result.name),
});
}
if (result.phone && filled.phone) {
rows.push({
label: '电话',
oldVal: displayOrEmpty(cur.phone),
newVal: displayOrEmpty(result.phone),
});
}
if ((result.province || result.city || result.area) && filled.region) {
const newRegion = [result.province, result.city, result.area]
.filter(Boolean)
.join('');
rows.push({
label: '地区',
oldVal: displayOrEmpty(cur.region),
newVal: displayOrEmpty(newRegion),
});
}
if (result.detail && filled.detail) {
rows.push({
label: '详细地址',
oldVal: displayOrEmpty(cur.detail),
newVal: displayOrEmpty(result.detail),
});
}
return rows;
}
function emitOrConfirm(result: AddressRecognizeResult) {
const rows = collectOverwriteRows(result);
if (rows.length > 0) {
pendingResult.value = result;
overwriteRows.value = rows;
overwriteVisible.value = true;
return;
}
emit('recognized', result);
}
function confirmOverwrite() {
const result = pendingResult.value;
overwriteVisible.value = false;
pendingResult.value = null;
overwriteRows.value = [];
if (result) emit('recognized', result);
}
function cancelOverwrite() {
overwriteVisible.value = false;
pendingResult.value = null;
overwriteRows.value = [];
}
/**
* 识别前先拉姓氏表(会话内只请求一次),失败则用本地兜底
*/
async function runRecognize(text: string) {
const raw = String(text || '').trim();
if (!raw) {
hasPreview.value = false;
return;
}
recognizing.value = true;
try {
await ensureSurnamesLoaded(async () => {
const data = await getAddressSurnames();
return data;
});
const result = recognizeAddress(raw) as AddressRecognizeResult;
preview.value = result;
hasPreview.value = true;
emitOrConfirm(result);
} finally {
recognizing.value = false;
}
}
onBeforeUnmount(() => {
if (debounceTimer) clearTimeout(debounceTimer);
});
</script>
<template>
<div class="address-paste-block">
<div class="paste-head">
<div class="paste-title">智能识别地址</div>
<Button
type="link"
size="small"
class="format-link"
@click="examplesVisible = true"
>
查看格式
</Button>
</div>
<Input.TextArea
v-model:value="pasteText"
:rows="3"
:maxlength="500"
placeholder="粘贴或输入地址文本"
/>
<div class="paste-actions">
<Button type="primary" size="small" @click="onPasteAndRecognize">
粘贴并识别
</Button>
</div>
<div v-if="recognizing" class="loading-bar">
<div class="loading-inner"></div>
</div>
<div v-if="hasPreview" class="preview-card">
<div class="preview-row">
<span class="preview-label">姓名</span>
<span class="preview-val">{{ preview.name || '—' }}</span>
<span class="preview-mark" :class="markClass(preview.confidence.name)">
{{ markOf(preview.confidence.name) }}
</span>
</div>
<div class="preview-row">
<span class="preview-label">电话</span>
<span class="preview-val">{{ preview.phone || '—' }}</span>
<span class="preview-mark" :class="markClass(preview.confidence.phone)">
{{ markOf(preview.confidence.phone) }}
</span>
</div>
<div class="preview-row">
<span class="preview-label">地区</span>
<span class="preview-val">{{ regionText || '—' }}</span>
<span
class="preview-mark"
:class="markClass(preview.confidence.region)"
>
{{ markOf(preview.confidence.region) }}
</span>
</div>
<div class="preview-row">
<span class="preview-label">详细</span>
<span class="preview-val">{{ preview.detail || '—' }}</span>
<span
class="preview-mark"
:class="markClass(preview.confidence.detail)"
>
{{ markOf(preview.confidence.detail) }}
</span>
</div>
</div>
<Modal
v-model:open="examplesVisible"
title="示例格式"
:footer="null"
:width="520"
destroy-on-close
>
<div class="examples-tip">点击一条即可填入并识别</div>
<button
v-for="(ex, idx) in examples"
:key="idx"
type="button"
class="example-item"
@click="onClickExample(ex)"
>
{{ ex }}
</button>
</Modal>
<Modal
v-model:open="overwriteVisible"
title="确认覆盖?"
:width="420"
ok-text="确认覆盖"
cancel-text="取消"
destroy-on-close
@ok="confirmOverwrite"
@cancel="cancelOverwrite"
>
<p class="overwrite-tip">以下字段已有内容,识别结果将覆盖:</p>
<div class="overwrite-list">
<div
v-for="(row, idx) in overwriteRows"
:key="idx"
class="overwrite-row"
>
<div class="overwrite-label">{{ row.label }}</div>
<div class="overwrite-diff">{{ row.oldVal }} → {{ row.newVal }}</div>
</div>
</div>
</Modal>
</div>
</template>
<style scoped>
.address-paste-block {
padding: 12px 14px;
margin-bottom: 12px;
background: hsl(var(--primary) / 8%);
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 8px;
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.paste-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.paste-title {
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.format-link {
height: auto;
padding: 0;
}
.paste-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
}
.loading-bar {
height: 3px;
margin-top: 8px;
overflow: hidden;
background: hsl(var(--primary) / 15%);
border-radius: 2px;
}
.loading-inner {
width: 40%;
height: 100%;
background: hsl(var(--primary));
animation: paste-loading 1s ease-in-out infinite;
}
@keyframes paste-loading {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(350%);
}
}
.preview-card {
padding: 8px 10px;
margin-top: 8px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 6px;
}
.preview-row {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 4px 0;
font-size: 12px;
}
.preview-label {
flex-shrink: 0;
width: 36px;
color: hsl(var(--muted-foreground));
}
.preview-val {
flex: 1;
color: hsl(var(--foreground));
word-break: break-all;
}
.preview-mark {
flex-shrink: 0;
width: 20px;
font-weight: 600;
text-align: center;
}
.mark-ok {
color: #00b42a;
}
.mark-mid {
color: #ff7d00;
}
.mark-bad {
color: #f53f3f;
}
.examples-tip {
margin-bottom: 8px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.example-item {
display: block;
width: 100%;
padding: 8px 10px;
margin-top: 8px;
font-size: 12px;
line-height: 1.45;
color: hsl(var(--foreground));
text-align: left;
cursor: pointer;
background: hsl(var(--muted) / 25%);
border: 1px solid hsl(var(--primary) / 25%);
border-radius: 4px;
}
.example-item:hover {
border-color: hsl(var(--primary) / 50%);
}
.overwrite-tip {
margin: 0;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.overwrite-list {
padding: 8px 12px;
margin-top: 10px;
background: hsl(var(--muted) / 25%);
border: 1px solid hsl(var(--border));
border-radius: 6px;
}
.overwrite-row {
padding: 8px 0;
border-bottom: 1px solid hsl(var(--border));
}
.overwrite-row:last-child {
border-bottom: none;
}
.overwrite-label {
margin-bottom: 2px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.overwrite-diff {
font-size: 13px;
line-height: 1.45;
color: hsl(var(--foreground));
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,347 @@
<script setup lang="ts">
/**
* Api 日志「api接口」选择器:接诊式标题/首拼/全拼搜索,选中后回填 url。
* 端由页面 Tab 的 platformType 锁定,避免跨端搜到无关路由。
*/
import { computed, nextTick, ref, watch } from 'vue';
import { useDebounceFn, useVModel } from '@vueuse/core';
import { Empty, Input, Popover, Spin, Tag } from 'ant-design-vue';
import { searchApiRouteMap } from '#/views/log/api-route-map/api';
defineOptions({
name: 'ApiLogRoutePicker',
inheritAttrs: false,
});
const props = withDefaults(
defineProps<{
disabled?: boolean;
platformType?: number;
/** 选中后存完整 url,与日志列表 router 参数对齐 */
value?: ApiLogRouteFilter | string;
}>(),
{
platformType: 0,
},
);
const emits = defineEmits<{
'update:value': [value: string | undefined];
}>();
export type ApiLogRouteFilter = {
method: number;
method_txt?: string;
pinyin_display?: string;
title: string;
url: string;
};
const mValue = useVModel(props, 'value', emits, { passive: true });
const open = ref(false);
const loading = ref(false);
const searchKeyword = ref('');
const options = ref<any[]>([]);
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
/** 展示用:兼容旧字符串 url 与对象 */
const selectedLabel = computed(() => {
const v = mValue.value;
if (!v) return '';
if (typeof v === 'string') return v;
return v.title ? `${v.title}(${v.url})` : v.url;
});
const selectedUrl = computed(() => {
const v = mValue.value;
if (!v) return '';
return typeof v === 'string' ? v : v.url;
});
async function fetchOptions() {
const keyword = searchKeyword.value.trim();
if (!keyword) {
options.value = [];
return;
}
loading.value = true;
try {
const result = await searchApiRouteMap({
keyword,
platform_type: props.platformType,
pageSize: 24,
});
options.value = result?.items ?? [];
} finally {
loading.value = false;
}
}
const debouncedFetch = useDebounceFn(fetchOptions, 300);
watch(searchKeyword, () => {
debouncedFetch();
});
watch(
() => props.platformType,
() => {
mValue.value = undefined;
options.value = [];
searchKeyword.value = '';
},
);
function selectOption(item: any) {
// 列表查询只吃 url 字符串
mValue.value = String(item.url || '');
open.value = false;
searchKeyword.value = '';
options.value = [];
}
function clearSelection(e: Event) {
e.stopPropagation();
mValue.value = undefined;
}
function onOpenChange(next: boolean) {
if (props.disabled) return;
open.value = next;
if (next) {
searchKeyword.value = '';
options.value = [];
nextTick(() => searchInputRef.value?.focus?.());
}
}
</script>
<template>
<Popover
:open="open"
trigger="click"
placement="bottomLeft"
overlay-class-name="api-log-route-picker-popover"
@open-change="onOpenChange"
>
<template #content>
<div class="api-log-route-panel">
<Input
ref="searchInputRef"
v-model:value="searchKeyword"
allow-clear
placeholder="标题 / 首拼 / 全拼 / 路由"
class="api-log-route-search"
/>
<Spin :spinning="loading">
<div v-if="options.length" class="api-log-route-list">
<button
v-for="item in options"
:key="`${item.url}-${item.method}`"
type="button"
class="api-log-route-card"
:class="{ active: selectedUrl === item.url }"
@click="selectOption(item)"
>
<div class="api-log-route-card-main">
<div class="api-log-route-card-title">
{{ item.title || '未命名接口' }}
</div>
<div class="api-log-route-card-url" :title="item.url">
{{ item.url }}
</div>
<div v-if="item.pinyin_display" class="api-log-route-card-py">
{{ item.pinyin_display }}
</div>
</div>
<Tag
:color="
Number(item.method) === 1
? 'blue'
: Number(item.method) === 2
? 'green'
: 'default'
"
>
{{ item.method_txt || '—' }}
</Tag>
</button>
</div>
<Empty
v-else
:description="
searchKeyword.trim() ? '无匹配接口' : '输入标题或拼音搜索'
"
class="api-log-route-empty"
/>
</Spin>
</div>
</template>
<div class="api-log-route-trigger" :class="{ disabled }">
<div v-if="selectedUrl" class="api-log-route-selected">
<div class="api-log-route-selected-text" :title="selectedLabel">
{{ selectedLabel }}
</div>
<span
v-if="!disabled"
class="api-log-route-clear"
@click="clearSelection"
>
×
</span>
</div>
<div v-else class="api-log-route-placeholder">请选择 api 接口</div>
</div>
</Popover>
</template>
<style scoped lang="scss">
.api-log-route-trigger {
width: 100%;
}
.api-log-route-trigger.disabled {
cursor: not-allowed;
opacity: 0.65;
}
.api-log-route-trigger:not(.disabled) {
cursor: pointer;
}
.api-log-route-selected {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
min-height: 40px;
padding: 6px 10px;
background: hsl(var(--muted) / 25%);
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
.api-log-route-selected-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: hsl(var(--foreground));
white-space: nowrap;
}
.api-log-route-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 40px;
padding: 8px 12px;
font-size: 14px;
color: hsl(var(--muted-foreground));
border: 1px dashed hsl(var(--border));
border-radius: 8px;
&:hover {
color: hsl(var(--primary));
border-color: hsl(var(--primary) / 55%);
}
}
.api-log-route-clear {
flex-shrink: 0;
padding: 0 4px;
font-size: 16px;
color: hsl(var(--muted-foreground));
cursor: pointer;
&:hover {
color: hsl(var(--foreground));
}
}
</style>
<style lang="scss">
.api-log-route-picker-popover {
.ant-popover-inner {
padding: 12px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
}
.api-log-route-panel {
width: 420px;
max-width: 86vw;
}
.api-log-route-search {
margin-bottom: 10px;
}
.api-log-route-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 320px;
overflow-y: auto;
}
.api-log-route-card {
display: flex;
gap: 10px;
align-items: center;
width: 100%;
padding: 8px 10px;
text-align: left;
cursor: pointer;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 8px;
&:hover,
&.active {
background: hsl(var(--primary) / 8%);
border-color: hsl(var(--primary) / 35%);
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
}
.api-log-route-card-main {
flex: 1;
min-width: 0;
}
.api-log-route-card-title {
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
font-weight: 500;
color: hsl(var(--foreground));
white-space: nowrap;
}
.api-log-route-card-url {
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
color: hsl(var(--muted-foreground));
white-space: nowrap;
}
.api-log-route-card-py {
margin-top: 2px;
font-size: 11px;
color: hsl(var(--muted-foreground));
}
.api-log-route-empty {
margin: 16px 0;
color: hsl(var(--muted-foreground));
}
}
</style>

View File

@@ -1,8 +1,13 @@
<script setup lang="ts">
/**
* Api 访问日志操作用户选择器
* 端由页面 Tab 锁定(userType);搜索拆成「按 ID」与「按姓名/手机号」两套,互不混搜。
* 候选卡用横向小长方形 + 角色小标签;颜色走主题变量以适配亮/暗色。
*/
import { computed, nextTick, ref, watch } from 'vue';
import { useDebounceFn, useVModel } from '@vueuse/core';
import { Avatar, Empty, Input, Popover, Spin, Tabs, Tag } from 'ant-design-vue';
import { Avatar, Empty, Input, Popover, Segmented, Spin } from 'ant-design-vue';
import { searchApiLogUsers } from '#/views/log/api-log/api';
@@ -11,48 +16,68 @@ defineOptions({
inheritAttrs: false,
});
export type ApiLogUserFilter = {
id: number;
user_type: 'admin' | 'patient' | 'doctor';
platform_type: 0 | 1 | 2;
name: string;
role_label: string;
avatar?: string;
};
type ApiLogUserOption = ApiLogUserFilter;
type UserTab = 'admin' | 'patient' | 'doctor';
const props = defineProps<{
value?: ApiLogUserFilter;
disabled?: boolean;
onPlatformTypeChange?: (platformType: 0 | 1 | 2) => void;
}>();
const props = withDefaults(
defineProps<{
disabled?: boolean;
platformType?: 0 | 1 | 2;
/** 由页面端 Tab 锁定,决定搜哪类用户 */
userType?: UserType;
value?: ApiLogUserFilter;
}>(),
{
userType: 'admin',
platformType: 0,
},
);
const emits = defineEmits<{
'update:value': [value: ApiLogUserFilter | undefined];
}>();
export type ApiLogUserFilter = {
avatar?: string;
id: number;
name: string;
platform_type: 0 | 1 | 2;
role_label: string;
user_type: 'admin' | 'doctor' | 'patient';
};
type ApiLogUserOption = ApiLogUserFilter;
type UserType = 'admin' | 'doctor' | 'patient';
type SearchMode = 'id' | 'text';
const mValue = useVModel(props, 'value', emits, { passive: true });
const open = ref(false);
const loading = ref(false);
/** id=精确用户 ID;text=姓名/手机号模糊 */
const searchMode = ref<SearchMode>('text');
const searchKeyword = ref('');
const activeTab = ref<UserTab>('admin');
const options = ref<ApiLogUserOption[]>([]);
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
const TAB_ITEMS = [
{ key: 'admin', label: '后台用户' },
{ key: 'patient', label: '患者端用户' },
{ key: 'doctor', label: '医生端用户' },
] as const;
/** 第二档文案随端变化:患者无手机号字段,只提示用户名 */
const searchModeOptions = computed(() => [
{ label: '按 ID', value: 'id' },
{
label: props.userType === 'patient' ? '按用户名' : '姓名/手机号',
value: 'text',
},
]);
const searchPlaceholder = computed(() => {
if (activeTab.value === 'admin') return '搜索手机号或昵称';
if (activeTab.value === 'patient') return '搜索用户名字';
return '搜索名字或手机号';
if (searchMode.value === 'id') return '输入用户 ID(精确)';
if (props.userType === 'patient') return '搜索用户名';
if (props.userType === 'doctor') return '搜索名字 / 手机号';
return '搜索昵称 / 手机号';
});
const emptyHint = computed(() => {
if (!searchKeyword.value.trim()) {
return searchMode.value === 'id' ? '请输入用户 ID' : '请输入姓名或手机号';
}
return '无匹配用户';
});
async function fetchOptions() {
@@ -61,12 +86,17 @@ async function fetchOptions() {
options.value = [];
return;
}
// ID 模式只接受正整数,避免无意义请求
if (searchMode.value === 'id' && !/^\d+$/.test(keyword)) {
options.value = [];
return;
}
loading.value = true;
try {
const result = await searchApiLogUsers({
user_type: activeTab.value,
user_type: props.userType,
keyword,
search_mode: searchMode.value,
pageSize: 24,
});
options.value = result?.items ?? [];
@@ -77,10 +107,28 @@ async function fetchOptions() {
const debouncedFetch = useDebounceFn(fetchOptions, 300);
watch([searchKeyword, activeTab], () => {
watch(searchKeyword, () => {
debouncedFetch();
});
watch(searchMode, () => {
// 切换模式清空关键词与结果,避免带着上一模式的输入去混搜
searchKeyword.value = '';
options.value = [];
nextTick(() => searchInputRef.value?.focus?.());
});
watch(
() => props.userType,
() => {
// 切端后清空已选与候选,避免串端
mValue.value = undefined;
options.value = [];
searchKeyword.value = '';
searchMode.value = 'text';
},
);
function selectOption(item: ApiLogUserOption) {
mValue.value = {
id: item.id,
@@ -90,7 +138,6 @@ function selectOption(item: ApiLogUserOption) {
role_label: item.role_label,
avatar: item.avatar,
};
props.onPlatformTypeChange?.(item.platform_type);
open.value = false;
searchKeyword.value = '';
options.value = [];
@@ -129,22 +176,22 @@ watch(
>
<template #content>
<div class="api-log-user-panel">
<Tabs v-model:active-key="activeTab" size="small" class="api-log-user-tabs">
<Tabs.TabPane
v-for="tab in TAB_ITEMS"
:key="tab.key"
:tab="tab.label"
/>
</Tabs>
<Segmented
v-model:value="searchMode"
:options="searchModeOptions"
block
class="api-log-user-mode"
/>
<Input
ref="searchInputRef"
v-model:value="searchKeyword"
allow-clear
:placeholder="searchPlaceholder"
:inputmode="searchMode === 'id' ? 'numeric' : 'text'"
class="api-log-user-search"
/>
<Spin :spinning="loading">
<div v-if="options.length" class="api-log-user-grid">
<div v-if="options.length" class="api-log-user-list">
<button
v-for="item in options"
:key="`${item.user_type}-${item.id}`"
@@ -152,39 +199,45 @@ watch(
class="api-log-user-card"
:class="{
active:
mValue?.id === item.id && mValue?.user_type === item.user_type,
mValue?.id === item.id &&
mValue?.user_type === item.user_type,
}"
@click="selectOption(item)"
>
<Avatar :src="item.avatar" :size="30">
<Avatar :src="item.avatar" :size="32">
{{ (item.name || '?').charAt(0) }}
</Avatar>
<div class="api-log-user-card-id">#{{ item.id }}</div>
<div class="api-log-user-card-name" :title="item.name">
{{ item.name }}
<div class="api-log-user-card-main">
<div class="api-log-user-card-name" :title="item.name">
{{ item.name || '—' }}
</div>
<div class="api-log-user-card-id">#{{ item.id }}</div>
</div>
<Tag class="api-log-user-card-role" :bordered="false">
{{ item.role_label }}
</Tag>
<span
class="api-log-role-chip"
:class="`is-${item.user_type}`"
:title="item.role_label || '未设角色'"
>
{{ item.role_label || '未设角色' }}
</span>
</button>
</div>
<Empty
v-else
:description="searchKeyword.trim() ? '无匹配用户' : '请输入关键词搜索'"
class="api-log-user-empty"
/>
<Empty v-else :description="emptyHint" class="api-log-user-empty" />
</Spin>
</div>
</template>
<div class="api-log-user-trigger" :class="{ disabled: disabled }">
<div class="api-log-user-trigger" :class="{ disabled }">
<div v-if="mValue" class="api-log-selected-card">
<Avatar :src="mValue.avatar" :size="36">
<Avatar :src="mValue.avatar" :size="32">
{{ (mValue.name || '?').charAt(0) }}
</Avatar>
<div class="api-log-selected-meta">
<div class="api-log-selected-name">{{ mValue.name }}</div>
<div class="api-log-selected-sub">
#{{ mValue.id }} · {{ mValue.role_label }}
<span>#{{ mValue.id }}</span>
<span class="api-log-role-chip" :class="`is-${mValue.user_type}`">
{{ mValue.role_label || '未设角色' }}
</span>
</div>
</div>
<span
@@ -201,8 +254,6 @@ watch(
</template>
<style scoped lang="scss">
@use './picker-card-theme.scss' as theme;
.api-log-user-trigger {
width: 100%;
}
@@ -217,75 +268,146 @@ watch(
}
.api-log-selected-card {
@include theme.picker-selected-card;
display: flex;
gap: 10px;
align-items: center;
width: 100%;
padding: 6px 10px;
cursor: pointer;
background: hsl(var(--muted) / 25%);
border: 1px solid hsl(var(--border));
border-radius: 8px;
transition:
border-color 0.2s,
box-shadow 0.2s;
&:hover {
border-color: hsl(var(--primary) / 45%);
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
}
.api-log-selected-meta {
min-width: 0;
flex: 1;
min-width: 0;
}
.api-log-selected-name {
font-size: 14px;
font-weight: 500;
color: #1d2129;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
color: hsl(var(--foreground));
white-space: nowrap;
}
.api-log-selected-sub {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 2px;
font-size: 12px;
color: #86909c;
line-height: 1.4;
color: hsl(var(--muted-foreground));
}
.api-log-trigger-placeholder {
@include theme.picker-trigger-placeholder;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 40px;
padding: 8px 12px;
font-size: 14px;
color: hsl(var(--muted-foreground));
cursor: pointer;
border: 1px dashed hsl(var(--border));
border-radius: 8px;
transition:
border-color 0.2s,
color 0.2s;
&:hover {
color: hsl(var(--primary));
border-color: hsl(var(--primary) / 55%);
}
}
.api-log-clear-btn {
@include theme.picker-clear-btn;
flex-shrink: 0;
padding: 0 4px;
font-size: 16px;
line-height: 1;
color: hsl(var(--muted-foreground));
pointer-events: auto;
cursor: pointer;
&:hover {
color: hsl(var(--foreground));
}
}
.dark {
.api-log-selected-card {
@include theme.picker-selected-card-dark-props;
/* 角色小长方形标签:选中区与下拉候选共用 */
.api-log-role-chip {
display: inline-flex;
flex-shrink: 0;
align-items: center;
max-width: 7.5em;
height: 20px;
padding: 0 6px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
line-height: 20px;
color: hsl(var(--primary));
white-space: nowrap;
background: hsl(var(--primary) / 12%);
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 3px;
box-shadow: 0 0 4px hsl(var(--primary) / 12%);
&.is-admin {
color: hsl(var(--primary));
background: hsl(var(--primary) / 12%);
border-color: hsl(var(--primary) / 30%);
}
.api-log-selected-name {
@include theme.picker-text-primary-dark;
&.is-doctor {
color: hsl(142deg 45% 38%);
background: hsl(142deg 45% 38% / 12%);
border-color: hsl(142deg 45% 38% / 30%);
}
.api-log-selected-sub {
@include theme.picker-text-secondary-dark;
}
.api-log-trigger-placeholder {
@include theme.picker-placeholder-dark-props;
}
.api-log-clear-btn {
@include theme.picker-clear-dark-props;
&.is-patient {
color: hsl(var(--warning));
background: hsl(var(--warning) / 12%);
border-color: hsl(var(--warning) / 30%);
}
}
</style>
<style lang="scss">
@use './picker-card-theme.scss' as theme;
/* Popover 挂到 body,需非 scoped;一律用主题变量,亮/暗自动跟 */
.api-log-user-picker-popover {
.ant-popover-inner {
padding: 12px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
}
.ant-popover-inner-content {
color: hsl(var(--foreground));
}
.api-log-user-panel {
width: 540px;
width: 420px;
max-width: 86vw;
}
.api-log-user-tabs {
.api-log-user-mode {
width: 100%;
margin-bottom: 8px;
}
@@ -293,54 +415,103 @@ watch(
margin-bottom: 10px;
}
.api-log-user-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
gap: 8px;
max-height: 300px;
overflow-y: auto;
/* 纵向列表:每项横向小长方形,信息可读 */
.api-log-user-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 320px;
padding: 2px;
overflow-y: auto;
}
.api-log-user-card {
@include theme.picker-card-base;
display: flex;
gap: 10px;
align-items: center;
width: 100%;
min-height: 48px;
padding: 8px 10px;
text-align: left;
cursor: pointer;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 8px;
transition:
border-color 0.2s,
box-shadow 0.2s,
background-color 0.2s;
&:hover,
&.active {
background: hsl(var(--primary) / 8%);
border-color: hsl(var(--primary) / 35%);
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
}
.api-log-user-card-id {
@include theme.picker-card-sub;
.api-log-user-card-main {
flex: 1;
min-width: 0;
}
.api-log-user-card-name {
@include theme.picker-card-name;
}
.api-log-user-card-role {
margin: 0;
font-size: 11px;
line-height: 18px;
padding: 0 6px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
font-weight: 500;
line-height: 1.3;
color: hsl(var(--foreground));
white-space: nowrap;
}
.api-log-user-card-id {
margin-top: 2px;
font-size: 11px;
line-height: 1.2;
color: hsl(var(--muted-foreground));
}
.api-log-role-chip {
display: inline-flex;
flex-shrink: 0;
align-items: center;
max-width: 7.5em;
height: 20px;
padding: 0 6px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
line-height: 20px;
color: hsl(var(--primary));
white-space: nowrap;
background: hsl(var(--primary) / 12%);
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 3px;
box-shadow: 0 0 4px hsl(var(--primary) / 12%);
&.is-admin {
color: hsl(var(--primary));
background: hsl(var(--primary) / 12%);
border-color: hsl(var(--primary) / 30%);
}
&.is-doctor {
color: hsl(142deg 45% 38%);
background: hsl(142deg 45% 38% / 12%);
border-color: hsl(142deg 45% 38% / 30%);
}
&.is-patient {
color: hsl(var(--warning));
background: hsl(var(--warning) / 12%);
border-color: hsl(var(--warning) / 30%);
}
}
.api-log-user-empty {
margin: 16px 0;
}
}
.dark .api-log-user-picker-popover {
.api-log-user-card {
@include theme.picker-card-dark-props;
}
.api-log-user-card-name {
@include theme.picker-text-primary-dark;
}
.api-log-user-card-id {
@include theme.picker-text-secondary-dark;
color: hsl(var(--muted-foreground));
}
}
</style>

View File

@@ -0,0 +1,299 @@
<script setup lang="ts">
/**
* 通用液态玻璃卡片(Apple Liquid Glass 风格)
*
* 视觉:半透明卡片底 + 背景模糊饱和 + 顶部内高光 + 左上角高光反射 + 1px 同色系边框与微光
* (遵守规范:禁止左侧竖色条,统一「1px 同色系边框 + 微光」)。颜色全部走主题变量,自动适配暗色。
*
* 为什么封装:多个页面(消息、发票、公户、业务员看板、素材库、VIP 等)都要同一种卡片质感,
* 统一在这里维护,页面只关心内容;新增/改动的卡片一律用本组件,不要再手写一套玻璃样式。
*
* 用法:
* <GlassCard title="标题" subtitle="说明" tone="success" hoverable>
* <template #extra><Button>操作</Button></template>
* 内容
* </GlassCard>
*/
import { computed, useSlots } from 'vue';
import { IconifyIcon } from '@vben/icons';
defineOptions({ name: 'GlassCard' });
const props = withDefaults(
defineProps<{
/** 选中态(如 VIP 等级选择),边框与微光加强 */
active?: boolean;
/** 内容区额外 class */
bodyClass?: string;
/** 可点击:鼠标手型 + 键盘回车触发 click */
clickable?: boolean;
/** 悬停上浮 + 加强微光,适合列表卡片 */
hoverable?: boolean;
/** 标题左侧图标(iconify 名称,如 lucide:wallet) */
icon?: string;
/** 内边距档位 */
padding?: 'lg' | 'md' | 'none' | 'sm';
/** 是否带左上高光反射层(密集小卡片可关掉减少视觉噪音) */
specular?: boolean;
subtitle?: string;
title?: string;
/** 主题色变量名(不带 --):primary / success / warning / destructive 等,决定边框、微光、图标颜色 */
tone?: string;
}>(),
{
title: '',
subtitle: '',
icon: '',
tone: 'primary',
hoverable: false,
clickable: false,
active: false,
padding: 'md',
specular: true,
bodyClass: '',
},
);
const emit = defineEmits<{ click: [event: KeyboardEvent | MouseEvent] }>();
const slots = useSlots();
/** 有标题、副标题、图标或对应插槽时才渲染头部,避免空头部占高度 */
const hasHeader = computed(
() =>
!!(
props.title ||
props.subtitle ||
props.icon ||
slots.title ||
slots.extra
),
);
function onClick(event: MouseEvent) {
if (props.clickable) emit('click', event);
}
function onKeydown(event: KeyboardEvent) {
if (props.clickable && event.key === 'Enter') emit('click', event);
}
</script>
<template>
<div
class="glass-card"
:class="[
`glass-card--pad-${padding}`,
{
'glass-card--hoverable': hoverable || clickable,
'glass-card--clickable': clickable,
'glass-card--active': active,
},
]"
:style="{ '--glass-tone': `var(--${tone})` }"
:role="clickable ? 'button' : undefined"
:tabindex="clickable ? 0 : undefined"
@click="onClick"
@keydown="onKeydown"
>
<div v-if="specular" class="glass-card__specular" aria-hidden="true"></div>
<div v-if="hasHeader" class="glass-card__head">
<div class="glass-card__head-main">
<span v-if="icon" class="glass-card__icon">
<IconifyIcon :icon="icon" />
</span>
<div class="glass-card__titles">
<div class="glass-card__title">
<slot name="title">{{ title }}</slot>
</div>
<div v-if="subtitle || $slots.subtitle" class="glass-card__subtitle">
<slot name="subtitle">{{ subtitle }}</slot>
</div>
</div>
</div>
<div v-if="$slots.extra" class="glass-card__extra" @click.stop>
<slot name="extra"></slot>
</div>
</div>
<div class="glass-card__body" :class="bodyClass">
<slot></slot>
</div>
<div v-if="$slots.footer" class="glass-card__footer" @click.stop>
<slot name="footer"></slot>
</div>
</div>
</template>
<style scoped>
/*
* --glass-specular:高光色 HSL 三元组;暗色下降低亮度,让高光更含蓄
* 只作用于本组件根节点,不改全局主题变量
*/
.glass-card {
--glass-specular: 0 0% 100%;
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
color: hsl(var(--foreground));
background: linear-gradient(
150deg,
hsl(var(--card) / 72%),
hsl(var(--card) / 46%)
);
border: 1px solid hsl(var(--glass-tone) / 18%);
border-radius: 18px;
box-shadow:
inset 0 1px 0 hsl(var(--glass-specular) / 55%),
inset 0 -1px 0 hsl(var(--glass-specular) / 8%),
0 0 6px hsl(var(--glass-tone) / 10%),
0 10px 30px hsl(var(--foreground) / 5%);
backdrop-filter: blur(24px) saturate(180%);
transition:
transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow 0.35s ease,
border-color 0.35s ease;
}
.dark .glass-card {
--glass-specular: 0 0% 62%;
}
.glass-card--pad-none {
padding: 0;
}
.glass-card--pad-sm {
padding: 12px 14px;
}
.glass-card--pad-md {
padding: 16px 18px;
}
.glass-card--pad-lg {
padding: 20px 24px;
}
.glass-card--hoverable:hover,
.glass-card--active {
border-color: hsl(var(--glass-tone) / 40%);
box-shadow:
inset 0 1px 0 hsl(var(--glass-specular) / 70%),
0 0 10px hsl(var(--glass-tone) / 18%),
0 16px 38px hsl(var(--glass-tone) / 12%);
}
.glass-card--hoverable:hover {
transform: translateY(-2px);
}
.glass-card--clickable {
cursor: pointer;
}
.glass-card--clickable:focus-visible {
outline: 2px solid hsl(var(--glass-tone) / 60%);
outline-offset: 2px;
}
/* 液态玻璃高光:左上斜向反射 + 右上主题色折射 */
.glass-card__specular {
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(
120% 60% at 0% 0%,
hsl(var(--glass-specular) / 22%) 0%,
transparent 55%
),
radial-gradient(
60% 80% at 100% 0%,
hsl(var(--glass-tone) / 10%) 0%,
transparent 70%
);
}
.glass-card__head,
.glass-card__body,
.glass-card__footer {
position: relative;
}
.glass-card__head {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
}
.glass-card--pad-none .glass-card__head {
padding: 16px 18px 0;
}
.glass-card__head-main {
display: flex;
flex: 1;
gap: 10px;
align-items: center;
min-width: 0;
}
.glass-card__icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
font-size: 17px;
color: hsl(var(--glass-tone));
background: hsl(var(--glass-tone) / 12%);
border: 1px solid hsl(var(--glass-tone) / 20%);
border-radius: 10px;
box-shadow: inset 0 1px 0 hsl(var(--glass-specular) / 40%);
}
.glass-card__titles {
min-width: 0;
}
.glass-card__title {
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
}
.glass-card__subtitle {
margin-top: 2px;
font-size: 12px;
line-height: 1.5;
color: hsl(var(--muted-foreground));
}
.glass-card__extra {
display: flex;
flex-shrink: 0;
gap: 8px;
align-items: center;
}
.glass-card__body {
flex: 1;
min-width: 0;
}
.glass-card__footer {
width: 100%;
padding-top: 12px;
margin-top: 12px;
border-top: 1px solid hsl(var(--border) / 60%);
}
</style>

View File

@@ -0,0 +1,188 @@
<script setup lang="ts" generic="T extends Record<string, any>">
/**
* 通用「卡片视图」列表容器(配合 ViewModeSwitch 的 card 模式使用)
*
* 职责:按页拉数 + 分页 + 加载态 + 空态 + 自适应网格;每张卡片长什么样由页面通过 #card 插槽决定,
* 卡片外壳建议直接用 GlassCard。
*
* 为什么分页而不是无限滚动:内层滚动 + 触底追加会和页面滚动抢滚轮(商品订单卡片踩过的坑),
* 分页由页面统一滚动更稳定。
*
* 用法:
* <GlassCardList ref="cardListRef" :fetcher="getXxxList" :form-values="searchValues">
* <template #card="{ row }"><GlassCard hoverable>...</GlassCard></template>
* </GlassCardList>
* 刷新:cardListRef.value?.reload()(回第 1 页) / refreshKeepPage()(留在当前页)
*/
import { onMounted, ref, watch } from 'vue';
import { Empty, Pagination, Spin } from 'ant-design-vue';
defineOptions({ name: 'GlassCardList' });
const props = withDefaults(
defineProps<{
emptyText?: string;
/** 额外固定参数(如 Tab 状态),与 formValues 合并 */
extraParams?: Record<string, any>;
/** 列表接口,入参会自动带 page / pageSize + formValues,返回 { items, total } */
fetcher: (params: Record<string, any>) => Promise<any>;
/** 顶部搜索条件,变化后自动回第 1 页重拉 */
formValues?: Record<string, any>;
/** 卡片最小宽度,网格按此自适应列数 */
minCardWidth?: number;
pageSize?: number;
/** 接口返回不是 { items, total } 时自定义解析 */
parseResponse?: (res: any) => { items: T[]; total: number };
rowKey?: string;
}>(),
{
formValues: () => ({}),
extraParams: () => ({}),
pageSize: 12,
minCardWidth: 320,
rowKey: 'id',
emptyText: '暂无数据',
parseResponse: undefined,
},
);
const items = ref<T[]>([]) as { value: T[] };
const page = ref(1);
const size = ref(props.pageSize);
const total = ref(0);
const loading = ref(false);
const rootEl = ref<HTMLElement | null>(null);
/** 请求序号:只应用最后一次响应,避免快速切换筛选时旧结果覆盖新列表 */
let requestSeq = 0;
/** 默认按 vxe 适配器口径解析 { items, total } */
function parse(res: any) {
if (props.parseResponse) return props.parseResponse(res);
return {
items: (res?.items ?? res?.list ?? []) as T[],
total: Number(res?.total ?? 0),
};
}
async function fetchPage(target: number) {
const seq = ++requestSeq;
loading.value = true;
try {
const res = await props.fetcher({
...props.formValues,
...props.extraParams,
page: target,
pageSize: size.value,
});
if (seq !== requestSeq) return;
const parsed = parse(res);
items.value = parsed.items;
total.value = parsed.total;
page.value = target;
} catch (error) {
if (seq === requestSeq) console.error('卡片列表加载失败:', error);
} finally {
if (seq === requestSeq) loading.value = false;
}
}
/** 回第 1 页重拉(筛选变化 / 新增后) */
async function reload() {
await fetchPage(1);
}
/** 原地刷新当前页(编辑、审核等操作后,避免跳回第 1 页) */
async function refreshKeepPage() {
// 当前页最后一条被删掉后回退一页,避免停在空白页
const lastPage = Math.max(1, Math.ceil((total.value - 1) / size.value));
await fetchPage(
items.value.length === 1 && page.value > lastPage ? lastPage : page.value,
);
}
/**
* 翻页后滚回卡片区顶部
* 为什么不用 scrollIntoView:会带动整页滚动,和外层 overflow-y-auto 叠成双滚动条抖动
*/
async function onPageChange(next: number, nextSize: number) {
if (nextSize === size.value) {
await fetchPage(next);
} else {
size.value = nextSize;
await fetchPage(1);
}
const el = rootEl.value;
if (!el) return;
const scroller = el.closest(
'.overflow-y-auto, .overflow-auto',
) as HTMLElement | null;
if (scroller) {
scroller.scrollTo({ top: 0, behavior: 'smooth' });
} else {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
defineExpose({ reload, refreshKeepPage, items });
watch(
() => [props.formValues, props.extraParams],
() => void reload(),
{ deep: true },
);
onMounted(() => void reload());
</script>
<template>
<div ref="rootEl" class="glass-card-list">
<Spin :spinning="loading">
<div
v-if="items.length > 0"
class="glass-card-list__grid"
:style="{ '--gcl-min': `${minCardWidth}px` }"
>
<template v-for="(row, index) in items" :key="row[rowKey] ?? index">
<slot name="card" :row="row" :index="index"></slot>
</template>
</div>
<div v-else class="glass-card-list__empty">
<Empty :description="loading ? '加载中…' : emptyText" />
</div>
</Spin>
<div v-if="total > 0" class="glass-card-list__pager">
<Pagination
:current="page"
:page-size="size"
:total="total"
:page-size-options="['12', '24', '48', '96']"
show-size-changer
show-quick-jumper
:show-total="(t: number) => `共 ${t} 条`"
@change="onPageChange"
/>
</div>
</div>
</template>
<style scoped>
.glass-card-list__grid {
display: grid;
grid-template-columns: repeat(
auto-fill,
minmax(min(var(--gcl-min), 100%), 1fr)
);
gap: 14px;
}
.glass-card-list__empty {
padding: 48px 0;
}
.glass-card-list__pager {
display: flex;
justify-content: flex-end;
padding-top: 16px;
}
</style>

View File

@@ -0,0 +1,7 @@
/**
* 液态玻璃卡片组件出口
* - GlassCard:卡片外壳(新增/改动的卡片统一使用)
* - GlassCardList:卡片视图列表容器(列表/卡片双视图的 card 模式)
*/
export { default as GlassCard } from './GlassCard.vue';
export { default as GlassCardList } from './GlassCardList.vue';

View File

@@ -0,0 +1,342 @@
<script setup lang="ts">
/**
* 通用液态玻璃标签(芯片)
*
* 视觉:胶囊圆角 + 半透明底 + 顶部内高光 + 同色微光;悬停时轻上浮并播放主题色粒子
* 为什么封装:详情对照药等多处需要同一种玻璃芯片;粒子默认仅 hover 开,避免密集列表每颗都常驻 RAF
*
* 用法:
* <GlassTag tone="primary">黄芪 10g → 炙黄芪</GlassTag>
* <GlassTag tone="destructive" :particles="false">关闭粒子</GlassTag>
*/
import { computed, onBeforeUnmount, ref, watch } from 'vue';
defineOptions({ name: 'GlassTag' });
const props = withDefaults(
defineProps<{
/** 是否启用悬停上浮 / 微光加强 */
interactive?: boolean;
/** 是否启用粒子(默认 true;仅 hover 时播放) */
particles?: boolean;
/** 尺寸:sm 适合密集药名芯片 */
size?: 'md' | 'sm';
/** 原生 title 提示 */
title?: string;
/** 主题色:default 走 border;其余走主题变量 */
tone?: 'default' | 'destructive' | 'primary' | 'success' | 'warning';
}>(),
{
tone: 'default',
size: 'sm',
title: '',
interactive: true,
particles: true,
},
);
const rootRef = ref<HTMLElement | null>(null);
const particleRef = ref<HTMLCanvasElement | null>(null);
const hovering = ref(false);
type Particle = {
a: number;
r: number;
vx: number;
vy: number;
x: number;
y: number;
};
let particles: Particle[] = [];
let particleRaf = 0;
let reducedMotion = false;
/** 映射到 CSS 变量名(不带 --),default 用 border */
const toneVar = computed(() => {
if (props.tone === 'default') return 'border';
return props.tone;
});
/** 读取当前 tone 的 HSL 通道,供 canvas 粒子着色 */
function toneChannels(): string {
const name = toneVar.value;
const raw = getComputedStyle(document.documentElement)
.getPropertyValue(`--${name}`)
.trim();
if (raw) return raw;
// border 在部分主题可能不是三通道,回落 primary
return (
getComputedStyle(document.documentElement)
.getPropertyValue('--primary')
.trim() || '162 72% 38%'
);
}
function resizeCanvas(el: HTMLCanvasElement, cssW: number, cssH: number) {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
el.width = Math.max(1, Math.floor(cssW * dpr));
el.height = Math.max(1, Math.floor(cssH * dpr));
el.style.width = `${cssW}px`;
el.style.height = `${cssH}px`;
const ctx = el.getContext('2d');
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return ctx;
}
/** 初始化少量粒子(芯片很小,8~14 颗即可) */
function initParticles(w: number, h: number) {
const n = Math.max(8, Math.min(14, Math.floor((w * h) / 180)));
particles = Array.from({ length: n }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.35,
vy: (Math.random() - 0.5) * 0.35,
r: 0.6 + Math.random() * 1.2,
a: 0.18 + Math.random() * 0.35,
}));
}
function stopParticles() {
if (particleRaf) {
cancelAnimationFrame(particleRaf);
particleRaf = 0;
}
const el = particleRef.value;
if (el) {
const ctx = el.getContext('2d');
if (ctx) ctx.clearRect(0, 0, el.width, el.height);
}
particles = [];
}
function loopParticles() {
const el = particleRef.value;
const root = rootRef.value;
if (!el || !root || !hovering.value) return;
const rect = root.getBoundingClientRect();
const w = Math.max(1, rect.width);
const h = Math.max(1, rect.height);
const ctx = resizeCanvas(el, w, h);
if (!ctx) return;
if (particles.length === 0) initParticles(w, h);
const ch = toneChannels();
ctx.clearRect(0, 0, w, h);
for (const p of particles) {
if (!reducedMotion) {
p.x += p.vx;
p.y += p.vy;
if (p.x < 0 || p.x > w) p.vx *= -1;
if (p.y < 0 || p.y > h) p.vy *= -1;
}
ctx.beginPath();
ctx.fillStyle = `hsl(${ch} / ${p.a})`;
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}
if (!reducedMotion && hovering.value) {
particleRaf = requestAnimationFrame(loopParticles);
}
}
function onEnter() {
if (!props.interactive) return;
hovering.value = true;
if (!props.particles) return;
reducedMotion = window.matchMedia?.(
'(prefers-reduced-motion: reduce)',
)?.matches;
stopParticles();
// 下一帧再画,确保尺寸已稳定
requestAnimationFrame(() => {
if (hovering.value) loopParticles();
});
}
function onLeave() {
hovering.value = false;
stopParticles();
}
watch(
() => [props.particles, props.interactive] as const,
([p, i]) => {
if (!p || !i) {
hovering.value = false;
stopParticles();
}
},
);
onBeforeUnmount(() => {
stopParticles();
});
</script>
<template>
<span
ref="rootRef"
class="glass-tag"
:class="[
`glass-tag--${size}`,
`glass-tag--tone-${tone}`,
{
'glass-tag--interactive': interactive,
'is-hover': hovering,
},
]"
:style="{ '--glass-tag-tone': `var(--${toneVar})` }"
:title="title || undefined"
@mouseenter="onEnter"
@mouseleave="onLeave"
>
<!-- 粒子层:仅 hover 时由 canvas 绘制 -->
<canvas
v-if="particles"
ref="particleRef"
class="glass-tag__particles"
aria-hidden="true"
></canvas>
<!-- 左上高光反射,对齐 GlassCard specular -->
<span class="glass-tag__specular" aria-hidden="true"></span>
<span class="glass-tag__content">
<slot></slot>
</span>
</span>
</template>
<style scoped>
.glass-tag {
--glass-specular: 0 0% 100%;
position: relative;
display: inline-flex;
flex-wrap: nowrap;
gap: 4px;
align-items: center;
max-width: 100%;
overflow: hidden;
font-weight: 500;
line-height: 1.4;
color: hsl(var(--foreground));
background: linear-gradient(
150deg,
hsl(var(--card, var(--background)) / 78%),
hsl(var(--card, var(--background)) / 48%)
);
border: 1px solid hsl(var(--glass-tag-tone) / 30%);
/* 胶囊圆角:芯片统一 pill,不要方形小圆角 */
border-radius: 999px;
box-shadow:
inset 0 1px 0 hsl(var(--glass-specular) / 55%),
inset 0 -1px 0 hsl(var(--glass-specular) / 8%),
0 0 6px hsl(var(--glass-tag-tone) / 18%);
backdrop-filter: blur(14px) saturate(170%);
transition:
transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow 0.22s ease,
border-color 0.22s ease;
}
.dark .glass-tag {
--glass-specular: 0 0% 62%;
}
.glass-tag--sm {
min-height: 24px;
padding: 3px 10px;
font-size: 12px;
}
.glass-tag--md {
min-height: 28px;
padding: 4px 12px;
font-size: 13px;
}
.glass-tag--tone-default {
border-color: hsl(var(--border));
box-shadow:
inset 0 1px 0 hsl(var(--glass-specular) / 45%),
0 0 4px hsl(var(--foreground) / 6%);
}
.glass-tag--tone-destructive {
border-color: hsl(var(--destructive) / 40%);
box-shadow:
inset 0 1px 0 hsl(var(--glass-specular) / 45%),
0 0 6px hsl(var(--destructive) / 16%);
}
.glass-tag--interactive {
cursor: default;
}
.glass-tag--interactive:hover,
.glass-tag--interactive.is-hover {
border-color: hsl(var(--glass-tag-tone) / 48%);
box-shadow:
inset 0 1px 0 hsl(var(--glass-specular) / 70%),
0 0 12px hsl(var(--glass-tag-tone) / 28%),
0 6px 14px hsl(var(--glass-tag-tone) / 12%);
transform: translateY(-1px);
}
.glass-tag--interactive:active {
box-shadow:
inset 0 1px 0 hsl(var(--glass-specular) / 50%),
0 0 8px hsl(var(--glass-tag-tone) / 20%);
transform: translateY(0);
}
.glass-tag__particles {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
border-radius: inherit;
}
.glass-tag__specular {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background:
radial-gradient(
120% 60% at 0% 0%,
hsl(var(--glass-specular) / 22%) 0%,
transparent 55%
),
radial-gradient(
60% 80% at 100% 0%,
hsl(var(--glass-tag-tone) / 12%) 0%,
transparent 70%
);
border-radius: inherit;
}
.glass-tag__content {
position: relative;
z-index: 2;
display: inline-flex;
flex-wrap: nowrap;
gap: 4px;
align-items: center;
min-width: 0;
max-width: 100%;
}
@media (prefers-reduced-motion: reduce) {
.glass-tag,
.glass-tag--interactive:hover,
.glass-tag--interactive.is-hover,
.glass-tag--interactive:active {
transform: none;
transition: none;
}
}
</style>

View File

@@ -0,0 +1,5 @@
/**
* 液态玻璃标签组件出口
* - GlassTag:紧凑芯片(对照药名、状态标签等)
*/
export { default as GlassTag } from './GlassTag.vue';

View File

@@ -5,12 +5,11 @@
*/
import type { Dayjs } from 'dayjs';
import { computed, ref } from 'vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { monthToTodayRangeDayjs } from '#/utils/search-time-range';
import {
Avatar,
Button,
@@ -18,16 +17,23 @@ import {
Descriptions,
Empty,
Image,
message,
Select,
Spin,
Table,
Tabs,
Tag,
message,
} from 'ant-design-vue';
import StoreFinancePanel from '#/components/store-card/StoreFinancePanel.vue';
import { monthToTodayRangeDayjs } from '#/utils/search-time-range';
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
import {
canAddStoreDoctor,
isPlatformSuperAdmin,
} from '#/views/system/admin/_shared/platform-admin-role';
import { getStoreBankCardReportDetail } from '#/views/system/store-bank-card/api';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
import {
getStoreCardApi,
getStoreCardStatsApi,
@@ -35,20 +41,32 @@ import {
openQrCodeApi,
} from '#/views/system/store/api';
import { getSalespersonList } from '#/views/system/store/api/salesperson';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
import AddDoctorFromStoreModal from '#/views/system/store/components/AddDoctorFromStoreModal.vue';
import { canAddStoreDoctor, isPlatformSuperAdmin } from '#/views/system/admin/_shared/platform-admin-role';
import StoreFinancePanel from '#/components/store-card/StoreFinancePanel.vue';
import {
popupToBody,
STORE_CARD_BODY_CLASS,
STORE_CARD_OVERLAY_Z_INDEX,
} from './overlay-z-index';
const RangePicker = DatePicker.RangePicker;
/** 打开期间给 body 打标,抬高挂到 body 的预览/下拉层级 */
function setStoreCardBodyOpen(open: boolean) {
document.body.classList.toggle(STORE_CARD_BODY_CLASS, open);
}
const userStore = useUserStore();
// 是否允许「添加医生」:超管/管理员/省经理/市经理/业务员
const canAddDoctor = computed(() => canAddStoreDoctor(userStore.userInfo));
// 是否可查看门店资金账户:仅超管/系统管理员
const canViewStoreFinance = computed(() => isPlatformSuperAdmin(userStore.userInfo));
const canViewStoreFinance = computed(() =>
isPlatformSuperAdmin(userStore.userInfo),
);
const financePanelRef = ref<InstanceType<typeof StoreFinancePanel> | null>(null);
const financePanelRef = ref<InstanceType<typeof StoreFinancePanel> | null>(
null,
);
const storeId = ref(0);
const loading = ref(false);
@@ -92,8 +110,18 @@ const drugTypeOptions = [
];
const drugColumns = [
{ title: '药品名称', dataIndex: 'drug_name', key: 'drug_name', ellipsis: true },
{ title: '规格', dataIndex: 'specification', key: 'specification', width: 140 },
{
title: '药品名称',
dataIndex: 'drug_name',
key: 'drug_name',
ellipsis: true,
},
{
title: '规格',
dataIndex: 'specification',
key: 'specification',
width: 140,
},
{ title: '售价', dataIndex: 'price', key: 'price', width: 100 },
{ title: '进价', dataIndex: 'buy_price', key: 'buy_price', width: 100 },
];
@@ -115,6 +143,8 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onOpenChange(isOpen: boolean) {
// 预览/下拉挂在 body 上,默认 z-index 低于 Vben 弹窗 2000,打开时抬高
setStoreCardBodyOpen(isOpen);
if (!isOpen) {
financePanelRef.value?.reset?.();
return;
@@ -133,6 +163,10 @@ const [Modal, modalApi] = useVbenModal({
},
});
onBeforeUnmount(() => {
setStoreCardBodyOpen(false);
});
const [DoctorModal, DoctorModalApi] = useVbenModal({
connectedComponent: DoctorCardModal,
});
@@ -176,8 +210,8 @@ async function loadCard() {
if (isPharmacy.value) {
drugType.value = 2;
}
} catch (e: any) {
message.error(e?.message || '加载门店详情失败');
} catch (error: any) {
message.error(error?.message || '加载门店详情失败');
} finally {
loading.value = false;
}
@@ -198,8 +232,8 @@ async function refreshStats() {
search_time: res.search_time,
};
}
} catch (e: any) {
message.error(e?.message || '刷新统计失败');
} catch (error: any) {
message.error(error?.message || '刷新统计失败');
}
}
@@ -256,7 +290,7 @@ async function loadPromoters() {
}
}
function onTabChange(key: string | number) {
function onTabChange(key: number | string) {
activeTab.value = String(key);
if (key === 'bank') loadBank();
if (key === 'drugs') loadDrugs();
@@ -275,8 +309,8 @@ async function ensureQrCode() {
await openQrCodeApi(storeId.value);
await loadCard();
message.success('二维码已生成');
} catch (e: any) {
message.error(e?.message || '生成二维码失败');
} catch (error: any) {
message.error(error?.message || '生成二维码失败');
} finally {
generatingQr.value = false;
}
@@ -290,14 +324,19 @@ function clinicTypeText(v: number) {
</script>
<template>
<Modal :title="modalTitle">
<Modal :title="modalTitle" class="w-[80%]">
<AddDoctorModal />
<Spin :spinning="loading">
<div v-if="cardData" class="mb-4 flex flex-wrap items-center justify-between gap-3 px-1">
<div
v-if="cardData"
class="mb-4 flex flex-wrap items-center justify-between gap-3 px-1"
>
<div class="text-sm text-muted-foreground">统计时间范围</div>
<RangePicker
v-model:value="searchTime"
format="YYYY-MM-DD"
:get-popup-container="popupToBody"
:popup-style="{ zIndex: STORE_CARD_OVERLAY_Z_INDEX }"
@change="refreshStats"
/>
</div>
@@ -310,7 +349,9 @@ function clinicTypeText(v: number) {
<div
class="relative flex flex-col overflow-hidden rounded-xl border border-blue-100 bg-blue-50 p-5 shadow-sm transition-all group hover:shadow-md dark:border-blue-800/50 dark:bg-blue-900/20"
>
<span class="text-sm font-semibold text-blue-600/80 dark:text-blue-300/80">
<span
class="text-sm font-semibold text-blue-600/80 dark:text-blue-300/80"
>
销售额
</span>
<div class="mt-2 text-2xl font-bold text-blue-900 dark:text-blue-100">
@@ -321,10 +362,14 @@ function clinicTypeText(v: number) {
<div
class="relative flex flex-col overflow-hidden rounded-xl border border-emerald-100 bg-emerald-50 p-5 shadow-sm transition-all group hover:shadow-md dark:border-emerald-800/50 dark:bg-emerald-900/20"
>
<span class="text-sm font-semibold text-emerald-600/80 dark:text-emerald-300/80">
<span
class="text-sm font-semibold text-emerald-600/80 dark:text-emerald-300/80"
>
利润
</span>
<div class="mt-2 text-2xl font-bold text-emerald-900 dark:text-emerald-100">
<div
class="mt-2 text-2xl font-bold text-emerald-900 dark:text-emerald-100"
>
<span class="text-lg">¥</span>
{{ Number(stats.profit ?? 0).toFixed(2) }}
</div>
@@ -332,12 +377,18 @@ function clinicTypeText(v: number) {
<div
class="relative flex flex-col overflow-hidden rounded-xl border border-purple-100 bg-purple-50 p-5 shadow-sm transition-all group hover:shadow-md dark:border-purple-800/50 dark:bg-purple-900/20"
>
<span class="text-sm font-semibold text-purple-600/80 dark:text-purple-300/80">
<span
class="text-sm font-semibold text-purple-600/80 dark:text-purple-300/80"
>
处方量
</span>
<div class="mt-2 text-2xl font-bold text-purple-900 dark:text-purple-100">
<div
class="mt-2 text-2xl font-bold text-purple-900 dark:text-purple-100"
>
{{ stats.prescription_count ?? 0 }}
<span class="text-xs font-normal text-purple-700/60 dark:text-purple-300/60">
<span
class="text-xs font-normal text-purple-700/60 dark:text-purple-300/60"
>
张
</span>
</div>
@@ -394,10 +445,20 @@ function clinicTypeText(v: number) {
<template v-if="bankDetail">
<Descriptions bordered :column="2" size="small">
<Descriptions.Item label="户名">
{{ bankDetail.bank_user_name || bankDetail.account_name || store.bank_user_name || '-' }}
{{
bankDetail.bank_user_name ||
bankDetail.account_name ||
store.bank_user_name ||
'-'
}}
</Descriptions.Item>
<Descriptions.Item label="卡号">
{{ bankDetail.bank_card || bankDetail.card_no || store.bank_card || '-' }}
{{
bankDetail.bank_card ||
bankDetail.card_no ||
store.bank_card ||
'-'
}}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{{ bankDetail.bank_name || store.bank_name || '-' }}
@@ -406,15 +467,28 @@ function clinicTypeText(v: number) {
{{ bankDetail.bank_no || store.bank_no || '-' }}
</Descriptions.Item>
<Descriptions.Item label="报备状态">
{{ bankDetail.report_status_text || store.bank_card_report_status_text || '-' }}
{{
bankDetail.report_status_text ||
store.bank_card_report_status_text ||
'-'
}}
</Descriptions.Item>
<Descriptions.Item label="账户类型">
{{ bankDetail.bank_account_type ?? store.bank_account_type ?? '-' }}
{{
bankDetail.bank_account_type ??
store.bank_account_type ??
'-'
}}
</Descriptions.Item>
</Descriptions>
</template>
<Empty v-else description="暂无银行卡报备信息,展示门店预留信息">
<Descriptions bordered :column="2" size="small" class="mt-4 text-left">
<Descriptions
bordered
:column="2"
size="small"
class="mt-4 text-left"
>
<Descriptions.Item label="户名">
{{ store.bank_user_name || '-' }}
</Descriptions.Item>
@@ -444,11 +518,7 @@ function clinicTypeText(v: number) {
<Tabs.TabPane v-if="isClinic" key="doctors" tab="诊所医生团队">
<div class="pl-4">
<div v-if="canAddDoctor" class="mb-3">
<Button
type="primary"
size="small"
@click="openAddDoctorModal"
>
<Button type="primary" size="small" @click="openAddDoctorModal">
添加医生
</Button>
</div>
@@ -459,9 +529,16 @@ function clinicTypeText(v: number) {
:key="item.su_id"
class="flex items-center gap-3 rounded-lg border border-gray-100 p-3 dark:border-slate-700"
>
<Avatar :src="resolveAvatarUrl(item.doctor?.avatar)" :size="48" />
<Avatar
:src="resolveAvatarUrl(item.doctor?.avatar)"
:size="48"
/>
<div class="min-w-0 flex-1">
<Button type="link" class="!px-0" @click="openDoctor(item.doctor?.su_id || item.su_id)">
<Button
type="link"
class="!px-0"
@click="openDoctor(item.doctor?.su_id || item.su_id)"
>
{{ item.doctor?.name || '未知医生' }}
</Button>
<div class="text-xs text-muted-foreground">
@@ -481,6 +558,8 @@ function clinicTypeText(v: number) {
v-model:value="drugType"
:options="drugTypeOptions"
style="width: 160px"
:get-popup-container="popupToBody"
:dropdown-style="{ zIndex: STORE_CARD_OVERLAY_Z_INDEX }"
@change="loadDrugs"
/>
</div>
@@ -502,7 +581,7 @@ function clinicTypeText(v: number) {
<Image
:src="cardData?.qr_code || store.qr_code"
:width="200"
:preview="true"
:preview="{ zIndex: STORE_CARD_OVERLAY_Z_INDEX }"
/>
<div class="text-sm text-muted-foreground">
{{ store.name }} {{ qrTabLabel }}
@@ -510,7 +589,11 @@ function clinicTypeText(v: number) {
</template>
<template v-else>
<Empty :description="`尚未生成${storeKindLabel}二维码`" />
<Button type="primary" :loading="generatingQr" @click="ensureQrCode">
<Button
type="primary"
:loading="generatingQr"
@click="ensureQrCode"
>
生成{{ storeKindLabel }}二维码
</Button>
</template>
@@ -549,16 +632,34 @@ function clinicTypeText(v: number) {
:deep(.custom-vertical-tabs .ant-tabs-nav) {
width: 140px;
}
:deep(.custom-vertical-tabs .ant-tabs-tab) {
padding: 12px 16px;
margin-bottom: 4px;
border-radius: 6px;
transition: all 0.3s;
}
:deep(.custom-vertical-tabs .ant-tabs-tab-active) {
background-color: var(
--ant-primary-color-active-deprecated-f-12,
rgba(22, 119, 255, 0.08)
rgb(22 119 255 / 8%)
);
}
</style>
<!--
挂到 body 的浮层无法靠 scoped 命中;仅在门店详情打开期间抬高层级,
避免被 Vben Dialog(--popup-z-index=2000)挡住
-->
<style>
body.store-card-modal-open .ant-image-preview-root,
body.store-card-modal-open .ant-image-preview-wrap,
body.store-card-modal-open .ant-select-dropdown,
body.store-card-modal-open .ant-picker-dropdown,
body.store-card-modal-open .ant-dropdown,
body.store-card-modal-open [data-slot='select-content'],
body.store-card-modal-open [data-reka-popper-content-wrapper] {
z-index: 3100 !important;
}
</style>

View File

@@ -1,22 +1,39 @@
<script lang="ts" setup>
/**
* 门店详情 - 资金账户面板(余额 + 资金流水 + 账户变动)
* 仅超管/系统管理员在 StoreCardModal 中可见,数据按门店 ID 查询
* 口径说明:资金流水按履约店 store_id;账户变动/余额按分账利润归属店 user_id
* 门店详情 - 资金账户面板
* 余额概览 + 四个列表 Tab(对齐提现管理:资金流水 / 结算记录 / 结算明细 / 资金变动记录)
* 仅超管/系统管理员在 StoreCardModal 中可见
*/
import type { Dayjs } from 'dayjs';
import { ref, watch } from 'vue';
import { nextTick, ref, watch } from 'vue';
import { DatePicker, Empty, Segmented, Spin, Table, Tag, message } from 'ant-design-vue';
import { AnalysisChartsTabs } from '@vben/common-ui';
import { IconifyIcon as VbenIcon } from '@vben/icons';
import { DatePicker, Empty, message, Spin, Table, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import { monthToTodayRangeDayjs } from '#/utils/search-time-range';
import ApplyOrdersModal from '#/views/finance/withdrawal/components/apply-orders-modal.vue';
import SettlementDetailDrawer from '#/views/finance/withdrawal/components/settlement-detail-drawer.vue';
import { getAccountChangeSearchOptions } from '#/views/finance/withdrawal/config/account-change-search';
import { formOptions3 as settlementLogSearchOptions } from '#/views/finance/withdrawal/config/settlement-log-search';
import { formOptions2 as settlementItemsSearchOptions } from '#/views/finance/withdrawal/config/settlement-search';
import { useWithdrawOrderDetailModals } from '#/views/finance/withdrawal/utils/use-withdraw-order-detail';
import {
getStoreAccountChangeLogListApi,
getStoreBalanceApi,
getStoreFundWaterListApi,
} from '#/views/system/store/api';
import { createStoreAccountChangeGridOptions } from './config/account-change-table';
import {
createStoreSettlementItemsGridOptions,
createStoreSettlementLogGridOptions,
} from './config/settlement-table';
import { popupToBody, STORE_CARD_OVERLAY_Z_INDEX } from './overlay-z-index';
const props = defineProps<{
storeId: number;
}>();
@@ -24,32 +41,24 @@ const props = defineProps<{
const RangePicker = DatePicker.RangePicker;
const balanceLoading = ref(false);
const balance = ref<Record<string, any> | null>(null);
const balance = ref<null | Record<string, any>>(null);
const fundWaterLoading = ref(false);
const fundWaterList = ref<any[]>([]);
const fundWaterPage = ref(1);
const fundWaterPageSize = ref(10);
const fundWaterTotal = ref(0);
const changeLogLoading = ref(false);
const changeLogList = ref<any[]>([]);
const changeLogPage = ref(1);
const changeLogPageSize = ref(10);
const changeLogTotal = ref(0);
const fundWaterTime = ref<[Dayjs, Dayjs]>(monthToTodayRangeDayjs());
const changeLogTime = ref<[Dayjs, Dayjs]>(monthToTodayRangeDayjs());
/** 流水列表切换:资金流水 / 账户变动(用 Segmented 避免窄容器下 Tab 折叠成 ...) */
const activeListTab = ref<'fundWater' | 'changeLog'>('fundWater');
const changeLogLoaded = ref(false);
const listTabOptions = [
/** 列表 Tab:与提现管理同套 AnalysisChartsTabs,避免 Segmented 在窄弹窗里难看 */
const activeListTab = ref('fundWater');
const listTabs = [
{ label: '资金流水', value: 'fundWater' },
{ label: '账户变动', value: 'changeLog' },
{ label: '结算记录', value: 'settlementLog' },
{ label: '结算明细', value: 'settlementItems' },
{ label: '资金变动记录', value: 'changeLog' },
];
/** 余额概览卡片配置 */
const balanceCards = [
{ key: 'balance', label: '可提现余额' },
{ key: 'total', label: '累计收益' },
@@ -66,28 +75,40 @@ const fundWaterColumns = [
{ title: '金额', dataIndex: 'price', key: 'price', width: 110 },
{ title: '流水类型', dataIndex: 'type', key: 'type', width: 110 },
{ title: '订单类型', dataIndex: 'order_type', key: 'order_type', width: 110 },
// store_id 便于核对是否本店履约(委托复诊时流水店与利润店可能不同)
{ title: '门店ID', dataIndex: 'store_id', key: 'store_id', width: 90 },
{ title: '诊所', dataIndex: 'store_name', key: 'store_name', width: 120, ellipsis: true },
{
title: '诊所',
dataIndex: 'store_name',
key: 'store_name',
width: 120,
ellipsis: true,
},
{ title: '订单号', dataIndex: 'order_no', key: 'order_no', ellipsis: true },
{ title: '医生', dataIndex: 'doctor_name', key: 'doctor_name', width: 100 },
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
];
const changeLogColumns = [
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
{ title: '来源', dataIndex: 'source_type_txt', key: 'source_type_txt', width: 100 },
{ title: '字段', dataIndex: 'field_name_txt', key: 'field_name_txt', width: 140 },
{ title: '变动前', dataIndex: 'before_amount', key: 'before_amount', width: 100 },
{ title: '变动后', dataIndex: 'after_amount', key: 'after_amount', width: 100 },
{ title: '变动值', dataIndex: 'change_amount', key: 'change_amount', width: 100 },
{ title: '主体ID', dataIndex: 'user_id', key: 'user_id', width: 90 },
{ title: '订单号', dataIndex: 'order_no', key: 'order_no', ellipsis: true },
{ title: '来源表', dataIndex: 'source_table_txt', key: 'source_table_txt', width: 110 },
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
];
const [SettlementLogGrid, settlementLogGridApi] = useVbenVxeGrid({
formOptions: settlementLogSearchOptions,
gridOptions: createStoreSettlementLogGridOptions(() => props.storeId),
});
const [SettlementItemsGrid] = useVbenVxeGrid({
formOptions: settlementItemsSearchOptions,
gridOptions: createStoreSettlementItemsGridOptions(() => props.storeId),
});
const [ChangeLogGrid, changeLogGridApi] = useVbenVxeGrid({
formOptions: getAccountChangeSearchOptions({ forceStore: true }),
gridOptions: createStoreAccountChangeGridOptions(() => props.storeId),
});
const settlementDetailDrawerRef =
ref<InstanceType<typeof SettlementDetailDrawer>>();
const applyOrdersModalRef = ref<InstanceType<typeof ApplyOrdersModal>>();
const { OrderDetailModal, RegisterDetailModal, TraceDrawer, openOrderDetail } =
useWithdrawOrderDetailModals();
/** 时间范围转接口参数 */
function timeParam(range: [Dayjs, Dayjs]): [string, string] {
return [
range[0].format('YYYY-MM-DD 00:00:00'),
@@ -105,9 +126,9 @@ async function loadBalance() {
balanceLoading.value = true;
try {
balance.value = await getStoreBalanceApi({ id: props.storeId });
} catch (e: any) {
} catch (error: any) {
balance.value = null;
message.error(e?.message || '加载余额失败');
message.error(error?.message || '加载余额失败');
} finally {
balanceLoading.value = false;
}
@@ -131,70 +152,72 @@ async function loadFundWater(page = fundWaterPage.value) {
store_name: item?.store?.name || '-',
}));
fundWaterTotal.value = Number(res?.total || 0);
} catch (e: any) {
} catch (error: any) {
fundWaterList.value = [];
fundWaterTotal.value = 0;
message.error(e?.message || '加载资金流水失败');
message.error(error?.message || '加载资金流水失败');
} finally {
fundWaterLoading.value = false;
}
}
/** 拉取账户变动 */
async function loadChangeLog(page = changeLogPage.value) {
if (!props.storeId) return;
changeLogLoading.value = true;
changeLogPage.value = page;
try {
const res = await getStoreAccountChangeLogListApi({
id: props.storeId,
page,
pageSize: changeLogPageSize.value,
search_time: timeParam(changeLogTime.value),
});
changeLogList.value = res?.items || [];
changeLogTotal.value = Number(res?.total || 0);
} catch (e: any) {
changeLogList.value = [];
changeLogTotal.value = 0;
message.error(e?.message || '加载账户变动失败');
} finally {
changeLogLoading.value = false;
}
}
/** 重置分页并加载余额与当前列表 Tab 数据 */
/** 重置并加载余额 + 资金流水(其它 Tab 挂载时由 Grid 自拉) */
function loadAll() {
fundWaterPage.value = 1;
changeLogPage.value = 1;
changeLogLoaded.value = false;
activeListTab.value = 'fundWater';
loadBalance();
loadFundWater(1);
}
/** 列表切换:懒加载账户变动 */
function onListTabChange(value: string | number) {
activeListTab.value = value === 'changeLog' ? 'changeLog' : 'fundWater';
if (activeListTab.value === 'changeLog' && !changeLogLoaded.value) {
loadChangeLog(1);
changeLogLoaded.value = true;
}
/** Tab 切换后重算 Vxe 高度,避免弹窗内缩成一行 */
function onListTabChange() {
nextTick(() => {
setTimeout(() => {
settlementLogGridApi.grid?.recalculate?.();
changeLogGridApi.grid?.recalculate?.();
}, 50);
});
}
watch(activeListTab, () => {
onListTabChange();
});
function showSettlementDetail(row: Record<string, any>) {
settlementDetailDrawerRef.value?.open(row, { storeId: props.storeId });
}
function showApplyOrders(row: Record<string, any>) {
const orders = row.orders || [];
if (orders.length === 1) {
openOrderDetail(orders[0]);
return;
}
applyOrdersModalRef.value?.open({
title: row.order_no ? `关联订单 · ${row.order_no}` : '关联订单',
orders,
});
}
function onAccountChangeOrder(row: Record<string, any>) {
const orders = row.orders || [];
if (
orders.length > 1 ||
(row.source_type === 'withdraw' && orders.length > 0)
) {
showApplyOrders(row);
return;
}
openOrderDetail(row);
}
/** 重置面板状态(弹窗关闭时由父组件调用) */
function reset() {
balance.value = null;
fundWaterList.value = [];
changeLogList.value = [];
fundWaterTotal.value = 0;
changeLogTotal.value = 0;
fundWaterPage.value = 1;
changeLogPage.value = 1;
fundWaterTime.value = monthToTodayRangeDayjs();
changeLogTime.value = monthToTodayRangeDayjs();
activeListTab.value = 'fundWater';
changeLogLoaded.value = false;
}
defineExpose({ loadAll, reset });
@@ -225,6 +248,12 @@ function orderTypeTag(orderType: number) {
<template>
<div class="store-finance-panel pl-4">
<OrderDetailModal />
<RegisterDetailModal />
<TraceDrawer />
<ApplyOrdersModal ref="applyOrdersModalRef" @view-order="openOrderDetail" />
<SettlementDetailDrawer ref="settlementDetailDrawerRef" />
<section class="finance-section">
<div class="section-title">余额概览</div>
<Spin :spinning="balanceLoading">
@@ -235,7 +264,9 @@ function orderTypeTag(orderType: number) {
class="balance-card"
>
<span class="balance-card__label">{{ card.label }}</span>
<span class="balance-card__value">{{ formatMoney(balance[card.key]) }}</span>
<span class="balance-card__value">{{
formatMoney(balance[card.key])
}}</span>
</div>
</div>
<Empty v-else-if="!balanceLoading" description="暂无余额数据" />
@@ -244,101 +275,140 @@ function orderTypeTag(orderType: number) {
<section class="finance-section finance-section--list">
<p class="finance-hint">
资金流水按订单履约店;账户变动/余额按分账利润归属店。委托复诊等场景两边主体可能不同,属业务口径而非串店。
资金流水按订单履约店;结算/资金变动/余额按分账利润归属店。委托复诊等场景两边主体可能不同,属业务口径而非串店。
</p>
<Segmented
<AnalysisChartsTabs
v-model:value="activeListTab"
block
class="finance-list-segment"
:options="listTabOptions"
@change="onListTabChange"
/>
:tabs="listTabs"
class="finance-list-tabs"
>
<template #fundWater>
<div class="list-toolbar">
<RangePicker
v-model:value="fundWaterTime"
format="YYYY-MM-DD"
:get-popup-container="popupToBody"
:popup-style="{ zIndex: STORE_CARD_OVERLAY_Z_INDEX }"
@change="loadFundWater(1)"
/>
</div>
<Table
:columns="fundWaterColumns"
:data-source="fundWaterList"
:loading="fundWaterLoading"
:pagination="{
current: fundWaterPage,
pageSize: fundWaterPageSize,
total: fundWaterTotal,
showSizeChanger: true,
showTotal: (total: number) => `共 ${total} 条`,
}"
row-key="id"
size="small"
:scroll="{ x: 1000, y: 320 }"
@change="
(pag: any) => {
fundWaterPageSize = pag.pageSize || fundWaterPageSize;
loadFundWater(pag.current || 1);
}
"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'price'">
{{ formatMoney(record.price) }}
</template>
<template v-else-if="column.key === 'type'">
<Tag :color="fundWaterTypeTag(record.type).color">
{{ fundWaterTypeTag(record.type).text }}
</Tag>
</template>
<template v-else-if="column.key === 'order_type'">
<Tag :color="orderTypeTag(Number(record.order_type)).color">
{{ orderTypeTag(Number(record.order_type)).text }}
</Tag>
</template>
</template>
</Table>
</template>
<div v-show="activeListTab === 'fundWater'">
<div class="list-toolbar">
<RangePicker
v-model:value="fundWaterTime"
format="YYYY-MM-DD"
@change="loadFundWater(1)"
/>
</div>
<Table
:columns="fundWaterColumns"
:data-source="fundWaterList"
:loading="fundWaterLoading"
:pagination="{
current: fundWaterPage,
pageSize: fundWaterPageSize,
total: fundWaterTotal,
showSizeChanger: true,
showTotal: (total: number) => `共 ${total} 条`,
}"
row-key="id"
size="small"
:scroll="{ x: 1000, y: 320 }"
@change="(pag: any) => {
fundWaterPageSize = pag.pageSize || fundWaterPageSize;
loadFundWater(pag.current || 1);
}"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'price'">
{{ formatMoney(record.price) }}
</template>
<template v-else-if="column.key === 'type'">
<Tag :color="fundWaterTypeTag(record.type).color">
{{ fundWaterTypeTag(record.type).text }}
</Tag>
</template>
<template v-else-if="column.key === 'order_type'">
<Tag :color="orderTypeTag(Number(record.order_type)).color">
{{ orderTypeTag(Number(record.order_type)).text }}
</Tag>
</template>
</template>
</Table>
</div>
<template #settlementLog>
<div class="tab-grid-wrap">
<SettlementLogGrid>
<template #toolbar-actions></template>
<template #toolbar-tools></template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '查看明细',
type: 'link',
size: 'small',
onClick: showSettlementDetail.bind(null, row),
},
{
label: '订单详情',
type: 'link',
size: 'small',
onClick: openOrderDetail.bind(null, row),
},
]"
:drop-down-actions="[]"
/>
</template>
</SettlementLogGrid>
</div>
</template>
<div v-show="activeListTab === 'changeLog'">
<div class="list-toolbar">
<RangePicker
v-model:value="changeLogTime"
format="YYYY-MM-DD"
@change="loadChangeLog(1)"
/>
</div>
<Table
:columns="changeLogColumns"
:data-source="changeLogList"
:loading="changeLogLoading"
:pagination="{
current: changeLogPage,
pageSize: changeLogPageSize,
total: changeLogTotal,
showSizeChanger: true,
showTotal: (total: number) => `共 ${total} 条`,
}"
row-key="id"
size="small"
:scroll="{ x: 1080, y: 320 }"
@change="(pag: any) => {
changeLogPageSize = pag.pageSize || changeLogPageSize;
loadChangeLog(pag.current || 1);
}"
>
<template #bodyCell="{ column, record }">
<template
v-if="
column.key === 'before_amount' ||
column.key === 'after_amount' ||
column.key === 'change_amount'
"
>
{{ formatMoney(record[column.key]) }}
</template>
</template>
</Table>
</div>
<template #settlementItems>
<div class="tab-grid-wrap">
<SettlementItemsGrid>
<template #toolbar-actions></template>
<template #toolbar-tools></template>
</SettlementItemsGrid>
</div>
</template>
<template #changeLog>
<div class="tab-grid-wrap">
<ChangeLogGrid>
<template #toolbar-actions></template>
<template #toolbar-tools></template>
<template #change_amount="{ row }">
<span
v-if="Number(row.change_amount) > 0"
class="amount-cell is-up"
>
<VbenIcon icon="lucide:trending-up" class="amount-icon" />
+{{ Number(row.change_amount).toFixed(2) }}
</span>
<span
v-else-if="Number(row.change_amount) < 0"
class="amount-cell is-down"
>
<VbenIcon icon="lucide:trending-down" class="amount-icon" />
{{ Number(row.change_amount).toFixed(2) }}
</span>
<span v-else class="cell-muted">0.00</span>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '订单详情',
type: 'link',
size: 'small',
ifShow:
!!row.order_id || (row.orders && row.orders.length > 0),
onClick: onAccountChangeOrder.bind(null, row),
},
]"
:drop-down-actions="[]"
/>
</template>
</ChangeLogGrid>
</div>
</template>
</AnalysisChartsTabs>
</section>
</div>
</template>
@@ -356,14 +426,6 @@ function orderTypeTag(orderType: number) {
gap: 12px;
}
.section-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-title {
font-size: 14px;
font-weight: 600;
@@ -382,8 +444,14 @@ function orderTypeTag(orderType: number) {
color: hsl(var(--muted-foreground));
}
.finance-list-segment {
margin-bottom: 4px;
/* AnalysisChartsTabs 根节点自带 card-box,这里收紧边距并加同色系边框微光 */
.finance-list-tabs {
padding-inline: 12px;
margin-top: 0;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 12px;
box-shadow: 0 0 6px hsl(var(--primary) / 12%);
}
.list-toolbar {
@@ -392,6 +460,10 @@ function orderTypeTag(orderType: number) {
margin-bottom: 12px;
}
.tab-grid-wrap {
min-height: 460px;
}
.balance-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
@@ -403,9 +475,9 @@ function orderTypeTag(orderType: number) {
flex-direction: column;
gap: 6px;
padding: 14px 16px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 10px;
background: hsl(var(--card, var(--background)));
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
@@ -419,4 +491,30 @@ function orderTypeTag(orderType: number) {
font-weight: 600;
color: hsl(var(--foreground));
}
.amount-cell {
display: inline-flex;
gap: 4px;
align-items: center;
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.amount-cell.is-up {
color: hsl(var(--success));
}
.amount-cell.is-down {
color: hsl(var(--destructive));
}
.amount-icon {
width: 14px;
height: 14px;
}
.cell-muted {
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,69 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getStoreAccountChangeLogListApi } from '#/views/system/store/api';
/**
* 诊所详情 · 资金变动记录表格
* 列与交互对齐提现管理「资金变动记录」,数据按门店主体走 store-account-change-log-list
*/
export function createStoreAccountChangeGridOptions(
getStoreId: () => number,
): VxeGridProps {
return {
columnConfig: {
useKey: true,
},
rowConfig: {
keyField: 'id',
useKey: true,
},
columns: [
{ field: 'created_at', title: '变动时间', width: 170 },
{ field: 'source_type_txt', title: '来源类型', width: 100 },
{ field: 'field_name_txt', title: '变更字段', width: 120 },
{ field: 'before_amount', title: '变动前', width: 110 },
{ field: 'after_amount', title: '变动后', width: 110 },
{
field: 'change_amount',
title: '变动值',
width: 110,
slots: { default: 'change_amount' },
},
{ field: 'order_no', title: '订单号', minWidth: 160 },
{ field: 'source_table_txt', title: '来源表', width: 140 },
{ field: 'remark', title: '备注', minWidth: 180 },
{ type: 'html', title: '操作', slots: { default: 'action' }, width: 100 },
],
keepSource: true,
height: 420,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const storeId = Number(getStoreId() || 0);
if (storeId <= 0) {
return { items: [], total: 0 };
}
return await getStoreAccountChangeLogListApi({
id: storeId,
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: {
buttons: 'toolbar-actions',
},
},
showOverflow: false,
};
}

View File

@@ -0,0 +1,117 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import {
getStoreSettlementItemsListApi,
getStoreSettlementLedgerLogListApi,
} from '#/views/system/store/api';
/** 同订单合并开关:表单 Switch 可能是 boolean / 字符串 */
function normalizeMergeByOrder(value: unknown): 0 | 1 {
if (value === false || value === 0 || value === '0') {
return 0;
}
return 1;
}
/**
* 诊所详情 · 结算记录表格(对齐提现管理 Grid3)
*/
export function createStoreSettlementLogGridOptions(
getStoreId: () => number,
): VxeGridProps {
return {
columnConfig: { useKey: true },
rowConfig: { keyField: 'id', useKey: true },
columns: [
{ field: 'order_no', title: '订单号', minWidth: 160 },
{ field: 'order_type_txt', title: '订单类型', width: 100 },
{ field: 'fee_type_txt', title: '费用类型', minWidth: 120 },
{ field: 'amount', title: '金额', width: 110 },
{ field: 'type_txt', title: '类型', width: 90 },
{ field: 'content', title: '说明', minWidth: 140 },
{ field: 'created_at', title: '结算时间', width: 170 },
{ type: 'html', title: '操作', slots: { default: 'action' }, width: 160 },
],
keepSource: true,
height: 420,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const storeId = Number(getStoreId() || 0);
if (storeId <= 0) {
return { items: [], total: 0 };
}
const { merge_by_order, ...rest } = formValues || {};
return await getStoreSettlementLedgerLogListApi({
id: storeId,
page: page.currentPage,
pageSize: page.pageSize,
merge_by_order: normalizeMergeByOrder(merge_by_order),
exclude_zero_amount: 1,
...rest,
});
},
},
},
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: { buttons: 'toolbar-actions' },
},
showOverflow: false,
};
}
/**
* 诊所详情 · 结算明细表格(对齐提现管理 Grid2,去掉名称列——主体已固定为本店)
*/
export function createStoreSettlementItemsGridOptions(
getStoreId: () => number,
): VxeGridProps {
return {
columnConfig: { useKey: true },
rowConfig: { keyField: 'id', useKey: true },
columns: [
{ field: 'id', title: 'ID', width: 90 },
{ field: 'order.order_no', title: '订单号', minWidth: 160 },
{ field: 'money', title: '分账金额', width: 110 },
{ field: 'fee_type_txt', title: '费用类型', width: 110 },
{ field: 'status_txt', title: '结算状态', width: 100 },
{ field: 'created_at', title: '创建时间', width: 170 },
],
keepSource: true,
height: 420,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const storeId = Number(getStoreId() || 0);
if (storeId <= 0) {
return { items: [], total: 0 };
}
return await getStoreSettlementItemsListApi({
id: storeId,
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: { buttons: 'toolbar-actions' },
},
showOverflow: false,
};
}

View File

@@ -0,0 +1,14 @@
/**
* 门店详情弹窗浮层层级
* Vben Dialog 默认 --popup-z-index=2000;antd Image 预览约 1080、Select 约 1050,
* 不抬高会被弹窗挡住。资金 Tab 的 VbenSelect 也走 z-popup=2000,同层易被盖住。
*/
export const STORE_CARD_OVERLAY_Z_INDEX = 3100;
/** body 标记:弹窗打开期间用全局样式抬高挂到 body 的预览/下拉 */
export const STORE_CARD_BODY_CLASS = 'store-card-modal-open';
/** antd 下拉/日期挂到 body,避免被弹窗 content overflow-y-auto 裁切 */
export function popupToBody() {
return document.body;
}

View File

@@ -3,9 +3,15 @@
* 卡片视图专用操作栏(与列表交互解耦)
*
* 列表单元格里 TableAction 平铺全部按钮没问题,但卡片 footer 空间小。
* 只露出前 maxVisible 个主操作(默认「详情」),其余收进「更多」气泡卡片(Popover)。
* 只露出前 maxVisible 个主操作,其余收进「更多」气泡。
*
* 默认 variant=glass:液态玻璃胶囊按钮(半透明底 + 同色系边框微光),footer 默认沾满等分;
* layout=center 可改为居中不拉满;variant=default 才退回 antd Button(极少用)。
*
* size:超级小 xs / 小 sm / 中 md / 大 lg / 沾满一半 half / 沾满整行 full。
* half、full 管整行占宽;xs~lg 管字号高度,占宽仍跟 layout。
*/
import type { ActionItem } from './types';
import type { ActionItem, CardActionsSize } from './types';
import { computed, ref } from 'vue';
@@ -20,17 +26,48 @@ const props = withDefaults(
defineProps<{
actions?: ActionItem[];
dropDownActions?: ActionItem[];
/** fill(默认)footer 沾满等分;center 居中不拉满;size=half/full 时以 size 为准 */
layout?: 'center' | 'fill';
maxVisible?: number;
/**
* 玻璃按钮尺寸
* xs 超级小 / sm 小 / md 中(默认) / lg 大 / half 沾满一半 / full 沾满整行
*/
size?: CardActionsSize;
/** glass(默认)玻璃胶囊;default 才用 antd 按钮 */
variant?: 'default' | 'glass';
}>(),
{
actions: () => [],
dropDownActions: () => [],
maxVisible: 1,
variant: 'glass',
layout: 'fill',
size: 'md',
},
);
const { hasAccessByCodes } = useAccess();
const moreOpen = ref(false);
const isGlass = computed(() => props.variant !== 'default');
/** half/full 走占宽尺寸;其余走字号尺寸(视觉 chrome) */
const sizeMode = computed(() => {
if (props.size === 'half' || props.size === 'full') return 'width';
return 'chrome';
});
/** 实际应用的 layout:half≈半宽等分,full≈整行等分,其余沿用 props.layout */
const effectiveLayout = computed(() => {
if (props.size === 'half' || props.size === 'full') return 'fill';
return props.layout;
});
/** 字号档:half/full 用中号视觉,避免半宽/整行按钮显得过小或过大 */
const chromeSize = computed(() => {
if (sizeMode.value === 'width') return 'md';
return props.size || 'md';
});
/** 与 TableAction 同规则判断单个操作是否可见(auth + ifShow) */
function isActionVisible(action: ActionItem): boolean {
@@ -61,7 +98,9 @@ const splitActions = computed(() => {
);
const rest = [
...visible.slice(props.maxVisible),
...(props.dropDownActions || []).filter((action) => isActionVisible(action)),
...(props.dropDownActions || []).filter((action) =>
isActionVisible(action),
),
].map(mapAction);
return {
primary: visible.slice(0, props.maxVisible).map(mapAction),
@@ -81,6 +120,13 @@ function getButtonProps(action: ActionItem) {
return res;
}
/** 玻璃按钮色调:危险红、主操作绿、其余中性 */
function glassTone(action: ActionItem, index: number) {
if (action.danger || action.color === 'error') return 'danger';
if (index === 0) return 'primary';
return 'neutral';
}
function runAction(action: ReturnType<typeof mapAction>) {
if (action.disabled) return;
if (!action.enable && action.onClick) {
@@ -89,30 +135,159 @@ function runAction(action: ReturnType<typeof mapAction>) {
}
}
function getPopConfirmProps(attrs: Record<string, any>) {
const originAttrs: any = { ...attrs };
delete originAttrs.icon;
if (attrs.confirm && isFunction(attrs.confirm)) {
originAttrs.onConfirm = attrs.confirm;
delete originAttrs.confirm;
/**
* 只透传 Popconfirm 需要的字段,禁止把整份 action(label/type/auth…)v-bind 上去
* 为什么:多余字段会导致 antdv 内部回调变成非函数,报 _a.call is not a function
*/
function getPopConfirmProps(action: ReturnType<typeof mapAction>) {
const pc = (action as ActionItem).popConfirm;
const title = pc?.title ?? (action as any).title;
const rawConfirm =
(isFunction(pc?.confirm) ? pc!.confirm : undefined) ||
(isFunction((action as any).confirm)
? (action as any).confirm
: undefined) ||
(isFunction(action.onConfirm) ? action.onConfirm : undefined);
const rawCancel =
(isFunction(pc?.cancel) ? pc!.cancel : undefined) ||
(isFunction((action as any).cancel) ? (action as any).cancel : undefined) ||
(isFunction(action.onCancel) ? action.onCancel : undefined);
const props: Record<string, any> = {
title: title || '确定?',
};
if (pc?.okText) props.okText = pc.okText;
if (pc?.cancelText) props.cancelText = pc.cancelText;
if (isFunction(rawConfirm)) {
props.onConfirm = () => {
rawConfirm();
moreOpen.value = false;
};
}
if (attrs.cancel && isFunction(attrs.cancel)) {
originAttrs.onCancel = attrs.cancel;
delete originAttrs.cancel;
if (isFunction(rawCancel)) {
props.onCancel = rawCancel;
}
return originAttrs;
return props;
}
</script>
<template>
<div class="card-actions m-table-action">
<Space :size="0">
<template v-for="(action, index) in splitActions.primary" :key="'p-' + index">
<Popconfirm
v-if="action.enable"
v-bind="getPopConfirmProps(action)"
@confirm="() => { action.onConfirm?.(); moreOpen = false; }"
<div
class="card-actions m-table-action"
:class="[
{ 'card-actions--glass': isGlass },
isGlass ? `card-actions--${effectiveLayout}` : '',
isGlass ? `card-actions--size-${chromeSize}` : '',
isGlass && size === 'half' ? 'card-actions--half' : '',
isGlass && size === 'full' ? 'card-actions--full' : '',
]"
>
<div v-if="isGlass" class="card-actions__glass-row">
<template
v-for="(action, index) in splitActions.primary"
:key="`gp-${ index}`"
>
<Popconfirm v-if="action.enable" v-bind="getPopConfirmProps(action)">
<button
type="button"
class="card-actions__glass-btn"
:class="`card-actions__glass-btn--${glassTone(action, index)}`"
:disabled="action.disabled === true"
>
<Icon
v-if="action.icon"
:icon="action.icon"
class="card-actions__glass-icon"
/>
<span>{{ action.label }}</span>
</button>
</Popconfirm>
<button
v-else
type="button"
class="card-actions__glass-btn"
:class="[
`card-actions__glass-btn--${glassTone(action, index)}`,
{ 'is-disabled': action.disabled === true },
]"
:disabled="action.disabled === true"
@click="runAction(action)"
>
<Icon
v-if="action.icon"
:icon="action.icon"
class="card-actions__glass-icon"
/>
<span>{{ action.label }}</span>
</button>
</template>
<Popover
v-if="splitActions.rest.length > 0"
v-model:open="moreOpen"
trigger="click"
placement="topRight"
overlay-class-name="card-actions-popover"
>
<button
type="button"
class="card-actions__glass-btn card-actions__glass-btn--neutral"
>
<span>更多</span>
<Icon
icon="ant-design:down-outlined"
class="card-actions__more-icon"
/>
</button>
<template #content>
<div class="card-actions-popover__panel">
<template
v-for="(action, index) in splitActions.rest"
:key="`gm-${ index}`"
>
<Popconfirm
v-if="action.enable"
v-bind="getPopConfirmProps(action)"
>
<button
type="button"
class="card-actions-popover__item"
:class="{ 'is-disabled': action.disabled === true }"
:disabled="action.disabled === true"
>
<Icon
v-if="action.icon"
:icon="action.icon"
class="card-actions-popover__icon"
/>
<span>{{ action.label }}</span>
</button>
</Popconfirm>
<button
v-else
type="button"
class="card-actions-popover__item"
:class="{ 'is-disabled': action.disabled === true }"
:disabled="action.disabled === true"
@click="runAction(action)"
>
<Icon
v-if="action.icon"
:icon="action.icon"
class="card-actions-popover__icon"
/>
<span>{{ action.label }}</span>
</button>
</template>
</div>
</template>
</Popover>
</div>
<Space v-else :size="0">
<template
v-for="(action, index) in splitActions.primary"
:key="`p-${ index}`"
>
<Popconfirm v-if="action.enable" v-bind="getPopConfirmProps(action)">
<Button v-bind="getButtonProps(action)">
<template v-if="action.icon" #icon>
<Icon :icon="action.icon" />
@@ -140,15 +315,20 @@ function getPopConfirmProps(attrs: Record<string, any>) {
>
<Button size="small" type="link" class="card-actions__more">
更多
<Icon icon="ant-design:down-outlined" class="card-actions__more-icon" />
<Icon
icon="ant-design:down-outlined"
class="card-actions__more-icon"
/>
</Button>
<template #content>
<div class="card-actions-popover__panel">
<template v-for="(action, index) in splitActions.rest" :key="'m-' + index">
<template
v-for="(action, index) in splitActions.rest"
:key="`m-${ index}`"
>
<Popconfirm
v-if="action.enable"
v-bind="getPopConfirmProps(action)"
@confirm="() => { action.onConfirm?.(); moreOpen = false; }"
>
<button
type="button"
@@ -156,7 +336,11 @@ function getPopConfirmProps(attrs: Record<string, any>) {
:class="{ 'is-disabled': action.disabled === true }"
:disabled="action.disabled === true"
>
<Icon v-if="action.icon" :icon="action.icon" class="card-actions-popover__icon" />
<Icon
v-if="action.icon"
:icon="action.icon"
class="card-actions-popover__icon"
/>
<span>{{ action.label }}</span>
</button>
</Popconfirm>
@@ -168,7 +352,11 @@ function getPopConfirmProps(attrs: Record<string, any>) {
:disabled="action.disabled === true"
@click="runAction(action)"
>
<Icon v-if="action.icon" :icon="action.icon" class="card-actions-popover__icon" />
<Icon
v-if="action.icon"
:icon="action.icon"
class="card-actions-popover__icon"
/>
<span>{{ action.label }}</span>
</button>
</template>
@@ -184,6 +372,195 @@ function getPopConfirmProps(attrs: Record<string, any>) {
font-size: 10px;
}
.card-actions--glass {
width: 100%;
}
/* 沾满一半:整行只占 footer 一半,按钮在半宽内等分 */
.card-actions--half {
width: 50%;
max-width: 50%;
}
/* 沾满整行:显式 100%(与默认 glass 一致,便于调用方语义选择) */
.card-actions--full {
width: 100%;
}
.card-actions__glass-row {
display: flex;
flex-wrap: nowrap;
gap: 8px;
width: 100%;
}
/* fill:按钮等分沾满 footer;center:居中、宽度随内容 */
.card-actions--fill .card-actions__glass-btn {
flex: 1 1 0;
min-width: 0;
}
.card-actions--center .card-actions__glass-row {
flex-wrap: wrap;
justify-content: center;
}
.card-actions--center .card-actions__glass-btn {
flex: 0 1 auto;
min-width: 96px;
}
/*
* 玻璃按钮:与 GlassCard 同套路——半透明底 + blur + 1px 同色系边框微光
* 禁止左侧色条;颜色全走主题变量,暗色自动跟
*/
.card-actions__glass-btn {
display: inline-flex;
gap: 6px;
align-items: center;
justify-content: center;
min-height: 34px;
padding: 0 12px;
font-size: 13px;
font-weight: 500;
line-height: 1;
color: hsl(var(--glass-btn-fg));
cursor: pointer;
background: linear-gradient(
160deg,
hsl(var(--glass-btn-tone) / 16%),
hsl(var(--glass-btn-tone) / 6%)
);
border: 1px solid hsl(var(--glass-btn-tone) / 32%);
border-radius: 999px;
box-shadow:
inset 0 1px 0 hsl(0deg 0% 100% / 12%),
0 0 8px hsl(var(--glass-btn-tone) / 14%);
backdrop-filter: blur(12px) saturate(160%);
transition:
transform 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease;
&:hover:not(:disabled):not(.is-disabled) {
border-color: hsl(var(--glass-btn-tone) / 55%);
box-shadow:
inset 0 1px 0 hsl(0deg 0% 100% / 18%),
0 0 12px hsl(var(--glass-btn-tone) / 28%);
transform: translateY(-1px);
}
&:active:not(:disabled):not(.is-disabled) {
transform: translateY(0);
}
&:disabled,
&.is-disabled {
cursor: not-allowed;
opacity: 0.45;
}
}
/* 尺寸档:xs 超级小 / sm 小 / md 中 / lg 大 */
.card-actions--size-xs {
.card-actions__glass-row {
gap: 4px;
}
.card-actions__glass-btn {
min-height: 24px;
padding: 0 8px;
font-size: 11px;
}
.card-actions__glass-icon {
font-size: 12px;
}
&.card-actions--center .card-actions__glass-btn {
min-width: 64px;
}
}
.card-actions--size-sm {
.card-actions__glass-row {
gap: 6px;
}
.card-actions__glass-btn {
min-height: 28px;
padding: 0 10px;
font-size: 12px;
}
.card-actions__glass-icon {
font-size: 13px;
}
&.card-actions--center .card-actions__glass-btn {
min-width: 80px;
}
}
.card-actions--size-md {
.card-actions__glass-btn {
min-height: 34px;
padding: 0 12px;
font-size: 13px;
}
.card-actions__glass-icon {
font-size: 14px;
}
}
.card-actions--size-lg {
.card-actions__glass-row {
gap: 10px;
}
.card-actions__glass-btn {
min-height: 40px;
padding: 0 16px;
font-size: 14px;
}
.card-actions__glass-icon {
font-size: 16px;
}
&.card-actions--center .card-actions__glass-btn {
min-width: 112px;
}
}
.card-actions__glass-btn--primary {
--glass-btn-tone: var(--primary);
--glass-btn-fg: hsl(var(--primary));
}
.card-actions__glass-btn--neutral {
--glass-btn-tone: var(--foreground);
--glass-btn-fg: hsl(var(--foreground) / 88%);
}
.card-actions__glass-btn--danger {
--glass-btn-tone: var(--destructive);
--glass-btn-fg: hsl(var(--destructive));
}
.card-actions__glass-icon {
flex-shrink: 0;
font-size: 14px;
}
.card-actions__glass-btn > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.card-actions-popover .ant-popover-inner) {
padding: 6px;
background: hsl(var(--card, var(--background)));
@@ -214,8 +591,8 @@ function getPopConfirmProps(attrs: Record<string, any>) {
transition: background 0.15s ease;
&:hover:not(.is-disabled):not(:disabled) {
background: hsl(var(--primary) / 10%);
color: hsl(var(--primary));
background: hsl(var(--primary) / 10%);
}
&.is-disabled,

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import type { ButtonType } from 'ant-design-vue/es/button';
import type { PropType } from 'vue';
import type { ActionItem, PopConfirm } from './types';
import { computed, type PropType, toRaw } from 'vue';
import { computed, toRaw } from 'vue';
import { useAccess } from '@vben/access';
import { isBoolean, isFunction } from '@vben/utils';
@@ -96,18 +98,24 @@ const getDropdownList = computed((): any[] => {
};
});
});
/**
* 只透传 Popconfirm 白名单字段,禁止原地 mutate / 整对象 v-bind
* 为什么:原先 delete confirm 会污染源对象,且多余字段会触发 antdv _a.call is not a function
*/
const getPopConfirmProps = (attrs: PopConfirm) => {
const originAttrs: any = attrs;
delete originAttrs.icon;
if (attrs.confirm && isFunction(attrs.confirm)) {
originAttrs.onConfirm = attrs.confirm;
delete originAttrs.confirm;
const props: Record<string, any> = {
title: attrs?.title || '确定?',
};
if (attrs?.okText) props.okText = attrs.okText;
if (attrs?.cancelText) props.cancelText = attrs.cancelText;
if (attrs?.disabled != null) props.disabled = attrs.disabled;
if (attrs?.confirm && isFunction(attrs.confirm)) {
props.onConfirm = attrs.confirm;
}
if (attrs.cancel && isFunction(attrs.cancel)) {
originAttrs.onCancel = attrs.cancel;
delete originAttrs.cancel;
if (attrs?.cancel && isFunction(attrs.cancel)) {
props.onCancel = attrs.cancel;
}
return originAttrs;
return props;
};
const getButtonProps = (action: ActionItem) => {
const res = {

View File

@@ -1,6 +1,13 @@
import { ButtonProps } from 'ant-design-vue/es/button/buttonTypes';
import { TooltipProps } from 'ant-design-vue/es/tooltip/Tooltip';
/**
* CardActions 玻璃按钮尺寸
* - xs 超级小 / sm 小 / md 中 / lg 大:控制高度字号
* - half 沾满一半 / full 沾满整行:控制整行占 footer 宽度
*/
export type CardActionsSize = 'full' | 'half' | 'lg' | 'md' | 'sm' | 'xs';
export interface PopConfirm {
title: string;
okText?: string;

View File

@@ -0,0 +1,113 @@
import type { Ref } from 'vue';
import type { VbenFormProps } from '#/adapter/form';
import type { ViewMode } from '#/components/view-mode-switch';
import { nextTick, ref } from 'vue';
import { isEqual } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
export interface UseDualViewSearchOptions {
/** 原 grid 内嵌搜索表单配置(schema / submitOnChange 等原样复用) */
formOptions: VbenFormProps;
/** 当前视图模式(useViewMode 返回的 ref) */
viewMode: Ref<ViewMode>;
/**
* 列表视图下提交/重置后的刷新动作(一般是 gridApi.reload())
* 卡片视图不需要传:searchValues 变化后 GlassCardList 会 watch 自动回第 1 页重拉
*/
onListSearch: () => void;
}
/**
* 从 schema 提取 defaultValue 作为首屏搜索条件
* 为什么:卡片视图首屏不经过表单提交,若不预置默认值(如「待到账」状态),
* 卡片与列表首屏的查询口径会不一致
*/
function collectSchemaDefaults(formOptions: VbenFormProps) {
const values: Record<string, any> = {};
for (const item of formOptions.schema ?? []) {
if (item.fieldName && item.defaultValue !== undefined) {
values[item.fieldName] = item.defaultValue;
}
}
return values;
}
/**
* 「列表/卡片」双视图共用的页面级搜索表单
*
* 为什么封装:把 grid 内嵌搜索表单抽到页面顶部后,两种视图要共用一份条件,
* 每个页面都要写「读表单现值 → 列表 reload / 卡片靠 watch 拉数 → 重置兼容 submitOnChange」这套逻辑;
* 财务公账(日汇总/订单明细两套表单)、我的开票等多页复用,避免复制粘贴。
*
* 用法:
* const { SearchForm, searchValues, readLiveSearchValues } = useDualViewSearch({
* formOptions, viewMode, onListSearch: () => gridApi.reload(),
* });
* grid query 里:searchValues.value = await readLiveSearchValues(); 再合并进请求参数
* 卡片:<GlassCardList :form-values="searchValues" />
*/
export function useDualViewSearch(options: UseDualViewSearchOptions) {
const { formOptions, viewMode, onListSearch } = options;
/** 列表与卡片共用的搜索条件(提交/重置时整体替换,卡片靠 watch 自动拉数) */
const searchValues = ref<Record<string, any>>(
collectSchemaDefaults(formOptions),
);
/**
* 读搜索表单现值(不只依赖 handleSubmit 快照)
* 为什么:列表翻页/刷新按钮触发的 query 不经过表单提交,必须自己取最新表单值,
* 否则会丢掉用户刚改但没点查询的条件;表单不可用时回退上次提交的条件
*/
async function readLiveSearchValues(): Promise<Record<string, any>> {
try {
await nextTick();
const live = await searchFormApi.getValues();
return { ...live };
} catch {
return { ...searchValues.value };
}
}
/** 提交:同步共享条件;列表模式手动 reload,卡片模式由 formValues watch 驱动 */
async function applySearch() {
searchValues.value = await readLiveSearchValues();
if (viewMode.value === 'list') {
onListSearch();
}
}
const [SearchForm, searchFormApi] = useVbenForm({
// 与 grid 内嵌表单的默认外观保持一致(紧凑、多列、控件撑满格子),页面配置可覆盖
compact: true,
commonConfig: {
componentProps: {
class: 'w-full',
},
},
showCollapseButton: true,
submitButtonOptions: { content: '查询' },
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
...formOptions,
handleSubmit: applySearch,
/**
* 重置:对齐 vben grid 内嵌表单行为——
* submitOnChange 的表单重置后值变化会自动触发一次提交,这里只在值没变或非 submitOnChange 时手动刷新,避免重复请求
*/
handleReset: async () => {
const prevValues = await searchFormApi.getValues();
await searchFormApi.reset();
const nextValues = await searchFormApi.getValues();
if (isEqual(prevValues, nextValues) || !formOptions.submitOnChange) {
await applySearch();
}
},
});
return { SearchForm, searchFormApi, searchValues, readLiveSearchValues };
}

View File

@@ -41,8 +41,18 @@ export function useFileGalleryFilter(
const activeFolderId = ref<number>(ALL_FOLDER_VALUE);
const sidebarTab = ref<FileGallerySidebarTab>('group');
const keyword = ref('');
/** 只看无引用(管理页用,选择器不传) */
const unrefOnly = ref(false);
/**
* 引用筛选(管理页用,选择器不传)
* all=全部 referenced=有引用 unreferenced=未引用
*/
const refFilter = ref<'all' | 'referenced' | 'unreferenced'>('all');
/** @deprecated 兼容旧调用,等价 refFilter===unreferenced */
const unrefOnly = computed({
get: () => refFilter.value === 'unreferenced',
set: (v: boolean) => {
refFilter.value = v ? 'unreferenced' : 'all';
},
});
const typesLoading = ref(false);
const { groupOptions, loadGroupOptions } = useFileGroupCache();
@@ -111,9 +121,14 @@ export function useFileGalleryFilter(
}
}
/** 管理页「只看无引用」,选择器不要开 */
/** 管理页引用筛选:all|referenced|unreferenced */
function setRefFilter(value: 'all' | 'referenced' | 'unreferenced') {
refFilter.value = value;
}
/** @deprecated 请用 setRefFilter;保留给旧 Switch */
function setUnrefOnly(value: boolean) {
unrefOnly.value = value;
setRefFilter(value ? 'unreferenced' : 'all');
}
function buildListParams(page: number, pageSize: number) {
@@ -124,6 +139,7 @@ export function useFileGalleryFilter(
page: number;
page_size: number;
pid: number;
ref_filter?: string;
type?: number;
unfiled?: number;
unref_only?: number;
@@ -154,8 +170,8 @@ export function useFileGalleryFilter(
params.keyword = kw;
}
if (unrefOnly.value) {
params.unref_only = 1;
if (refFilter.value !== 'all') {
params.ref_filter = refFilter.value;
}
return params;
@@ -187,6 +203,7 @@ export function useFileGalleryFilter(
activeFolderId,
sidebarTab,
keyword,
refFilter,
unrefOnly,
typesLoading,
tabs,
@@ -196,6 +213,7 @@ export function useFileGalleryFilter(
setActiveGroupId,
setActiveFolderId,
setSidebarTab,
setRefFilter,
setUnrefOnly,
buildListParams,
loadFileTypes,

View File

@@ -7,15 +7,15 @@ import type { NotificationItem } from '@vben/layouts';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { AuthenticationLoginExpiredModal, VbenIcon } from '@vben/common-ui';
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
import { useWatermark } from '@vben/hooks';
import { $t } from '@vben/locales';
import {
BasicLayout,
LockScreen,
Notification,
UserDropdown,
} from '@vben/layouts';
import { $t } from '@vben/locales';
import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
@@ -32,12 +32,12 @@ import { useAuthStore } from '#/store';
import { desktop, isElectron } from '#/util/desktop';
import { formatTimeToRelative } from '#/util/tool';
import LoginForm from '#/views/_core/authentication/login.vue';
import NoticeActionHost from '#/views/notice/action/NoticeActionHost.vue';
import {
emitNoticeRefresh,
onNoticeRefresh,
openNoticeAction,
} from '#/views/notice/action/bus';
import NoticeActionHost from '#/views/notice/action/NoticeActionHost.vue';
import { resolveNoticeColor } from '#/views/notice/action/types';
import {
getNoticeListApi,
@@ -45,6 +45,7 @@ import {
readAllApi,
readApi,
} from '#/views/notice/api';
import NoticeCanvasIcon from '#/views/notice/canvas-icon/NoticeCanvasIcon.vue';
const router = useRouter();
const switchAccountModalRef = ref<InstanceType<typeof SwitchAccountModal>>();
@@ -241,7 +242,7 @@ const { isDark } = usePreferences();
const showDot = computed(() => unreadCount.value > 0);
const showDoctorTransfer = computed(() => {
const info = userStore.userInfo as Record<string, any> | null | undefined;
const info = userStore.userInfo as null | Record<string, any> | undefined;
if (!info) return false;
return !!(info.doctor_id && info.store_id);
});
@@ -356,17 +357,13 @@ watch(
v-if="!item.isRead"
class="absolute top-2 right-2 size-2 rounded-sm bg-primary"
></span>
<span
class="relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-xl"
:class="[(item as any).colorBg || 'bg-muted', (item as any).color]"
>
<VbenIcon
v-if="(item as any).type_icon"
:icon="(item as any).type_icon"
class="size-5"
/>
<Bell v-else class="size-5" />
</span>
<!-- 按消息类型绘制的 Canvas 动态图标;铃铛里都是未读,已读后才定格 -->
<NoticeCanvasIcon
:type-code="(item as any).type_code"
:color="(item as any).type_color"
:size="40"
:animate="!item.isRead"
/>
<div class="min-w-0 flex-1 pr-8 leading-none">
<div class="mb-1 flex items-center gap-2">
<span

View File

@@ -0,0 +1,15 @@
/**
* 地址粘贴识别字典 API(xk-api)
* 姓氏表来自后端 config/xk.php → address.common_surnames
*/
import { requestClient } from '#/api/request';
const prefix = 'address-dict/';
/**
* 拉取姓氏表(复姓在前)
* requestClient 已解包 result,直接得到 { surnames: string[] }
*/
export async function getAddressSurnames() {
return requestClient.get<{ surnames: string[] }>(`${prefix}surnames`);
}

View File

@@ -1,11 +1,14 @@
import type {
RegionPickerChild,
RegionPickerSearchHit,
} from '#/api/region-picker';
import { ref } from 'vue';
import {
getRegionChildren,
resolveRegionPair,
searchRegions,
type RegionPickerChild,
type RegionPickerSearchHit,
} from '#/api/region-picker';
export interface AddressNode {
@@ -28,7 +31,7 @@ export interface ResolvedAddress {
export type AddressValue = [number, number];
export type RegionLevel = 'province' | 'city' | 'district';
export type RegionLevel = 'city' | 'district' | 'province';
export interface BreadcrumbItem {
level: RegionLevel;
@@ -47,7 +50,7 @@ export interface AddressSearchHit {
node: AddressNode;
pathLabel: string;
complete: boolean;
path?: { id: number; name: string; level: number }[];
path?: { id: number; level: number; name: string; }[];
}
export interface AddressListRow {
@@ -74,11 +77,11 @@ function levelFromNumber(level: number): RegionLevel {
}
function toNode(row: {
has_children?: boolean;
id: number;
level?: number;
name: string;
pid?: number;
level?: number;
has_children?: boolean;
}): AddressNode {
return {
value: row.id,
@@ -201,7 +204,9 @@ export function formatAddressDisplay(value?: AddressValue | null): string {
return '';
}
export function formatBreadcrumbItems(resolved: ResolvedAddress): BreadcrumbItem[] {
export function formatBreadcrumbItems(
resolved: ResolvedAddress,
): BreadcrumbItem[] {
const items: BreadcrumbItem[] = [];
if (resolved.province) {
items.push({
@@ -229,7 +234,7 @@ export function formatBreadcrumbItems(resolved: ResolvedAddress): BreadcrumbItem
export function buildBreadcrumbFromContext(
ctx: SelectionContext,
labels?: { province?: string; city?: string; district?: string },
labels?: { city?: string; district?: string; province?: string; },
): BreadcrumbItem[] {
const items: BreadcrumbItem[] = [];
if (ctx.provinceId) {
@@ -415,3 +420,77 @@ export function getLevelSearchPlaceholder(level: RegionLevel): string {
export function isMunicipalityProvince(_provinceId?: number): boolean {
return false;
}
/**
* 从中文省市区解析为选择器值 [provinceId, leafId]
* 为什么:region-picker/search 是对单条 name LIKE,整串「浙江省杭州市西湖区」搜不到;
* 必须用区/市/省逐级短词搜索,再按 path_label 与识别结果对齐打分取最优
*/
export async function resolveAddressPairFromNames(
province?: string,
city?: string,
area?: string,
): Promise<AddressValue | undefined> {
const p = String(province || '').trim();
const c = String(city || '').trim();
const a = String(area || '').trim();
if (!p && !c && !a) {
return undefined;
}
// 优先搜索最细一级,命中率最高
const keywords = [a, c, p].filter(Boolean);
let bestHit: AddressSearchHit | null = null;
let bestScore = -1;
for (const kw of keywords) {
const hits = await searchAddressHits(kw);
for (const hit of hits) {
const score = scoreRegionHit(hit, p, c, a);
if (score > bestScore) {
bestScore = score;
bestHit = hit;
}
}
// 已有高分完整命中可提前结束
if (bestHit && bestScore >= 6 && bestHit.complete) {
break;
}
}
if (!bestHit || bestScore <= 0) {
return undefined;
}
const ctx = applySearchHit(bestHit, {});
return buildAddressOutput(ctx.provinceId, ctx.cityId, ctx.districtId);
}
/** 命中项与识别省市区的对齐分:path 含省/市/区、complete、叶子名精确匹配加分 */
function scoreRegionHit(
hit: AddressSearchHit,
province: string,
city: string,
area: string,
): number {
const label = `${hit.pathLabel || ''} ${hit.node?.label || ''}`;
let score = 0;
if (area) {
if (hit.node?.label === area || label.includes(area)) score += 4;
else if (label.includes(area.replace(/(区|县|市|旗)$/, ''))) score += 2;
}
if (city) {
if (label.includes(city)) score += 3;
else if (label.includes(city.replace(/市$/, ''))) score += 2;
}
if (province) {
if (label.includes(province)) score += 3;
else if (
label.includes(
province
.replaceAll(/特别行政区|自治区|壮族|回族|维吾尔/g, '')
.replace(/(省|市)$/, ''),
)
) {
score += 2;
}
}
if (hit.complete) score += 1;
return score;
}

View File

@@ -0,0 +1,34 @@
/** address-recognizer.js 的类型声明,供 TS 引用 */
export const ADDRESS_PASTE_EXAMPLES: string[];
export function confidenceMark(score: number): string;
export function setCommonSurnames(list: string | string[]): void;
export function ensureSurnamesLoaded(
fetcher?: () => Promise<
null | string[] | undefined | { surnames?: string[] }
>,
): Promise<void>;
export function recognizeAddress(text: string): {
area: string;
city: string;
confidence: {
detail: number;
name: number;
phone: number;
region: number;
};
detail: string;
name: string;
phone: string;
province: string;
};
export class AddressRecognizer {
constructor(tree?: unknown);
recognize(raw: string): ReturnType<typeof recognizeAddress>;
}
export const addressRecognizer: AddressRecognizer;

View File

@@ -0,0 +1,843 @@
/**
* 收货地址粘贴识别引擎(本地纯文本解析)
* 从乱序/多分隔符文本中提取:姓名、电话、省市区、详细地址,并给出 0~1 置信度
* 省市区依赖 region-names.json 名称树打分匹配;识别失败字段留空,不抛错
*/
import regionTree from './region-names.json';
/** 示例文本:点击后填入并触发识别,覆盖乱序/关键词/直辖市/电商多行等场景 */
export const ADDRESS_PASTE_EXAMPLES = [
'张三 13800138000 浙江省杭州市西湖区文三路100号',
'收货人:李四 电话:15912345678 北京市朝阳区建国路88号SOHO现代城A座1201',
'王五,广东省深圳市南山区科技园南路,联系电话13812345678',
'收件人赵六 上海市浦东新区张江高科技园区碧波路690号 021-50801000',
'杭州西湖区文二西路1号 陈七 18600001111',
'欧阳娜娜 138 0013 8000 浙江省杭州市西湖区文三路100号 310000',
'收货人:司马懿\n手机号码:+86-159-1234-5678转1234\n所在地区:北京市朝阳区\n详细地址:建国路88号SOHO现代城A座1201',
'收件人:诸葛孔明\n电话:18600001111\n地址:广东省深圳市南山区科技园南路1号',
];
/**
* 离线兜底姓氏(接口失败时仍可识别常见姓名)
* 接口成功后由 setCommonSurnames 替换为后端 config 全量表
*/
const FALLBACK_SURNAMES_STR =
'赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜戚谢邹喻柏水窦章云苏潘葛奚范彭郎鲁韦昌马苗凤花方俞任袁柳酆鲍史唐费廉岑薛雷贺倪汤滕殷罗毕郝邬安常乐于时傅皮卞齐康伍余元卜顾孟平黄和穆萧尹姚邵湛汪祁毛禹狄米贝明臧计伏成戴谈宋茅庞熊纪舒屈项祝董梁杜阮蓝闵席季麻强贾路娄危江童颜郭梅盛林刁钟徐邱骆高夏蔡田樊胡凌霍虞万支柯昝管卢莫经房裘缪干解应宗丁宣贲邓郁单杭洪包诸左石崔吉钮龚程嵇邢滑裴陆荣翁荀羊於惠甄曲家封芮羿储靳汲邴糜松井段富巫乌焦巴弓牧隗山谷车侯宓蓬全郗班仰秋仲伊宫宁仇栾暴甘钭厉戎祖武符刘景詹束龙叶幸司韶郜黎蓟薄印宿白怀蒲邰从鄂索咸籍赖卓蔺屠蒙池乔阴鬱胥能苍双闻莘党翟谭贡劳逄姬申扶堵冉宰郦雍郤璩桑桂濮牛寿通边扈燕冀郏浦尚农温别庄晏柴瞿阎充慕连茹习宦艾鱼容向古易慎戈廖庾终暨居衡步都耿满弘匡国文寇广禄阙东欧殳沃利蔚越夔隆师巩厍聂晁勾敖融冷訾辛阚那简饶空曾毋沙乜养鞠须丰巢关蒯相查后荆红游竺权逯盖益桓公万俟司马上官欧阳夏侯诸葛闻人东方赫连皇甫尉迟公羊澹台公冶宗政濮阳淳于单于太叔申屠公孙仲孙轩辕令狐钟离宇文长孙慕容鲜于闾丘司徒司空亓官司寇仉督子车颛孙端木巫马公西漆雕乐正壤驷公良拓跋夹谷宰父谷梁晋楚闫法汝鄢涂钦段干百里东郭南门呼延归海羊舌微生岳帅缑亢况後有琴梁丘左丘东门西门商牟佘佴伯赏南宫墨哈谯笪年爱阳佟';
/**
* 将姓氏源整理为「字数降序」数组,供前缀匹配先命中复姓
* @param {string|string[]} source
* @returns {string[]}
*/
function buildSurnameList(source) {
const seen = {};
const list = [];
const pushOne = (s) => {
const t = String(s || '').trim();
if (!t || seen[t]) return;
seen[t] = true;
list.push(t);
};
if (Array.isArray(source)) {
for (let i = 0; i < source.length; i++) pushOne(source[i]);
} else {
const str = String(source || '');
// 复姓片段(2~4 字)优先从连续汉字串中按已知复姓切出较难,兜底串用单字+尾部复姓字面
const compounds = [
'万俟',
'司马',
'上官',
'欧阳',
'夏侯',
'诸葛',
'闻人',
'东方',
'赫连',
'皇甫',
'尉迟',
'公羊',
'澹台',
'公冶',
'宗政',
'濮阳',
'淳于',
'单于',
'太叔',
'申屠',
'公孙',
'仲孙',
'轩辕',
'令狐',
'钟离',
'宇文',
'长孙',
'慕容',
'鲜于',
'闾丘',
'司徒',
'司空',
'亓官',
'司寇',
'仉督',
'子车',
'颛孙',
'端木',
'巫马',
'公西',
'漆雕',
'乐正',
'壤驷',
'公良',
'拓跋',
'夹谷',
'宰父',
'谷梁',
'晋楚',
'闫法',
'汝鄢',
'涂钦',
'段干',
'百里',
'东郭',
'南门',
'呼延',
'归海',
'羊舌',
'微生',
'岳帅',
'缑亢',
'况后',
'况後',
'有琴',
'梁丘',
'左丘',
'东门',
'西门',
'商牟',
'佘佴',
'伯赏',
'南宫',
'墨哈',
'谯笪',
'年爱',
'阳佟',
];
for (let i = 0; i < compounds.length; i++) {
if (str.indexOf(compounds[i]) >= 0) pushOne(compounds[i]);
}
for (let i = 0; i < str.length; i++) {
const ch = str.charAt(i);
if (/[\u4E00-\u9FA5]/.test(ch)) pushOne(ch);
}
}
list.sort((a, b) => b.length - a.length || 0);
return list;
}
/** 当前生效姓氏表(字数降序);可被后端接口覆盖 */
let surnameList = buildSurnameList(FALLBACK_SURNAMES_STR);
/** ensureSurnamesLoaded 的会话内 Promise 缓存 */
let surnamesLoadPromise = null;
let surnamesLoadedFromRemote = false;
/**
* 注入姓氏表(后端 address-dict/surnames 返回的数组)
* @param {string[]|string} list
*/
export function setCommonSurnames(list) {
if (!list || (Array.isArray(list) && list.length === 0)) return;
surnameList = buildSurnameList(list);
surnamesLoadedFromRemote = true;
}
/**
* 首次识别前拉取姓氏;同会话只请求一次;失败则保留兜底表
* @param {() => Promise<{ surnames?: string[] }|string[]|null|undefined>} fetcher
* @returns {Promise<void>}
*/
export function ensureSurnamesLoaded(fetcher) {
if (surnamesLoadedFromRemote) return Promise.resolve();
if (!fetcher || typeof fetcher !== 'function') return Promise.resolve();
if (surnamesLoadPromise) return surnamesLoadPromise;
surnamesLoadPromise = Promise.resolve()
.then(() => fetcher())
.then((res) => {
const list = Array.isArray(res)
? res
: res && Array.isArray(res.surnames)
? res.surnames
: null;
if (list && list.length > 0) setCommonSurnames(list);
})
.catch(() => {
// 网络失败静默降级,继续用兜底姓氏
})
.then(() => {});
return surnamesLoadPromise;
}
/**
* 候选姓名是否以已知姓开头(复姓优先)
* @param {string} name
* @returns {boolean}
*/
function hasKnownSurname(name) {
const n = String(name || '');
for (let i = 0; i < surnameList.length; i++) {
const s = surnameList[i];
if (s && n.indexOf(s) === 0) return true;
}
return false;
}
/** 排除的地址/机构关键词(含易被误判为姓名的地名碎片) */
const NAME_BLOCK_WORDS = [
'收货人',
'收件人',
'联系人',
'详细地址',
'地址',
'电话',
'手机',
'公司',
'学校',
'小区',
'大厦',
'广场',
'中心',
'医院',
'药店',
'诊所',
'街道',
'科技',
'高科',
'园区',
'步行',
'解放',
'只有',
'姓名',
'省',
'市',
'区',
'县',
'路',
'号',
'栋',
'楼',
'单元',
'室',
'村',
'镇',
'乡',
'巷',
'弄',
];
const KEYWORD_STRIP = [
'收货人',
'收件人',
'联系人',
'收货',
'收件',
'联系电话',
'联系方式',
'手机号码',
'手机号',
'手机',
'电话',
'详细地址',
'地址',
'姓名',
'收',
];
/**
* 置信度标记:≥0.8 ✓ / 0.5~0.8 ? / <0.5 ✗
* @param {number} score
* @returns {string}
*/
export function confidenceMark(score) {
const s = Number(score) || 0;
if (s >= 0.8) return '✓';
if (s >= 0.5) return '?';
return '✗';
}
/**
* 空识别结果
* @returns {object}
*/
function emptyResult() {
return {
name: '',
phone: '',
province: '',
city: '',
area: '',
detail: '',
confidence: { name: 0, phone: 0, region: 0, detail: 0 },
};
}
/**
* 生成名称简称(去行政区后缀)
* @param {string} name
* @returns {string}
*/
function toShort(name) {
return String(name || '')
.replaceAll(/特别行政区|自治区|壮族|回族|维吾尔/g, '')
.replaceAll(/(省|市|区|县|旗|州|盟)$/g, '');
}
/**
* AddressRecognizer:一次性解析粘贴文本
*/
export class AddressRecognizer {
constructor(tree) {
this.tree = Array.isArray(tree) && tree.length > 0 ? tree : regionTree;
this._index = null;
}
/**
* 懒构建省市区索引,加速全称/简称查找
* @returns {{ provinces: Array, byShort: Map }}
*/
_ensureIndex() {
if (this._index) return this._index;
const provinces = [];
for (let i = 0; i < this.tree.length; i++) {
const p = this.tree[i];
const pShort = p.short || toShort(p.name);
const cities = [];
const children = p.children || [];
// 直辖市:子节点直接是区县,虚拟一层「市辖区」式城市为空,区挂在 province 下
const looksLikeDistrict =
children.length > 0 &&
children.every((c) => {
const n = c.name || '';
return (
/[区县市旗]$/.test(n) && !(c.children && c.children.length > 0)
);
});
if (looksLikeDistrict) {
cities.push({
name: p.name,
short: pShort,
isMunicipality: true,
districts: children.map((d) => ({
name: d.name,
short: d.short || toShort(d.name),
})),
});
} else {
for (let j = 0; j < children.length; j++) {
const c = children[j];
const districts = (c.children || []).map((d) => ({
name: d.name,
short: d.short || toShort(d.name),
}));
cities.push({
name: c.name,
short: c.short || toShort(c.name),
isMunicipality: false,
districts,
});
}
}
provinces.push({ name: p.name, short: pShort, cities });
}
this._index = { provinces };
return this._index;
}
/**
* 区级:全称、简称、去区/县后再试
*/
_findDistrictHit(text, full, short) {
const hit = this._findNameHit(text, full, short);
if (hit) return hit;
const stripped = String(full || '').replaceAll(/(区|县|市|旗)$/g, '');
if (stripped && stripped.length >= 2 && text.indexOf(stripped) >= 0) {
return { kind: 'stripped', len: stripped.length };
}
return null;
}
/**
* 在文本中查找全称或简称命中
* @returns {{ kind: string, len: number }|null}
*/
_findNameHit(text, full, short) {
if (full && text.indexOf(full) >= 0) {
return { kind: 'full', len: full.length };
}
if (short && short.length >= 2 && text.indexOf(short) >= 0) {
return { kind: 'short', len: short.length };
}
return null;
}
_nameAfterPhone(text, phoneInfo) {
if (!phoneInfo || !phoneInfo.value) return null;
const raw = phoneInfo.raw || phoneInfo.value;
const idx = text.indexOf(raw);
if (idx === -1) return null;
const after = text.slice(idx + raw.length, idx + raw.length + 16);
const m = after.match(/^\s*[,,、]?\s*([\u4E00-\u9FA5]{2,6})/);
if (!m) return null;
const chunk = m[1];
for (let len = 2; len <= chunk.length; len++) {
const cand = chunk.slice(0, len);
if (this.isValidName(cand)) {
return { value: cand, confidence: len <= 3 ? 0.8 : 0.7 };
}
}
return null;
}
/**
* 从手机号前取名:短到长尝试,避免「张三杭州」整段误判;支持复姓最长 6 字
*/
_nameBeforePhone(text, phoneInfo) {
if (!phoneInfo || !phoneInfo.value) return null;
const idx = text.indexOf(phoneInfo.raw || phoneInfo.value);
if (idx <= 0) return null;
const before = text.slice(Math.max(0, idx - 16), idx);
const m = before.match(/([\u4E00-\u9FA5]{2,6})\s*$/);
if (!m) return null;
const chunk = m[1];
for (let len = 2; len <= chunk.length; len++) {
const cand = chunk.slice(chunk.length - len);
if (this.isValidName(cand)) {
return { value: cand, confidence: len <= 3 ? 0.85 : 0.75 };
}
}
return null;
}
_nameBeforePhoneKeyword(text) {
const re =
/([\u4E00-\u9FA5]{2,6})\s*(?:电话|手机|联系电话|联系方式|手机号)/;
const m = text.match(re);
if (m) return { value: m[1], confidence: 0.78 };
return null;
}
_nameByKeyword(text) {
// 支持「收件人:张三」「收件人张三」「收 李四」等;长度放宽到 6 以兼容复姓+名
const re =
/(?:收货人|联系人|收件人|姓名)\s*[::\s]?\s*([\u4E00-\u9FA5]{2,6})|(?:^|[,,、\s])收\s*[::\s]?\s*([\u4E00-\u9FA5]{2,6})/;
const m = text.match(re);
if (m) {
const val = m[1] || m[2];
if (val) return { value: val, confidence: 0.92 };
}
return null;
}
_nameFallback(text, phoneInfo) {
let rest = text;
if (phoneInfo && phoneInfo.raw) {
rest = rest.split(phoneInfo.raw).join(' ');
}
if (phoneInfo && phoneInfo.value) {
rest = rest.split(phoneInfo.value).join(' ');
}
rest = rest.replaceAll(/[0-9+\-()\s]/g, ' ');
const parts = rest.match(/[\u4E00-\u9FA5]{2,6}/g) || [];
for (let i = 0; i < parts.length; i++) {
if (this.isValidName(parts[i])) {
return { value: parts[i], confidence: 0.55 };
}
}
return null;
}
/**
* 权重:省 3、市 2、区 2;全称 +0.5,简称按长度
*/
_regionScore(pHit, cHit, dHit) {
let score = 0;
if (pHit) {
score += 3;
if (pHit.kind === 'full') score += 0.5;
}
if (cHit && cHit.kind !== 'muni') {
score += 2;
if (cHit.kind === 'full') score += 0.4;
}
if (cHit && cHit.kind === 'muni') {
score += 0.5;
}
if (dHit) {
score += 2;
if (dHit.kind === 'full') score += 0.4;
else if (dHit.kind === 'stripped') score += 0.1;
}
return score;
}
/**
* 详细地址:剥离已识别字段、邮编、虚拟号分机与关键词后清理
*/
extractDetail(text, parts) {
let rest = String(text || '');
const removeList = [
parts.phoneRaw || parts.phone,
parts.phone,
parts.name,
parts.province,
parts.city,
parts.area,
].filter(Boolean);
// 也去掉简称形式
if (parts.province) removeList.push(toShort(parts.province));
if (parts.city) removeList.push(toShort(parts.city));
if (parts.area) {
removeList.push(
toShort(parts.area),
String(parts.area).replace(/(区|县)$/g, ''),
);
}
for (let i = 0; i < removeList.length; i++) {
const piece = removeList[i];
if (piece) rest = rest.split(piece).join(' ');
}
for (let i = 0; i < KEYWORD_STRIP.length; i++) {
rest = rest.split(KEYWORD_STRIP[i]).join(' ');
}
// 邮编:独立 6 位数字(前后非数字)
rest = rest.replaceAll(/(^|[^\d])\d{6}(?=[^\d]|$)/g, '$1');
// 虚拟号/转分机残留
if (parts.ext) {
rest = rest.split(`转${parts.ext}`).join(' ');
rest = rest.split(String(parts.ext)).join(' ');
}
rest = rest.replaceAll(/转\s*\d{3,6}/g, ' ');
rest = rest
.replaceAll(/[::,,、;;。.!!??\-_/\\|]+/g, ' ')
.replaceAll(/\s+/g, ' ')
.trim();
// 智能截断:去掉开头残留的短地名/无意义碎片
rest = rest.replace(/^[\u4E00-\u9FA5]{1}\s+/, '');
rest = rest.replaceAll(/^(只有|以及|还有|和)\s*/g, '');
if (!rest || rest.length < 2) {
return { value: '', confidence: 0 };
}
// 纯语气词不算详细地址
if (/^(只有|姓名|地址)$/.test(rest)) {
return { value: '', confidence: 0 };
}
// 含路/街/号等特征则置信度更高
let conf = 0.6;
if (/[路街巷道号弄苑园村镇乡楼栋单元室]/.test(rest)) conf = 0.85;
else if (rest.length >= 6) conf = 0.7;
return { value: rest, confidence: conf };
}
/**
* 姓名 5 策略按优先级
* @param {string} text
* @param {{ value: string }} phoneInfo
*/
extractName(text, phoneInfo) {
const strategies = [
() => this._nameByKeyword(text),
() => this._nameBeforePhone(text, phoneInfo),
() => this._nameAfterPhone(text, phoneInfo),
() => this._nameBeforePhoneKeyword(text),
() => this._nameFallback(text, phoneInfo),
];
for (let i = 0; i < strategies.length; i++) {
const hit = strategies[i]();
if (hit && hit.value && this.isValidName(hit.value)) {
return {
value: hit.value,
confidence: hit.confidence || Math.max(0.55, 0.95 - i * 0.1),
};
}
}
return { value: '', confidence: 0 };
}
/**
* 按优先级提取电话:手机(含空格/横线/+86)> 座机 > 400/800
* value 输出连续 11 位;raw 保留原文片段便于从 detail 剔除;ext 为转分机
* @param {string} text
* @returns {{ value: string, confidence: number, raw: string, ext: string }}
*/
extractPhone(text) {
const src = String(text || '');
// 宽松匹配带空格/横线的手机,再规范化
const looseMobile = src.match(
/(?:\+?86[-\s]?)?(1[3-9](?:[-\s]?\d){9})(?:\s*转\s*\d{3,6})?/,
);
if (looseMobile) {
const raw = looseMobile[0];
const digits = raw.replaceAll(/\D/g, '');
let mobile = digits;
if (mobile.indexOf('86') === 0 && mobile.length >= 13) {
mobile = mobile.slice(2);
}
mobile = mobile.slice(0, 11);
const extMatch = raw.match(/转\s*(\d{3,6})/);
const ext = extMatch ? extMatch[1] : '';
if (/^1[3-9]\d{9}$/.test(mobile)) {
return { value: mobile, confidence: 0.95, raw, ext };
}
}
const compact = src.replaceAll(/[\s\-()()]/g, '').replace(/^\+?86/, '');
const compactMobile = compact.match(/1[3-9]\d{9}/);
if (compactMobile) {
const mobile = compactMobile[0];
const idx = compact.indexOf(mobile);
const after = compact.slice(idx + 11, idx + 20);
const extMatch = after.match(/^转(\d{3,6})/);
return {
value: mobile,
confidence: 0.92,
raw: mobile,
ext: extMatch ? extMatch[1] : '',
};
}
const landline = src.match(/0\d{2,3}[-\s]?\d{7,8}/);
if (landline) {
return {
value: landline[0].replaceAll(/\s+/g, '-'),
confidence: 0.85,
raw: landline[0],
ext: '',
};
}
const service = src.match(/[48]00[-\s]?\d{3,4}[-\s]?\d{3,4}/);
if (service) {
return {
value: service[0].replaceAll(/\s+/g, '-'),
confidence: 0.75,
raw: service[0],
ext: '',
};
}
return { value: '', confidence: 0, raw: '', ext: '' };
}
/**
* 省市区三级联动名称树打分匹配,取总分最高
* @param {string} text
* @returns {{ province: string, city: string, area: string, confidence: number }}
*/
extractRegion(text) {
const { provinces } = this._ensureIndex();
let best = { score: 0, province: '', city: '', area: '', hits: 0 };
const compact = text.replaceAll(/\s+/g, '');
for (let i = 0; i < provinces.length; i++) {
const p = provinces[i];
const pHit = this._findNameHit(compact, p.name, p.short);
for (let j = 0; j < p.cities.length; j++) {
const c = p.cities[j];
const cHit = this._findNameHit(
compact,
c.isMunicipality ? '' : c.name,
c.isMunicipality ? '' : c.short,
);
const districts = c.districts || [];
if (districts.length === 0) {
const score = this._regionScore(pHit, cHit, null);
if (score > best.score) {
best = {
score,
province: pHit ? p.name : '',
city: cHit && !c.isMunicipality ? c.name : '',
area: '',
hits: (pHit ? 1 : 0) + (cHit ? 1 : 0),
};
}
continue;
}
for (let k = 0; k < districts.length; k++) {
const d = districts[k];
const dHit = this._findDistrictHit(compact, d.name, d.short);
// 允许省略任一级:至少命中区,或省+市
if (!pHit && !cHit && !dHit) continue;
if (!pHit && !dHit && cHit) {
// 仅市也可能,但需有一定把握
}
const score = this._regionScore(
pHit,
cHit ||
(c.isMunicipality && dHit ? { kind: 'muni', len: 0 } : null),
dHit,
);
if (score > best.score) {
const hitAny = !!(pHit || cHit || dHit);
best = {
score,
province: hitAny ? p.name : '',
city: c.isMunicipality
? ''
: cHit
? c.name
: dHit && pHit
? c.name
: '',
area: dHit ? d.name : '',
hits:
(pHit || hitAny ? 1 : 0) +
(cHit || (c.isMunicipality && dHit) ? 1 : 0) +
(dHit ? 1 : 0),
};
// 直辖市:命中区则省必填、市留空
if (c.isMunicipality && dHit) {
best.province = p.name;
best.city = '';
best.area = d.name;
}
}
}
// 仅省+市(无区)
if (pHit || cHit) {
const score = this._regionScore(pHit, cHit, null);
if (score > best.score && (pHit || cHit)) {
best = {
score,
province: pHit ? p.name : p.name,
city: c.isMunicipality ? '' : cHit ? c.name : '',
area: '',
hits: (pHit ? 1 : 0) + (cHit ? 1 : 0),
};
if (!pHit && cHit && !c.isMunicipality) {
best.province = p.name;
}
}
}
}
// 仅省
if (pHit) {
const score = this._regionScore(pHit, null, null);
if (score > best.score) {
best = {
score,
province: p.name,
city: '',
area: '',
hits: 1,
};
}
}
}
if (best.score <= 0) {
return { province: '', city: '', area: '', confidence: 0 };
}
// 归一化置信度:满分约 3+2+2=7,全称加权更高
let conf = Math.min(0.98, best.score / 7);
if (best.hits >= 3) conf = Math.max(conf, 0.9);
else if (best.hits === 2) conf = Math.max(conf, 0.75);
else conf = Math.max(conf, 0.55);
return {
province: best.province,
city: best.city,
area: best.area,
confidence: conf,
};
}
/**
* 姓名有效性:2~6 字、已知姓(复姓优先前缀匹配)、无地址特征
* @param {string} name
* @returns {boolean}
*/
isValidName(name) {
const n = String(name || '').trim();
if (n.length < 2 || n.length > 6) return false;
if (!/^[\u4E00-\u9FA5]+$/.test(n)) return false;
if (!hasKnownSurname(n)) return false;
for (let i = 0; i < NAME_BLOCK_WORDS.length; i++) {
if (n.indexOf(NAME_BLOCK_WORDS[i]) >= 0) return false;
}
// 含行政区/路号特征则不是姓名(扩展姓表后「杭州」等更易误判)
if (/[路街号栋楼单元室省市县区州盟旗]/.test(n)) return false;
if (/\d/.test(n)) return false;
return true;
}
/**
* 电商多行模板:把「收货人:xxx」等标签行展平成可识别的连续文本
* 无换行/无标签时原样返回,避免破坏乱序单行粘贴
* @param {string} text
* @returns {string}
*/
normalizeEcommerceText(text) {
const src = String(text || '');
if (src.indexOf('\n') < 0 && src.indexOf('\r') < 0) return src;
const labelRe =
/^(?:收货人|收件人|联系人|姓名|手机号码|手机号|手机|联系电话|联系方式|电话|所在地区|详细地址|地址)\s*[::]\s*(.+)$/;
const lines = src.split(/\r?\n/);
const parts = [];
let hitLabel = false;
for (let i = 0; i < lines.length; i++) {
const line = String(lines[i] || '').trim();
if (!line) continue;
const m = line.match(labelRe);
if (m && m[1]) {
hitLabel = true;
parts.push(m[1].trim());
} else {
parts.push(line);
}
}
return hitLabel ? parts.join(' ') : src.replaceAll(/\r?\n/g, ' ');
}
/**
* 主入口:识别整段文本
* 先展开电商多行标签,再按 电话→省市区→姓名→详情 顺序抽取
* @param {string} raw
* @returns {object}
*/
recognize(raw) {
const text = this.normalizeEcommerceText(String(raw || '').trim());
if (!text) return emptyResult();
try {
const phoneInfo = this.extractPhone(text);
const regionInfo = this.extractRegion(text);
const nameInfo = this.extractName(text, phoneInfo);
const detailInfo = this.extractDetail(text, {
phone: phoneInfo.value,
phoneRaw: phoneInfo.raw,
ext: phoneInfo.ext || '',
province: regionInfo.province,
city: regionInfo.city,
area: regionInfo.area,
name: nameInfo.value,
});
return {
name: nameInfo.value || '',
phone: phoneInfo.value || '',
province: regionInfo.province || '',
city: regionInfo.city || '',
area: regionInfo.area || '',
detail: detailInfo.value || '',
confidence: {
name: nameInfo.confidence || 0,
phone: phoneInfo.confidence || 0,
region: regionInfo.confidence || 0,
detail: detailInfo.confidence || 0,
},
};
} catch {
return emptyResult();
}
}
}
/** 默认单例,便于页面直接调用 */
export const addressRecognizer = new AddressRecognizer(regionTree);
/**
* 便捷方法
* @param {string} text
*/
export function recognizeAddress(text) {
return addressRecognizer.recognize(text);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
/**
* 收货地址一键复制文案:{名字} {手机号} {省市区}{详细地址}
*/
import { message } from 'ant-design-vue';
export type ShipAddressLike = {
area?: string;
city?: string;
detail?: string;
detail_address?: string;
express_address?: string;
express_mobile?: string;
express_name?: string;
express_region?: string;
mobile?: string;
name?: string;
phone?: string;
province?: string;
region?: string;
};
/**
* 拼装复制文本;订单场景兼容 address + express_* 双轨
*/
export function formatShipAddressCopyText(
addr?: null | ShipAddressLike,
fallback?: null | ShipAddressLike,
): string {
const a = addr || {};
const f = fallback || {};
const name = String(a.name || f.express_name || f.name || '').trim();
const mobile = String(
a.mobile || a.phone || f.express_mobile || f.mobile || '',
).trim();
let region = String(a.region || '').trim();
if (!region) {
region = [a.province, a.city, a.area].filter(Boolean).join('');
}
if (!region) {
region = String(f.express_region || f.region || '').trim();
}
const detail = String(
a.detail_address || a.detail || f.express_address || f.detail_address || '',
).trim();
const place = region + detail;
return [name, mobile, place].filter(Boolean).join(' ');
}
/** 复制到剪贴板并 toast */
export async function copyShipAddressText(text: string): Promise<boolean> {
const raw = String(text || '').trim();
if (!raw) {
message.warning('暂无地址可复制');
return false;
}
try {
await navigator.clipboard.writeText(raw);
message.success('已复制');
return true;
} catch {
message.error('复制失败');
return false;
}
}

View File

@@ -1,6 +1,7 @@
<script setup>
import { defineEmits, defineProps } from 'vue';
import { useLayoutDensityStore } from '../stores/layoutDensity.ts';
import ChatHeader from './ChatHeader.vue';
import MessageInput from './MessageInput.vue';
import MessageList from './MessageList.vue';
@@ -8,6 +9,7 @@ import MessageList from './MessageList.vue';
// 定义 props 和 emits
const props = defineProps({
// ... existing props
});
const emit = defineEmits([
@@ -17,6 +19,8 @@ const emit = defineEmits([
'transfer-import-success',
]);
const layoutDensityStore = useLayoutDensityStore();
// 开方功能触发函数
const handleOpenPrescription = (registerId) => {
emit('openPrescription', registerId);
@@ -26,27 +30,159 @@ const handleOpenPrescription = (registerId) => {
const handleEndConsultationSuccess = () => {
emit('end-consultation-success');
};
// ... rest of the component
</script>
<template>
<div class="flex h-full flex-col">
<!-- 聊天头部 -->
<!--
chat-layout + 密度 class:用 CSS 变量统一驱动头/气泡/输入区尺寸,
子组件读 var(--chat-*),避免各处硬编码多套尺寸
-->
<div
class="chat-layout flex h-full flex-col"
:class="layoutDensityStore.densityClass"
>
<ChatHeader />
<!-- 消息区域 -->
<MessageList
<MessageList
class="flex-1"
@open-prescription="handleOpenPrescription"
@transfer-import-success="(data) => emit('transfer-import-success', data)"
/>
<!-- 输入区域 -->
<MessageInput
@open-prescription="handleOpenPrescription"
<MessageInput
@open-prescription="handleOpenPrescription"
@end-consultation-success="handleEndConsultationSuccess"
@transfer-import-success="(data) => emit('transfer-import-success', data)"
/>
</div>
</template>
<style scoped>
/**
* 聊天布局密度变量
* 小(sm):紧凑 IM;中(md 默认):可读压缩;大(lg):接近历史宽松尺寸
*/
.chat-layout {
/* 中:默认 */
--chat-header-px: 16px;
--chat-header-py: 8px;
--chat-avatar: 36px;
--chat-name-size: 15px;
--chat-status-size: 12px;
--chat-msg-mb: 10px;
--chat-msg-gap: 10px;
--chat-bubble-pad: 10px;
--chat-text-size: 14px;
--chat-text-leading: 1.5;
--chat-image-max: 240px;
--chat-input-pad: 12px;
--chat-toolbar-pad: 4px;
--chat-fn-btn: 36px;
--chat-fn-font: 15px;
--chat-send-btn: 40px;
--chat-send-font: 15px;
--chat-input-gap: 10px;
--chat-textarea-pad-y: 10px;
--chat-textarea-pad-x: 14px;
--chat-textarea-font: 14px;
--chat-quick-pad-y: 6px;
--chat-quick-pad-x: 10px;
--chat-quick-gap: 6px;
--chat-quick-font: 12px;
--chat-quick-bubble-py: 4px;
--chat-quick-bubble-px: 10px;
--chat-list-pad: 12px;
}
.chat-layout--sm {
--chat-header-px: 12px;
--chat-header-py: 6px;
--chat-avatar: 32px;
--chat-name-size: 14px;
--chat-status-size: 11px;
--chat-msg-mb: 8px;
--chat-msg-gap: 8px;
--chat-bubble-pad: 8px;
--chat-text-size: 13px;
--chat-text-leading: 1.45;
--chat-image-max: 200px;
--chat-input-pad: 8px;
--chat-toolbar-pad: 2px;
--chat-fn-btn: 32px;
--chat-fn-font: 14px;
--chat-send-btn: 36px;
--chat-send-font: 14px;
--chat-input-gap: 8px;
--chat-textarea-pad-y: 8px;
--chat-textarea-pad-x: 12px;
--chat-textarea-font: 13px;
--chat-quick-pad-y: 4px;
--chat-quick-pad-x: 8px;
--chat-quick-gap: 4px;
--chat-quick-font: 12px;
--chat-quick-bubble-py: 3px;
--chat-quick-bubble-px: 8px;
--chat-list-pad: 8px;
}
.chat-layout--md {
/* 与 .chat-layout 默认一致,显式写出便于对照 */
--chat-header-px: 16px;
--chat-header-py: 8px;
--chat-avatar: 36px;
--chat-name-size: 15px;
--chat-status-size: 12px;
--chat-msg-mb: 10px;
--chat-msg-gap: 10px;
--chat-bubble-pad: 10px;
--chat-text-size: 14px;
--chat-text-leading: 1.5;
--chat-image-max: 240px;
--chat-input-pad: 12px;
--chat-toolbar-pad: 4px;
--chat-fn-btn: 36px;
--chat-fn-font: 15px;
--chat-send-btn: 40px;
--chat-send-font: 15px;
--chat-input-gap: 10px;
--chat-textarea-pad-y: 10px;
--chat-textarea-pad-x: 14px;
--chat-textarea-font: 14px;
--chat-quick-pad-y: 6px;
--chat-quick-pad-x: 10px;
--chat-quick-gap: 6px;
--chat-quick-font: 12px;
--chat-quick-bubble-py: 4px;
--chat-quick-bubble-px: 10px;
--chat-list-pad: 12px;
}
.chat-layout--lg {
--chat-header-px: 24px;
--chat-header-py: 16px;
--chat-avatar: 48px;
--chat-name-size: 18px;
--chat-status-size: 14px;
--chat-msg-mb: 16px;
--chat-msg-gap: 12px;
--chat-bubble-pad: 16px;
--chat-text-size: 16px;
--chat-text-leading: 1.625;
--chat-image-max: 400px;
--chat-input-pad: 20px;
--chat-toolbar-pad: 8px;
--chat-fn-btn: 44px;
--chat-fn-font: 18px;
--chat-send-btn: 52px;
--chat-send-font: 18px;
--chat-input-gap: 12px;
--chat-textarea-pad-y: 12px;
--chat-textarea-pad-x: 16px;
--chat-textarea-font: 14px;
--chat-quick-pad-y: 8px;
--chat-quick-pad-x: 12px;
--chat-quick-gap: 8px;
--chat-quick-font: 13px;
--chat-quick-bubble-py: 6px;
--chat-quick-bubble-px: 12px;
--chat-list-pad: 20px;
}
</style>

View File

@@ -1,13 +1,24 @@
<script setup>
import { computed, ref } from 'vue';
import { Avatar } from 'ant-design-vue';
import { Avatar, Select } from 'ant-design-vue';
import { useChatStore } from '../stores/chat.ts';
import {
CHAT_LAYOUT_DENSITY_OPTIONS,
useLayoutDensityStore,
} from '../stores/layoutDensity.ts';
import { useThemeStore } from '../stores/theme.ts';
const chatStore = useChatStore();
const themeStore = useThemeStore();
const layoutDensityStore = useLayoutDensityStore();
/** 布局密度下拉:与 store 双向绑定,默认中 */
const layoutDensity = computed({
get: () => layoutDensityStore.density,
set: (v) => layoutDensityStore.setDensity(v),
});
const isFriendOnline = ref(true);
const showMoreActions = ref(false);
@@ -102,7 +113,9 @@ const viewFriendProfile = () => {
</script>
<template>
<div class="chat-header bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<div
class="chat-header bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
>
<div class="header-content">
<div class="friend-info">
<div
@@ -111,10 +124,10 @@ const viewFriendProfile = () => {
>
<Avatar
v-if="chatStore.currentFriend?.avatar"
:size="48"
:size="layoutDensityStore.avatarSize"
:src="chatStore.currentFriend?.avatar"
/>
<Avatar v-else :size="48">
<Avatar v-else :size="layoutDensityStore.avatarSize">
{{ chatStore.currentFriend?.nick_name.charAt(0) }}
</Avatar>
</div>
@@ -129,7 +142,10 @@ const viewFriendProfile = () => {
<span class="status-text">
{{ isFriendOnline ? '在线' : '离线' }}
</span>
<span v-if="callStatus" class="call-status-text text-blue-500 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/20">
<span
v-if="callStatus"
class="call-status-text text-blue-500 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/20"
>
{{ callStatusText }}
</span>
</p>
@@ -137,44 +153,18 @@ const viewFriendProfile = () => {
</div>
<div class="header-actions">
<!-- &lt;!&ndash; 语音通话按钮 &ndash;&gt;-->
<!-- <button-->
<!-- class="action-btn voice-call-btn"-->
<!-- title="语音通话"-->
<!-- @click="startVoiceCall"-->
<!-- >-->
<!-- <i class="fas fa-phone"></i>-->
<!-- </button>-->
<!-- &lt;!&ndash; 视频通话按钮 &ndash;&gt;-->
<!-- <button-->
<!-- class="action-btn video-call-btn"-->
<!-- title="视频通话"-->
<!-- @click="startVideoCall"-->
<!-- >-->
<!-- <i class="fas fa-video"></i>-->
<!-- </button>-->
<!-- 更多操作 -->
<!-- <button-->
<!-- class="action-btn more-btn"-->
<!-- title="更多操作"-->
<!-- @click="showMoreActions = !showMoreActions"-->
<!-- >-->
<!-- <i class="fas fa-ellipsis-v"></i>-->
<!-- </button>-->
<!-- &lt;!&ndash; 更多操作菜单 &ndash;&gt;-->
<!-- <div v-if="showMoreActions" class="more-actions-menu">-->
<!--&lt;!&ndash; <div class="menu-item" @click="clearChatHistory">&ndash;&gt;-->
<!--&lt;!&ndash; <i class="fas fa-trash"></i>&ndash;&gt;-->
<!--&lt;!&ndash; <span>清空聊天记录</span>&ndash;&gt;-->
<!--&lt;!&ndash; </div>&ndash;&gt;-->
<!-- <div class="menu-item" @click="viewFriendProfile">-->
<!-- <i class="fas fa-user"></i>-->
<!-- <span>查看资料</span>-->
<!-- </div>-->
<!-- </div>-->
<!-- 布局密度:大/中/小,默认中,写入 localStorage -->
<div class="layout-density-select" title="聊天布局大小">
<span class="layout-density-label">布局</span>
<Select
v-model:value="layoutDensity"
:options="CHAT_LAYOUT_DENSITY_OPTIONS"
size="small"
style="width: 64px"
:dropdown-match-select-width="false"
placeholder="中"
/>
</div>
</div>
</div>
</div>
@@ -183,6 +173,7 @@ const viewFriendProfile = () => {
<style scoped>
.chat-header {
position: relative;
padding: var(--chat-header-py, 8px) var(--chat-header-px, 16px);
}
.header-content {
@@ -193,41 +184,57 @@ const viewFriendProfile = () => {
.friend-info {
display: flex;
gap: 10px;
align-items: center;
gap: 12px;
min-width: 0;
}
.friend-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: white;
width: var(--chat-avatar, 36px);
height: var(--chat-avatar, 36px);
font-size: calc(var(--chat-avatar, 36px) * 0.38);
font-weight: bold;
font-size: 18px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
color: white;
border-radius: 50%;
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}
.friend-details {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
min-width: 0;
}
.friend-name {
font-size: 18px;
font-weight: 600;
margin: 0;
font-size: var(--chat-name-size, 15px);
font-weight: 600;
line-height: 1.3;
}
.friend-status {
display: flex;
align-items: center;
gap: 6px;
align-items: center;
margin: 0;
font-size: 14px;
font-size: var(--chat-status-size, 12px);
}
.layout-density-select {
display: flex;
gap: 6px;
align-items: center;
}
.layout-density-label {
font-size: 12px;
color: hsl(var(--muted-foreground, 215 16% 47%));
white-space: nowrap;
}
.status-dot {
@@ -259,107 +266,107 @@ const viewFriendProfile = () => {
/* 添加通话状态样式 */
.call-status-text {
padding: 2px 6px;
margin-left: 8px;
font-size: 13px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
position: relative;
display: flex;
gap: 12px;
align-items: center;
}
.action-btn {
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
width: 40px;
height: 40px;
font-size: 16px;
position: relative;
cursor: pointer;
border: none;
border-radius: 50%;
transition: all 0.3s ease;
}
.voice-call-btn {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
background: linear-gradient(135deg, #10b981, #059669);
box-shadow: 0 4px 12px rgb(16 185 129 / 30%);
}
.voice-call-btn:hover {
box-shadow: 0 6px 16px rgb(16 185 129 / 40%);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.4);
}
.video-call-btn {
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #3b82f6, #2563eb);
box-shadow: 0 4px 12px rgb(59 130 246 / 30%);
}
.video-call-btn:hover {
box-shadow: 0 6px 16px rgb(59 130 246 / 40%);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
}
.more-btn {
background: #f1f5f9;
color: #64748b;
background: #f1f5f9;
border: 1px solid #e2e8f0;
}
.more-btn:hover {
background: #e2e8f0;
color: #475569;
background: #e2e8f0;
}
.dark .more-btn {
background: #374151;
color: #9ca3af;
background: #374151;
border-color: #4b5563;
}
.dark .more-btn:hover {
background: #4b5563;
color: #d1d5db;
background: #4b5563;
}
.more-actions-menu {
position: absolute;
top: 100%;
right: 0;
z-index: 1000;
min-width: 160px;
padding: 8px 0;
margin-top: 8px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
padding: 8px 0;
min-width: 160px;
z-index: 1000;
margin-top: 8px;
box-shadow: 0 10px 30px rgb(0 0 0 / 10%);
}
.dark .more-actions-menu {
background: #374151;
border-color: #4b5563;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
box-shadow: 0 10px 30px rgb(0 0 0 / 30%);
}
.menu-item {
display: flex;
align-items: center;
gap: 12px;
align-items: center;
padding: 12px 16px;
font-size: 14px;
color: #374151;
cursor: pointer;
transition: all 0.2s ease;
color: #374151;
font-size: 14px;
}
.menu-item:hover {
@@ -386,41 +393,22 @@ const viewFriendProfile = () => {
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
transform: scale(1);
}
50% {
transform: scale(1.1);
opacity: 0.7;
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
transform: scale(1);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.chat-header {
@apply px-4 py-3;
}
.friend-avatar {
width: 40px;
height: 40px;
font-size: 16px;
}
.friend-name {
font-size: 16px;
}
.action-btn {
width: 36px;
height: 36px;
font-size: 14px;
}
.header-actions {
gap: 8px;
}

View File

@@ -1,89 +1,57 @@
<template>
<div
class="custom-textarea-container bg-white dark:bg-gray-700 border-2 border-gray-200 dark:border-gray-600"
:class="{ 'focused': isFocused, 'disabled': disabled }"
@drop="handleDrop"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
>
<textarea
ref="textareaRef"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:maxlength="maxlength"
:rows="rows"
class="custom-textarea"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown"
@paste="handlePaste"
></textarea>
<div class="textarea-actions" v-if="clearable || $slots.actions">
<button
v-if="clearable && modelValue && !disabled"
class="clear-btn"
@click="handleClear"
type="button"
>
<i class="fas fa-times"></i>
</button>
<slot name="actions"></slot>
</div>
</div>
</template>
<script setup>
import {ref, nextTick, watch} from 'vue';
import { nextTick, ref, watch } from 'vue';
const props = defineProps({
modelValue: {
type: String,
default: ''
default: '',
},
placeholder: {
type: String,
default: ''
default: '',
},
disabled: {
type: Boolean,
default: false
default: false,
},
readonly: {
type: Boolean,
default: false
default: false,
},
clearable: {
type: Boolean,
default: false
default: false,
},
maxlength: {
type: Number,
default: undefined
default: undefined,
},
rows: {
type: Number,
default: 3
default: 3,
},
autoResize: {
type: Boolean,
default: false
default: false,
},
maxRows: {
type: Number,
default: 6
default: 6,
},
detectPaste: {
type: Boolean,
default: false
}
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'focus', 'blur', 'clear', 'keydown', 'paste-file']);
const emit = defineEmits([
'update:modelValue',
'focus',
'blur',
'clear',
'keydown',
'paste-file',
]);
const textareaRef = ref(null);
const isFocused = ref(false);
@@ -166,11 +134,11 @@ const autoResize = () => {
const textarea = textareaRef.value;
textarea.style.height = 'auto';
const lineHeight = parseInt(getComputedStyle(textarea).lineHeight);
const lineHeight = Number.parseInt(getComputedStyle(textarea).lineHeight);
const maxHeight = lineHeight * props.maxRows;
const scrollHeight = textarea.scrollHeight;
textarea.style.height = Math.min(scrollHeight, maxHeight) + 'px';
textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
});
};
@@ -183,24 +151,66 @@ const blur = () => {
};
// 监听内容变化自动调整高度
watch(() => props.modelValue, () => {
if (props.autoResize) {
autoResize();
}
});
watch(
() => props.modelValue,
() => {
if (props.autoResize) {
autoResize();
}
},
);
defineExpose({
focus,
blur
blur,
});
</script>
<template>
<div
class="custom-textarea-container bg-white dark:bg-gray-700 border-2 border-gray-200 dark:border-gray-600"
:class="{ focused: isFocused, disabled }"
@drop="handleDrop"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
>
<textarea
ref="textareaRef"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:maxlength="maxlength"
:rows="rows"
class="custom-textarea"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown"
@paste="handlePaste"
></textarea>
<div class="textarea-actions" v-if="clearable || $slots.actions">
<button
v-if="clearable && modelValue && !disabled"
class="clear-btn"
@click="handleClear"
type="button"
>
<i class="fas fa-times"></i>
</button>
<slot name="actions"></slot>
</div>
</div>
</template>
<style scoped>
.custom-textarea-container {
position: relative;
overflow: hidden;
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.custom-textarea-container:hover {
@@ -213,17 +223,17 @@ defineExpose({
.custom-textarea-container.focused {
border-color: #4361ee;
box-shadow: 0 0 0 4px rgba(67, 97, 238, 0.1);
box-shadow: 0 0 0 4px rgb(67 97 238 / 10%);
}
.dark .custom-textarea-container.focused {
box-shadow: 0 0 0 4px rgba(67, 97, 238, 0.2);
box-shadow: 0 0 0 4px rgb(67 97 238 / 20%);
}
.custom-textarea-container.disabled {
cursor: not-allowed;
background: #f8fafc;
border-color: #e2e8f0;
cursor: not-allowed;
}
.dark .custom-textarea-container.disabled {
@@ -233,14 +243,14 @@ defineExpose({
.custom-textarea {
width: 100%;
border: none;
outline: none;
background: transparent;
padding: 12px 16px;
font-size: 14px;
padding: var(--chat-textarea-pad-y, 10px) var(--chat-textarea-pad-x, 14px);
font-family: inherit;
font-size: var(--chat-textarea-font, 14px);
line-height: 1.5;
resize: none;
font-family: inherit;
outline: none;
background: transparent;
border: none;
}
.custom-textarea::placeholder {
@@ -252,8 +262,8 @@ defineExpose({
}
.custom-textarea:disabled {
cursor: not-allowed;
color: #94a3b8;
cursor: not-allowed;
}
.dark .custom-textarea:disabled {
@@ -269,16 +279,16 @@ defineExpose({
}
.clear-btn {
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
color: #94a3b8;
cursor: pointer;
background: none;
border: none;
border-radius: 4px;
transition: all 0.2s ease;
}
.clear-btn:hover {

View File

@@ -6,14 +6,35 @@ import { useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
// 勿用 message 作 toast 名:会覆盖 props.message,导致模板 message_type 全失效
import { Avatar, Button, Image, message as antdMessage } from 'ant-design-vue';
import { message as antdMessage, Avatar, Button, Image } from 'ant-design-vue';
import { usePrescriptionStore } from '#/store/prescription';
import { getChatMessageRegisterInfoApi } from '#/views/business/chat/api';
import { useChatStore } from '#/views/business/chat/stores/chat';
import { sendMessage } from '#/views/business/chat/utils/request';
import { getDrugsByRegisterId as getDrugsByRegisterIdPharmacy } from '#/views/doctor/online-consultation/api/index';
import { getDrugsByRegisterId as getDrugsByRegisterIdClinic } from '#/views/doctor/online-consultation-clinic/api/index';
import { getDrugsByRegisterId as getDrugsByRegisterIdPharmacy } from '#/views/doctor/online-consultation/api/index';
const props = defineProps({
message: {
type: Object,
required: true,
},
isSent: {
type: Boolean,
default: false,
},
senderInfo: {
type: Object,
required: true,
},
});
const emit = defineEmits([
'show-user-profile',
'open-prescription',
'transfer-import-success',
]);
// 辅助函数:按需解析消息内容
const getParsedContent = (messageType, messageContent) => {
@@ -21,8 +42,8 @@ const getParsedContent = (messageType, messageContent) => {
if ([4, 9, 10, 11, 12, 13, 14].includes(messageType)) {
try {
return JSON.parse(messageContent);
} catch (e) {
console.error('解析消息内容失败:', e);
} catch (error) {
console.error('解析消息内容失败:', error);
return messageContent;
}
}
@@ -50,42 +71,29 @@ const getMessageTypeName = (messageType) => {
};
return types[messageType] || 'unknown';
};
import { XgVideoPlayer } from '#/components/video-player';
import { getPrescriptionCheckStatusApi } from '#/views/business/order/prescription/api';
import { receptionApi } from '#/views/doctor/doctor-reception/api';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import RefusalOfTreatmentModal from '#/views/doctor/doctor-reception/components/RefusalOfTreatmentModal.vue';
import previewMedia from '../composables/useMediaPreview.ts';
import { useThemeStore } from '../stores/theme.ts';
import { useUserStore as chatUseUserStore } from '../stores/user';
import AudioMessage from './AudioMessage.vue';
import { XgVideoPlayer } from '#/components/video-player';
import PatientExperienceCard from '#/views/doctor/online-consultation/components/PatientExperienceCard.vue';
import FollowUpDrugAssistantCard from './FollowUpDrugAssistantCard.vue';
import ProductCard from '#/views/doctor/online-consultation/components/ProductCard.vue';
import EndConsultationCard from '#/views/doctor/online-consultation/components/EndConsultationCard.vue';
import PatientExperienceCard from '#/views/doctor/online-consultation/components/PatientExperienceCard.vue';
import ProductCard from '#/views/doctor/online-consultation/components/ProductCard.vue';
import TransferPrescriptionMessageCard from '#/views/doctor/online-consultation/components/TransferPrescriptionMessageCard.vue';
import TransferPrescriptionViewDrawer from '#/views/doctor/online-consultation/components/TransferPrescriptionViewDrawer.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
isSent: {
type: Boolean,
default: false,
},
senderInfo: {
type: Object,
required: true,
},
});
const emit = defineEmits(['show-user-profile', 'open-prescription', 'transfer-import-success']);
import previewMedia from '../composables/useMediaPreview.ts';
import { useLayoutDensityStore } from '../stores/layoutDensity.ts';
import { useThemeStore } from '../stores/theme.ts';
import { useUserStore as chatUseUserStore } from '../stores/user';
import AudioMessage from './AudioMessage.vue';
import FollowUpDrugAssistantCard from './FollowUpDrugAssistantCard.vue';
const chatUserStore = chatUseUserStore();
const userStore = useUserStore();
const chatStore = useChatStore();
const themeStore = useThemeStore();
const layoutDensityStore = useLayoutDensityStore();
const route = useRoute();
const prescriptionStore = usePrescriptionStore();
@@ -94,7 +102,9 @@ const isClinicModule = computed(() => {
return route.path.includes('online-consultation-clinic');
});
const getDrugsByRegisterId = computed(() => {
return isClinicModule.value ? getDrugsByRegisterIdClinic : getDrugsByRegisterIdPharmacy;
return isClinicModule.value
? getDrugsByRegisterIdClinic
: getDrugsByRegisterIdPharmacy;
});
// 转接方查看抽屉相关
@@ -154,17 +164,18 @@ const checkPrescriptionStatus = (parsedContent) => {
// 如果 status 不存在,才调用 API(作为备用方案)
if (parsedContent && parsedContent.id) {
getPrescriptionCheckStatusApi({ id: parsedContent.id }).then((res) => {
statusInfo.value.status = res.status;
statusInfo.value.text = prescriptionStatusMap[res.status];
return prescriptionStatusMap[res.status];
});
statusInfo.value.status = res.status;
statusInfo.value.text = prescriptionStatusMap[res.status];
return prescriptionStatusMap[res.status];
});
}
};
// 计算属性:解析后的消息内容
const parsedContent = computed(() => {
// 确保 message_content 字段存在,优先使用 message_content,其次使用 content
const messageContent = props.message.message_content || props.message.content || '';
const messageContent =
props.message.message_content || props.message.content || '';
return getParsedContent(props.message.message_type, messageContent);
});
@@ -208,15 +219,15 @@ const registerCardDrugRows = computed(() => {
const fallbackQty =
pc.number != null && pc.number !== '' ? Number(pc.number) : 1;
const arr = pc.selected_western_drugs;
if (Array.isArray(arr) && arr.length) {
if (Array.isArray(arr) && arr.length > 0) {
return arr.map((d, idx) => {
const q = d?.quantity != null ? Number(d.quantity) : fallbackQty;
const q = d?.quantity == null ? fallbackQty : Number(d.quantity);
return {
key: String(d?.drug_id || d?.id || idx),
name: d?.name || '',
specification: d?.specification || d?.spec || d?.drug_spec || '',
image: d?.image || d?.drug_image || '',
quantity: q >= 1 ? q : 1,
quantity: Math.max(q, 1),
};
});
}
@@ -227,7 +238,7 @@ const registerCardDrugRows = computed(() => {
name: pc.drug.name,
specification: pc.drug.specification || pc.drug.spec || '',
image: pc.drug.image || pc.drug.drug_image || '',
quantity: fallbackQty >= 1 ? fallbackQty : 1,
quantity: Math.max(fallbackQty, 1),
},
];
}
@@ -272,7 +283,10 @@ const handleAddRegisterDrugsToPrescription = async () => {
}
let storeId = prescriptionStore.myStoreId;
if (!storeId || storeId === 0) {
if (prescriptionStore.myStoreList && prescriptionStore.myStoreList.length > 0) {
if (
prescriptionStore.myStoreList &&
prescriptionStore.myStoreList.length > 0
) {
storeId = prescriptionStore.myStoreList[0].id;
} else {
antdMessage.warning('请先选择诊所');
@@ -321,7 +335,7 @@ const canAddFollowUpDrugsToRx = computed(() => {
if (!isFollowUpDrugAssistant.value || props.isSent) return false;
const pc = parsedContent.value;
const drugs = pc && Array.isArray(pc.drugs) ? pc.drugs : [];
if (!drugs.length) return false;
if (drugs.length === 0) return false;
return !!(
prescriptionStore.currentRegisterId ||
chatStore.currentFriend?.register_id ||
@@ -408,7 +422,8 @@ const handleViewTransferDetail = (transferData) => {
// 处理转接方导入成功
const handleTransferImportSuccess = (prescriptionData) => {
// 触发打开处方模态框事件
const registerId = transferRegisterId.value || chatStore.currentFriend?.register_id;
const registerId =
transferRegisterId.value || chatStore.currentFriend?.register_id;
if (registerId) {
emit('open-prescription', registerId);
}
@@ -528,17 +543,16 @@ const formatDateTime = (timestamp) => {
const getSexText = (sex) => {
return sex === 1 ? '男' : '女';
};
</script>
<template>
<div
:class="isSent ? 'flex-row-reverse' : 'flex-row'"
class="mb-4 flex items-start gap-3"
class="message-row flex items-start"
>
<RefusalOfTreatmentModals />
<PrescriptionDetailModal />
<!-- 头像 -->
<!-- 头像:尺寸跟随布局密度 -->
<div
v-if="isSent"
:style="avatarStyle"
@@ -547,10 +561,10 @@ const getSexText = (sex) => {
>
<Avatar
v-if="userStore.userInfo?.avatar"
:size="48"
:size="layoutDensityStore.avatarSize"
:src="userStore.userInfo?.avatar"
/>
<Avatar v-else :size="48">
<Avatar v-else :size="layoutDensityStore.avatarSize">
{{ userStore.userInfo?.nick_name.charAt(0) }}
</Avatar>
</div>
@@ -560,8 +574,16 @@ const getSexText = (sex) => {
class="cursor-pointer rounded-full"
@click="showUserProfile"
>
<Avatar v-if="senderInfo.avatar" :size="48" :src="senderInfo.avatar" />
<Avatar v-else :size="48">{{ senderInfo.nick_name.charAt(0) }}</Avatar>
<Avatar
v-if="senderInfo.avatar"
:size="layoutDensityStore.avatarSize"
:src="senderInfo.avatar"
/>
<Avatar v-else :size="layoutDensityStore.avatarSize">
{{
senderInfo.nick_name.charAt(0)
}}
</Avatar>
</div>
<!-- 消息内容区域 -->
@@ -580,13 +602,15 @@ const getSexText = (sex) => {
{{ senderInfo.nick_name }}
</div>
<!-- 消息气泡 -->
<div :class="bubbleClasses" class="relative rounded-2xl p-4 shadow-sm">
<!-- 消息气泡:padding 由密度变量控制 -->
<div
:class="bubbleClasses"
class="message-bubble-pad relative rounded-2xl shadow-sm"
>
<!-- 图片消息 -->
<div
v-if="message.message_type === 1"
class="image-message relative cursor-pointer"
style="width: 400px;"
@click="handlePreviewMedia(parsedContent, 'image')"
>
<Image
@@ -672,19 +696,24 @@ const getSexText = (sex) => {
:class="{
'bg-yellow-100 text-yellow-800':
registerCardDisplay.status === 0,
'bg-blue-100 text-blue-800': registerCardDisplay.status === 1,
'bg-blue-100 text-blue-800':
registerCardDisplay.status === 1,
'bg-red-100 text-red-800': registerCardDisplay.status === 2,
'bg-red-100 text-red-800': registerCardDisplay.status === 3,
'bg-gray-100 text-gray-800': registerCardDisplay.status === 4,
'bg-gray-100 text-gray-800':
registerCardDisplay.status === 4,
'bg-purple-100 text-purple-800':
registerCardDisplay.status === 5,
'bg-indigo-100 text-indigo-800':
registerCardDisplay.status === 6,
'bg-pink-100 text-pink-800': registerCardDisplay.status === 7,
'bg-pink-100 text-pink-800':
registerCardDisplay.status === 7,
}"
class="rounded-full px-2 py-1 text-xs font-medium"
>
{{ registerStatusMap[registerCardDisplay.status] || '未知状态' }}
{{
registerStatusMap[registerCardDisplay.status] || '未知状态'
}}
</div>
</div>
@@ -715,7 +744,8 @@ const getSexText = (sex) => {
<div
:class="{
'bg-red-100 text-red-800': registerCardDisplay.is_pay === 0,
'bg-green-100 text-green-800': registerCardDisplay.is_pay === 1,
'bg-green-100 text-green-800':
registerCardDisplay.is_pay === 1,
}"
class="rounded-full px-2 py-1 text-xs font-medium"
>
@@ -728,7 +758,9 @@ const getSexText = (sex) => {
>
<div class="flex items-center gap-1">
<i class="fas fa-user text-xs"></i>
<span>{{ registerCardDisplay.user_patient?.name || '未知' }}</span>
<span>{{
registerCardDisplay.user_patient?.name || '未知'
}}</span>
</div>
<div class="flex items-center gap-1">
<i class="fas fa-birthday-cake text-xs"></i>
@@ -736,7 +768,9 @@ const getSexText = (sex) => {
</div>
<div class="flex items-center gap-1">
<i class="fas fa-venus-mars text-xs"></i>
<span>{{ getSexText(registerCardDisplay.user_patient?.sex) }}</span>
<span>{{
getSexText(registerCardDisplay.user_patient?.sex)
}}</span>
</div>
<div class="flex items-center gap-1">
<i class="fas fa-phone text-xs"></i>
@@ -790,7 +824,9 @@ const getSexText = (sex) => {
</span>
</div>
<div v-if="registerCardDrugRows.length" class="space-y-2">
<div class="font-medium text-gray-600 dark:text-gray-300">所选药品:</div>
<div class="font-medium text-gray-600 dark:text-gray-300">
所选药品:
</div>
<div
v-for="row in registerCardDrugRows"
:key="row.key"
@@ -805,7 +841,9 @@ const getSexText = (sex) => {
:preview="{ src: row.image }"
/>
<div class="min-w-0 flex-1">
<div class="text-gray-800 dark:text-gray-100">{{ row.name || '药品' }}</div>
<div class="text-gray-800 dark:text-gray-100">
{{ row.name || '药品' }}
</div>
<div
v-if="row.specification"
class="text-xs text-gray-400 dark:text-gray-500"
@@ -813,9 +851,9 @@ const getSexText = (sex) => {
{{ row.specification }}
</div>
</div>
<span class="flex-shrink-0 font-medium text-gray-700 dark:text-gray-200"
>×{{ row.quantity }}</span
>
<span
class="flex-shrink-0 font-medium text-gray-700 dark:text-gray-200"
>×{{ row.quantity }}</span>
</div>
</div>
</div>
@@ -830,14 +868,20 @@ const getSexText = (sex) => {
</div>
<!-- 接诊/拒诊按钮 - 只在待就诊状态显示 -->
<div v-if="registerCardDisplay.status === 1" class="mt-2 flex gap-2">
<div
v-if="registerCardDisplay.status === 1"
class="mt-2 flex gap-2"
>
<Button
:loading="registerOperating"
class="flex-1"
size="small"
type="primary"
@click="
acceptRegister(registerCardDisplay.id, registerCardDisplay.order_no)
acceptRegister(
registerCardDisplay.id,
registerCardDisplay.order_no,
)
"
>
<i class="fas fa-check mr-1"></i>
@@ -849,7 +893,10 @@ const getSexText = (sex) => {
danger
size="small"
@click="
rejectRegister(registerCardDisplay.id, registerCardDisplay.order_no)
rejectRegister(
registerCardDisplay.id,
registerCardDisplay.order_no,
)
"
>
<i class="fas fa-times mr-1"></i>
@@ -1017,9 +1064,7 @@ const getSexText = (sex) => {
class="text-sm"
>
<span class="w-20 text-gray-500">用药情况:</span>
{{
parsedContent.hasUsedDrug === '1' ? '使用过' : '未使用过'
}}
{{ parsedContent.hasUsedDrug === '1' ? '使用过' : '未使用过' }}
</p>
<!-- 医生信息 -->
@@ -1034,10 +1079,7 @@ const getSexText = (sex) => {
</div>
<!-- 系统消息/价格更新卡片 (type=9) -->
<div
v-else-if="message.message_type === 9"
class="system-message-card"
>
<div v-else-if="message.message_type === 9" class="system-message-card">
<div
v-if="parsedContent?.type === 'price_update'"
class="price-update-card"
@@ -1050,7 +1092,7 @@ const getSexText = (sex) => {
</div>
<div class="price-update-content">
<p class="price-message">{{ parsedContent.message }}</p>
<!-- 订单信息 -->
<div class="order-info">
<div class="info-item">
@@ -1070,7 +1112,13 @@ const getSexText = (sex) => {
运费
</span>
<span class="price-value shipping-fee">
<span v-if="parsedContent.is_free_shipping === 1 || parsedContent.is_free_shipping === '1'" class="free-shipping-tag">
<span
v-if="
parsedContent.is_free_shipping === 1 ||
parsedContent.is_free_shipping === '1'
"
class="free-shipping-tag"
>
<i class="fas fa-check-circle"></i>
包邮
</span>
@@ -1131,9 +1179,7 @@ const getSexText = (sex) => {
<!-- 通话消息 -->
<div
v-else-if="
message.message_type === 6 || message.message_type === 7
"
v-else-if="message.message_type === 6 || message.message_type === 7"
class="call-message"
>
<div class="call-info">
@@ -1148,9 +1194,18 @@ const getSexText = (sex) => {
</div>
</div>
<!-- 文本消息 -->
<div v-else class="whitespace-pre-wrap text-base leading-relaxed">
<span v-html="formatTextWithLinks(parsedContent || message.message_content || message.content || '')"></span>
<!-- 文本消息:字号/行高随密度 -->
<div v-else class="bubble-text whitespace-pre-wrap">
<span
v-html="
formatTextWithLinks(
parsedContent ||
message.message_content ||
message.content ||
'',
)
"
></span>
</div>
</div>
@@ -1180,10 +1235,30 @@ const getSexText = (sex) => {
</template>
<style scoped>
/* 行间距、气泡内边距、正文、图片宽度均读父级 chat-layout 密度变量 */
.message-row {
gap: var(--chat-msg-gap, 10px);
margin-bottom: var(--chat-msg-mb, 10px);
}
.message-bubble-pad {
padding: var(--chat-bubble-pad, 10px);
}
.bubble-text {
font-size: var(--chat-text-size, 14px);
line-height: var(--chat-text-leading, 1.5);
}
.image-message {
width: var(--chat-image-max, 240px);
max-width: 100%;
}
.message-bubble {
position: relative;
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
transition: all 0.2s ease;
}
@@ -1195,49 +1270,50 @@ const getSexText = (sex) => {
.bubble-bg {
background: linear-gradient(135deg, #4361ee, #3f37c9);
}
.bubble-bg.dark {
background: linear-gradient(135deg, #4361ee, #3f37c9);
}
.message-bubble.received.bubble-bg {
background: white;
color: #1a202c;
background: white;
border: 1px solid #e2e8f0;
border-bottom-left-radius: 4px;
}
.message-bubble.received.dark.bubble-bg {
background: #374151;
color: #f7fafc;
background: #374151;
border-color: #4b5563;
}
.video-message {
position: relative;
border-radius: 12px;
overflow: hidden;
max-width: 300px;
overflow: hidden;
border-radius: 12px;
}
.video-thumbnail {
display: block;
width: 100%;
height: auto;
max-height: 200px;
object-fit: cover;
display: block;
}
.video-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
transparent 0%,
rgba(0, 0, 0, 0.3) 100%
);
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
to bottom,
transparent 0%,
rgb(0 0 0 / 30%) 100%
);
opacity: 0;
transition: opacity 0.3s ease;
}
@@ -1247,34 +1323,34 @@ const getSexText = (sex) => {
}
.play-button {
width: 60px;
height: 60px;
background: rgba(0, 0, 0, 0.7);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
width: 60px;
height: 60px;
font-size: 24px;
color: white;
background: rgb(0 0 0 / 70%);
border-radius: 50%;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.play-button:hover {
background: rgba(0, 0, 0, 0.8);
background: rgb(0 0 0 / 80%);
transform: scale(1.1);
}
.video-duration {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
bottom: 8px;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
color: white;
background: rgb(0 0 0 / 70%);
border-radius: 4px;
}
.call-message {
@@ -1286,8 +1362,8 @@ const getSexText = (sex) => {
.call-info {
display: flex;
align-items: center;
gap: 8px;
align-items: center;
font-weight: 500;
}
@@ -1306,42 +1382,42 @@ const getSexText = (sex) => {
}
.url-preview {
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
background: white;
display: flex;
gap: 12px;
align-items: flex-start;
padding: 12px;
gap: 12px;
overflow: hidden;
cursor: pointer;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
transition: all 0.3s ease;
}
.url-preview:hover {
border-color: #4361ee;
box-shadow: 0 8px 20px rgb(67 97 238 / 15%);
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.15);
}
.message-bubble.sent .url-preview {
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
background: rgb(255 255 255 / 10%);
border-color: rgb(255 255 255 / 30%);
backdrop-filter: blur(10px);
}
.message-bubble.received.dark .url-preview {
border-color: #4b5563;
background: #4b5563;
border-color: #4b5563;
}
.url-favicon {
width: 20px;
height: 20px;
flex-shrink: 0;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
}
.url-favicon img {
@@ -1357,15 +1433,15 @@ const getSexText = (sex) => {
}
.url-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
color: #1a202c;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom: 4px;
overflow: hidden;
-webkit-line-clamp: 2;
font-size: 14px;
font-weight: 600;
line-height: 1.4;
color: #1a202c;
-webkit-box-orient: vertical;
}
.message-bubble.sent .url-title {
@@ -1377,18 +1453,18 @@ const getSexText = (sex) => {
}
.url-description {
font-size: 12px;
color: #64748b;
margin-bottom: 4px;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom: 4px;
overflow: hidden;
-webkit-line-clamp: 2;
font-size: 12px;
line-height: 1.3;
color: #64748b;
-webkit-box-orient: vertical;
}
.message-bubble.sent .url-description {
color: rgba(255, 255, 255, 0.8);
color: rgb(255 255 255 / 80%);
}
.message-bubble.received.dark .url-description {
@@ -1397,12 +1473,12 @@ const getSexText = (sex) => {
.url-domain {
font-size: 11px;
color: #94a3b8;
font-weight: 500;
color: #94a3b8;
}
.message-bubble.sent .url-domain {
color: rgba(255, 255, 255, 0.7);
color: rgb(255 255 255 / 70%);
}
.message-bubble.received.dark .url-domain {
@@ -1410,11 +1486,11 @@ const getSexText = (sex) => {
}
.url-thumbnail {
flex-shrink: 0;
width: 60px;
height: 60px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
border-radius: 8px;
}
.url-thumbnail img {
@@ -1440,7 +1516,7 @@ const getSexText = (sex) => {
}
.message-bubble.sent :deep(.text-link) {
color: rgba(255, 255, 255, 0.9);
color: rgb(255 255 255 / 90%);
}
.message-bubble.sent :deep(.text-link:hover) {
@@ -1482,11 +1558,13 @@ const getSexText = (sex) => {
.price-update-card {
@apply rounded-xl border border-orange-200 bg-gradient-to-r from-orange-50 to-amber-50 p-4 shadow-sm dark:border-orange-700 dark:from-orange-900/30 dark:to-amber-900/30;
transition: all 0.3s ease;
}
.price-update-card:hover {
@apply shadow-md;
transform: translateY(-2px);
}
@@ -1600,9 +1678,9 @@ const getSexText = (sex) => {
/* 图片和视频消息发送样式 */
.message-bubble.sent .image-message,
.message-bubble.sent .video-message {
opacity: 0.9;
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid rgb(255 255 255 / 20%);
border-radius: 12px;
opacity: 0.9;
}
/* 通话消息发送样式 */

View File

@@ -1,9 +1,13 @@
<script setup>
import { ref, provide, nextTick, onMounted, onUnmounted, computed } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, provide, ref } from 'vue';
import { useRoute } from 'vue-router';
import { CloseOutlined, PlusOutlined } from '@ant-design/icons-vue';
import { message, Modal } from 'ant-design-vue';
import { PlusOutlined, CloseOutlined } from '@ant-design/icons-vue';
import { usePrescriptionStore } from '#/store/prescription';
import { getTransferPrescriptionByRegisterApi } from '#/views/business/chat/api/transferPrescription';
import TransferPrescriptionViewDrawer from '#/views/doctor/online-consultation/components/TransferPrescriptionViewDrawer.vue';
import { useFileUpload } from '../composables/useFileUpload.ts';
import { useRecording } from '../composables/useRecording.ts';
@@ -11,21 +15,38 @@ import { useChatStore } from '../stores/chat.ts';
import { useThemeStore } from '../stores/theme.ts';
import { useUserStore } from '../stores/user.ts';
import { sendMessage } from '../utils/request.ts';
import { usePrescriptionStore } from '#/store/prescription';
import { getTransferPrescriptionByRegisterApi } from '#/views/business/chat/api/transferPrescription';
import TransferPrescriptionViewDrawer from '#/views/doctor/online-consultation/components/TransferPrescriptionViewDrawer.vue';
// 定义 props
const props = defineProps({
currentFriend: {
type: Object,
default: () => ({}),
},
});
// 定义 emits
const emit = defineEmits([
'sendMessage',
'openPrescription', // 添加开方事件
'end-consultation-success', // 结束接诊成功事件
'transfer-import-success', // 转接方导入成功事件
]);
// 根据路由判断是在线复诊-药店还是在线复诊-诊所
const route = useRoute();
const isClinicModule = computed(() => {
return route.path.includes('online-consultation-clinic');
});
// 动态导入对应的API和组件
import { endConsultation as endConsultationPharmacy, getDrugsByRegisterId as getDrugsByRegisterIdPharmacy } from '#/views/doctor/online-consultation/api/index';
import { endConsultation as endConsultationClinic, getDrugsByRegisterId as getDrugsByRegisterIdClinic } from '#/views/doctor/online-consultation-clinic/api/index';
import QuickReplyBubblesPharmacy from '#/views/doctor/online-consultation/components/QuickReplyBubbles.vue';
import {
endConsultation as endConsultationClinic,
getDrugsByRegisterId as getDrugsByRegisterIdClinic,
} from '#/views/doctor/online-consultation-clinic/api/index';
import QuickReplyBubblesClinic from '#/views/doctor/online-consultation-clinic/components/QuickReplyBubbles.vue';
// 动态导入对应的API和组件
import {
endConsultation as endConsultationPharmacy,
getDrugsByRegisterId as getDrugsByRegisterIdPharmacy,
} from '#/views/doctor/online-consultation/api/index';
import QuickReplyBubblesPharmacy from '#/views/doctor/online-consultation/components/QuickReplyBubbles.vue';
// 根据模块选择对应的API和组件
const endConsultation = computed(() => {
@@ -33,25 +54,22 @@ const endConsultation = computed(() => {
});
const getDrugsByRegisterId = computed(() => {
return isClinicModule.value ? getDrugsByRegisterIdClinic : getDrugsByRegisterIdPharmacy;
return isClinicModule.value
? getDrugsByRegisterIdClinic
: getDrugsByRegisterIdPharmacy;
});
const QuickReplyBubbles = computed(() => {
return isClinicModule.value ? QuickReplyBubblesClinic : QuickReplyBubblesPharmacy;
return isClinicModule.value
? QuickReplyBubblesClinic
: QuickReplyBubblesPharmacy;
});
import { prepareImageForUpload } from '#/utils/prepare-image-upload';
import CustomTextarea from './CustomTextarea.vue';
import EmojiPicker from './EmojiPicker.vue';
import FileUploadPreview from './FileUploadPreview.vue';
import { prepareImageForUpload } from '#/utils/prepare-image-upload';
// 定义 props
const props = defineProps({
currentFriend: {
type: Object,
default: () => ({})
}
});
const userStore = useUserStore();
const chatStore = useChatStore();
@@ -104,7 +122,7 @@ const sendUploadedFile = () => {
room_id: chatStore.currentFriend.room_id,
read: true,
duration: 0,
isSent: true, // 标记为自己发送的消息,确保显示在右侧
isSent: true, // 标记为自己发送的消息,确保显示在右侧
};
chatStore.addMessage(messageObj, userStore.currentUser.doctor_id);
@@ -113,7 +131,7 @@ const sendUploadedFile = () => {
sendMessage({
senderId: userStore.currentUser.doctor_id,
receiverId: chatStore.currentFriend.id,
roomId: chatStore.currentFriend.room_id, // 添加房间ID
roomId: chatStore.currentFriend.room_id, // 添加房间ID
type: uploadPreview.value.type,
content: uploadPreview.value.url,
}).catch((error) => {
@@ -339,14 +357,6 @@ onUnmounted(() => {
window.removeEventListener('audioRecorded', handleAudioRecorded);
});
// 定义 emits
const emit = defineEmits([
'sendMessage',
'openPrescription', // 添加开方事件
'end-consultation-success', // 结束接诊成功事件
'transfer-import-success' // 转接方导入成功事件
]);
// 开方功能触发函数
const handleOpenPrescription = () => {
// 获取当前会话的register_id,这里假设可以通过某种方式获取
@@ -383,8 +393,14 @@ const handleAddPatientProductsToPrescription = async () => {
const registerId = chatStore.currentFriend?.register_id;
if (registerId) {
// 根据模块选择对应的存储前缀
const storagePrefix = isClinicModule.value ? 'onlineConsultationClinic-' : 'onlineConsultation-';
prescriptionStore.initializePrescription(registerId, false, storagePrefix);
const storagePrefix = isClinicModule.value
? 'onlineConsultationClinic-'
: 'onlineConsultation-';
prescriptionStore.initializePrescription(
registerId,
false,
storagePrefix,
);
} else {
message.warning('请先接诊患者');
return;
@@ -394,7 +410,10 @@ const handleAddPatientProductsToPrescription = async () => {
// 获取诊所ID
let storeId = prescriptionStore.myStoreId;
if (!storeId || storeId === 0) {
if (prescriptionStore.myStoreList && prescriptionStore.myStoreList.length > 0) {
if (
prescriptionStore.myStoreList &&
prescriptionStore.myStoreList.length > 0
) {
storeId = prescriptionStore.myStoreList[0].id;
} else {
message.warning('请先选择诊所');
@@ -405,8 +424,9 @@ const handleAddPatientProductsToPrescription = async () => {
try {
// 根据 register_id 获取药品信息
const res = await getDrugsByRegisterId.value(
prescriptionStore.currentRegisterId || chatStore.currentFriend?.register_id,
storeId
prescriptionStore.currentRegisterId ||
chatStore.currentFriend?.register_id,
storeId,
);
const drugList = res?.result || res?.data || res || [];
@@ -427,7 +447,11 @@ const handleAddPatientProductsToPrescription = async () => {
if (addedCount > 0) {
message.success(`已成功添加 ${addedCount} 个药品到处方单`);
// 触发打开处方模态框事件
emit('openPrescription', prescriptionStore.currentRegisterId || chatStore.currentFriend?.register_id);
emit(
'openPrescription',
prescriptionStore.currentRegisterId ||
chatStore.currentFriend?.register_id,
);
} else {
message.warning('所有药品已在处方中');
}
@@ -459,7 +483,7 @@ const handleEndConsultation = () => {
await endConsultationFn({
register_id: registerId,
doctor_id: userStore.currentUser?.doctor_id || '39',
reason: '医生主动结束'
reason: '医生主动结束',
});
message.success('结束接诊成功');
@@ -468,7 +492,7 @@ const handleEndConsultation = () => {
console.error('结束接诊失败:', error);
message.error('结束接诊失败,请重试');
}
}
},
});
};
@@ -500,18 +524,22 @@ const handleOpenTransferView = async () => {
const handleTransferImportSuccess = (prescriptionData) => {
// 触发打开处方模态框的事件
emit('transfer-import-success', prescriptionData);
emit('openPrescription', transferRegisterId.value || chatStore.currentFriend?.register_id);
emit(
'openPrescription',
transferRegisterId.value || chatStore.currentFriend?.register_id,
);
};
</script>
<template>
<div class="message-input-container flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<div
class="message-input-container flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700"
>
<!-- 快速回复气泡(仅在线复诊场景显示) -->
<component
:is="QuickReplyBubbles"
v-if="chatStore.currentFriend"
:doctor-id="'32'"
doctor-id="32"
@select-reply="handleQuickReplySelect"
/>
@@ -527,7 +555,7 @@ const handleTransferImportSuccess = (prescriptionData) => {
title="开方"
>
<i class="fas fa-file-medical"></i>
<!-- <i class="fas fa-prescription"></i>-->
<!-- <i class="fas fa-prescription"></i>-->
</button>
<button
class="function-btn"
@@ -536,13 +564,13 @@ const handleTransferImportSuccess = (prescriptionData) => {
>
<i class="fas fa-image"></i>
</button>
<!-- <button-->
<!-- class="function-btn"-->
<!-- @click="triggerFileInput('video')"-->
<!-- title="发送视频"-->
<!-- >-->
<!-- <i class="fas fa-video"></i>-->
<!-- </button>-->
<!-- <button-->
<!-- class="function-btn"-->
<!-- @click="triggerFileInput('video')"-->
<!-- title="发送视频"-->
<!-- >-->
<!-- <i class="fas fa-video"></i>-->
<!-- </button>-->
<button
:class="{ active: showEmojiPicker }"
class="function-btn"
@@ -551,18 +579,18 @@ const handleTransferImportSuccess = (prescriptionData) => {
>
<i class="fas fa-smile"></i>
</button>
<!-- <button-->
<!-- :class="{ recording: isRecording }"-->
<!-- class="function-btn record-btn"-->
<!-- title="按住录音"-->
<!-- @mousedown="startRecording"-->
<!-- @mouseleave="stopRecording"-->
<!-- @mouseup="stopRecording"-->
<!-- @touchend="stopRecording"-->
<!-- @touchstart="startRecording"-->
<!-- >-->
<!-- <i class="fas fa-microphone"></i>-->
<!-- </button>-->
<!-- <button-->
<!-- :class="{ recording: isRecording }"-->
<!-- class="function-btn record-btn"-->
<!-- title="按住录音"-->
<!-- @mousedown="startRecording"-->
<!-- @mouseleave="stopRecording"-->
<!-- @mouseup="stopRecording"-->
<!-- @touchend="stopRecording"-->
<!-- @touchstart="startRecording"-->
<!-- >-->
<!-- <i class="fas fa-microphone"></i>-->
<!-- </button>-->
<!-- 添加药品按钮(仅在线复诊场景显示) -->
<button
v-if="showOnlineConsultationButtons"
@@ -646,9 +674,9 @@ const handleTransferImportSuccess = (prescriptionData) => {
<style scoped>
.message-input-container {
padding: 20px;
position: relative;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
padding: var(--chat-input-pad, 12px);
background: linear-gradient(135deg, #fff 0%, #f8fafc 100%);
}
.dark .message-input-container {
@@ -658,14 +686,14 @@ const handleTransferImportSuccess = (prescriptionData) => {
.input-container {
display: flex;
flex-direction: column;
gap: 16px;
gap: var(--chat-input-gap, 10px);
}
.toolbar {
display: flex;
align-items: center;
padding: 8px;
border-top: 1px solid #e2e8f0;
padding: var(--chat-toolbar-pad, 4px) 0;
border-top: none;
}
.dark .toolbar {
@@ -673,15 +701,15 @@ const handleTransferImportSuccess = (prescriptionData) => {
}
.function-btn {
background: none;
border: none;
cursor: pointer;
padding: 5px 10px;
border-radius: 4px;
margin-right: 5px;
display: flex;
align-items: center;
padding: 5px 10px;
margin-right: 5px;
color: #666;
cursor: pointer;
background: none;
border: none;
border-radius: 4px;
}
.dark .function-btn {
@@ -697,8 +725,8 @@ const handleTransferImportSuccess = (prescriptionData) => {
}
.prescription-button {
color: #455cda;
font-weight: 500;
color: #455cda;
}
.prescription-button:hover {
@@ -706,33 +734,33 @@ const handleTransferImportSuccess = (prescriptionData) => {
}
.end-consultation-btn {
color: #ff4d4f;
font-weight: 500;
color: #ff4d4f;
}
.end-consultation-btn:hover {
background-color: #fff1f0;
color: #ff4d4f;
background-color: #fff1f0;
}
.dark .end-consultation-btn:hover {
background-color: rgba(255, 77, 79, 0.2);
color: #ff4d4f;
background-color: rgb(255 77 79 / 20%);
}
.transfer-view-btn {
color: #f97316;
font-weight: 500;
color: #f97316;
}
.transfer-view-btn:hover {
background-color: #fff7ed;
color: #f97316;
background-color: #fff7ed;
}
.dark .transfer-view-btn:hover {
background-color: rgba(249, 115, 22, 0.2);
color: #f97316;
background-color: rgb(249 115 22 / 20%);
}
.function-buttons {
@@ -743,38 +771,40 @@ const handleTransferImportSuccess = (prescriptionData) => {
.function-btn {
position: relative;
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
color: #64748b;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
width: var(--chat-fn-btn, 36px);
height: var(--chat-fn-btn, 36px);
padding: 0;
margin-right: 6px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-size: var(--chat-fn-font, 15px);
color: #64748b;
cursor: pointer;
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
border: none;
border-radius: 50%;
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.function-btn:hover {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
background: linear-gradient(135deg, #4361ee, #3f37c9);
box-shadow: 0 8px 20px rgb(67 97 238 / 30%);
transform: translateY(-2px) scale(1.05);
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.3);
}
.function-btn.active {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
background: linear-gradient(135deg, #4361ee, #3f37c9);
transform: scale(1.1);
}
.function-btn.recording {
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
color: white;
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
animation: pulse-recording 1s infinite;
}
@@ -782,9 +812,11 @@ const handleTransferImportSuccess = (prescriptionData) => {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
@@ -794,22 +826,23 @@ const handleTransferImportSuccess = (prescriptionData) => {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 60px;
height: 60px;
border: 2px solid rgba(255, 255, 255, 0.3);
border: 2px solid rgb(255 255 255 / 30%);
border-radius: 50%;
transform: translate(-50%, -50%);
animation: wave-expand 1.5s infinite;
}
@keyframes wave-expand {
0% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 1;
transform: translate(-50%, -50%) scale(0.8);
}
100% {
transform: translate(-50%, -50%) scale(1.5);
opacity: 0;
transform: translate(-50%, -50%) scale(1.5);
}
}
@@ -819,8 +852,8 @@ const handleTransferImportSuccess = (prescriptionData) => {
left: 50%;
width: 0;
height: 0;
background: rgb(255 255 255 / 30%);
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: all 0.6s ease;
}
@@ -831,20 +864,20 @@ const handleTransferImportSuccess = (prescriptionData) => {
}
.dark .function-btn {
background: linear-gradient(135deg, #374151, #4b5563);
color: #9ca3af;
background: linear-gradient(135deg, #374151, #4b5563);
}
.dark .function-btn:hover {
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
background: linear-gradient(135deg, #4361ee, #3f37c9);
}
.input-section {
display: flex;
align-items: end;
gap: 12px;
position: relative;
display: flex;
gap: var(--chat-input-gap, 10px);
align-items: end;
}
.message-textarea {
@@ -853,32 +886,33 @@ const handleTransferImportSuccess = (prescriptionData) => {
.send-btn {
position: relative;
width: 52px;
height: 52px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #4361ee, #3f37c9);
color: white;
cursor: pointer;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
width: var(--chat-send-btn, 40px);
height: var(--chat-send-btn, 40px);
overflow: hidden;
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.3);
font-size: var(--chat-send-font, 15px);
color: white;
cursor: pointer;
background: linear-gradient(135deg, #4361ee, #3f37c9);
border: none;
border-radius: 50%;
box-shadow: 0 4px 12px rgb(67 97 238 / 30%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.send-btn:hover:not(:disabled) {
box-shadow: 0 8px 20px rgb(67 97 238 / 40%);
transform: translateY(-2px) scale(1.05);
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.4);
}
.send-btn:disabled {
background: #94a3b8;
cursor: not-allowed;
transform: none;
background: #94a3b8;
box-shadow: none;
transform: none;
}
.send-ripple {
@@ -887,8 +921,8 @@ const handleTransferImportSuccess = (prescriptionData) => {
left: 50%;
width: 0;
height: 0;
background: rgb(255 255 255 / 40%);
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
transform: translate(-50%, -50%);
transition: all 0.6s ease;
}
@@ -901,23 +935,4 @@ const handleTransferImportSuccess = (prescriptionData) => {
.hidden {
display: none;
}
/* 响应式设计 */
@media (max-width: 768px) {
.function-buttons {
gap: 8px;
}
.function-btn {
width: 40px;
height: 40px;
font-size: 16px;
}
.send-btn {
width: 48px;
height: 48px;
font-size: 16px;
}
}
</style>

View File

@@ -4,16 +4,16 @@ import { nextTick, onMounted, ref, watch } from 'vue';
import { MessageOutlined } from '@ant-design/icons-vue';
import { Spin } from 'ant-design-vue';
import { getTransferAssistantConfig } from '#/views/system/transfer-question/api';
import { useChatStore } from '../stores/chat.ts';
import { useUserStore } from '../stores/user.ts';
import { getTransferAssistantConfig } from '#/views/system/transfer-question/api';
import MessageBubble from './MessageBubble.vue';
const emit = defineEmits(['open-prescription', 'transfer-import-success']);
const userStore = useUserStore();
const chatStore = useChatStore();
const emit = defineEmits(['open-prescription', 'transfer-import-success']);
const messagesContainer = ref(null);
const loadingMessages = ref(false);
const loadingOlderMessages = ref(false); // 加载历史消息状态
@@ -57,13 +57,17 @@ const getSenderInfo = (message) => {
if (senderId === 'doctor_assistant') {
return {
nick_name: assistantConfig.value.name,
avatar: assistantConfig.value.avatar || assistantConfig.value.name.charAt(0),
avatar:
assistantConfig.value.avatar || assistantConfig.value.name.charAt(0),
color: '#4cc9f0',
};
}
// 如果是当前医生发送的消息
if (senderId === userStore.currentUser.doctor_id || senderId === `doctor-${userStore.currentUser.doctor_id}`) {
if (
senderId === userStore.currentUser.doctor_id ||
senderId === `doctor-${userStore.currentUser.doctor_id}`
) {
return {
nick_name: userStore.currentUser.nick_name,
avatar: userStore.currentUser.nick_name.charAt(0),
@@ -117,7 +121,6 @@ const loadOlderMessages = async () => {
// 生成模拟的历史消息
chatStore.generateOldMessages();
} finally {
loadingOlderMessages.value = false;
}
@@ -130,11 +133,7 @@ const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value;
// 滚动到顶部时加载更多
if (
scrollTop < 100 &&
!loadingOlderMessages.value &&
chatStore.hasMore
) {
if (scrollTop < 100 && !loadingOlderMessages.value && chatStore.hasMore) {
loadOlderMessages();
}
};
@@ -172,7 +171,7 @@ onMounted(() => {
<template>
<div
ref="messagesContainer"
class="message-list-background flex-1 overflow-y-auto bg-gray-50 p-5 dark:bg-gray-900 min-h-0"
class="message-list-background flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900 min-h-0"
@scroll="handleScroll"
>
<Spin :spinning="loadingMessages" tip="加载消息中...">
@@ -200,17 +199,22 @@ onMounted(() => {
没有更多消息了
</div>
<!-- 条间距由 MessageBubble.message-row 的 --chat-msg-mb 控制,外层不再叠 mb-6 -->
<div
v-for="(msg, index) in chatStore.messages"
:key="msg.id || index"
class="clear-both mb-6"
class="clear-both"
>
<MessageBubble
:is-sent="msg.isSent"
:message="msg"
:sender-info="getSenderInfo(msg)"
@open-prescription="(registerId) => emit('open-prescription', registerId)"
@transfer-import-success="(data) => emit('transfer-import-success', data)"
@open-prescription="
(registerId) => emit('open-prescription', registerId)
"
@transfer-import-success="
(data) => emit('transfer-import-success', data)
"
/>
</div>
</div>
@@ -218,8 +222,9 @@ onMounted(() => {
</div>
</template>
<style>
<style scoped>
.message-list-background {
padding: var(--chat-list-pad, 12px);
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" opacity="0.03"><rect width="100" height="100" fill="none"/><path d="M0,0 L100,100 M100,0 L0,100" stroke="currentColor"/></svg>');
background-size: 200px 200px;
}

View File

@@ -0,0 +1,72 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
/**
* 在线接诊聊天区布局密度(大 / 中 / 小)
* - 默认中:兼顾可读与消息可视高度
* - 持久化到 localStorage,切换会话/刷新后保持
*/
export type ChatLayoutDensity = 'lg' | 'md' | 'sm';
const STORAGE_KEY = 'chatLayoutDensity';
/** 下拉选项:大=宽松(接近原尺寸)、中=默认压缩、小=紧凑 IM */
export const CHAT_LAYOUT_DENSITY_OPTIONS: {
label: string;
value: ChatLayoutDensity;
}[] = [
{ label: '大', value: 'lg' },
{ label: '中', value: 'md' },
{ label: '小', value: 'sm' },
];
/** 各密度对应的头像像素(Ant Avatar 需数字 size,不能只靠 CSS) */
const AVATAR_SIZE_MAP: Record<ChatLayoutDensity, number> = {
sm: 32,
md: 36,
lg: 48,
};
/** 悬浮按钮尺寸 */
const FLOAT_BTN_SIZE_MAP: Record<ChatLayoutDensity, number> = {
sm: 40,
md: 44,
lg: 56,
};
function readStoredDensity(): ChatLayoutDensity {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === 'sm' || saved === 'md' || saved === 'lg') {
return saved;
}
return 'md';
}
export const useLayoutDensityStore = defineStore('chatLayoutDensity', () => {
const density = ref<ChatLayoutDensity>(readStoredDensity());
const avatarSize = computed(() => AVATAR_SIZE_MAP[density.value]);
const floatBtnSize = computed(() => FLOAT_BTN_SIZE_MAP[density.value]);
const densityClass = computed(() => `chat-layout--${density.value}`);
/**
* 切换布局密度并写入缓存
* 为什么:医生习惯不同,大屏偏大、笔记本偏小,需可调且跨刷新保留
*/
function setDensity(next: ChatLayoutDensity) {
if (next !== 'sm' && next !== 'md' && next !== 'lg') {
return;
}
density.value = next;
localStorage.setItem(STORAGE_KEY, next);
}
return {
density,
avatarSize,
floatBtnSize,
densityClass,
setDensity,
};
});

View File

@@ -1,14 +1,20 @@
<script setup lang="ts">
/**
* 诊所业务员「我的收益」页
* 展示基本信息、推广二维码,以及累计获客/服务费/待结算/已结算四项汇总。
* 卡片统一用 GlassCard,避免再引入 antd Card(构建期也会因标签不匹配失败)。
*/
import { onMounted, ref } from 'vue';
import { Page, useVbenModal } from "@vben/common-ui";
import { Page, useVbenModal } from '@vben/common-ui';
import { Card, Col, Image, Row, Spin } from 'ant-design-vue';
import { Col, Image, Row, Spin } from 'ant-design-vue';
import { getSelfMyInfoApi } from '#/views/business/clinic-salesperson/api';
import QrCodePreview from '#/views/business/store/settings/components/SalespersonQrCodePreview.vue';
import { GlassCard } from '#/components/glass-card';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { getSelfMyInfoApi } from '#/views/business/clinic-salesperson/api';
import QrCodePreview from '#/views/business/store/settings/components/SalespersonQrCodePreview.vue';
const [QrCodePreviewModal, QrCodePreviewApi] = useVbenModal({
connectedComponent: QrCodePreview,
});
@@ -24,7 +30,7 @@ onMounted(async () => {
}
});
/** 打开推广二维码预览弹窗(复用门店设置里的预览组件) */
function openQrCode(item: any) {
QrCodePreviewApi.setData({ values: item });
QrCodePreviewApi.open();
@@ -36,55 +42,70 @@ function openQrCode(item: any) {
<QrCodePreviewModal />
<Spin :spinning="loading">
<template v-if="info">
<Card class="mb-4" title="基本信息">
<GlassCard
class="mb-4"
title="基本信息"
icon="lucide:user-round"
padding="lg"
>
<p>姓名:{{ info.salesperson?.nick_name }}</p>
<p>手机:<SensitiveText :record="info.salesperson" field="phone" /></p>
<p>
手机:
<SensitiveText :record="info.salesperson" field="phone" />
</p>
<p>所属诊所:{{ info.store?.name }}</p>
<div v-if="info.qr_code" class="mt-3">
<p class="mb-2">推广二维码</p>
<Image :src="info.qr_code" :preview="false" :width="160" @click="openQrCode({
qr_code: {
qr_code: info.qr_code,
store: info.store,
},
type: 'salesperson',
nick_name: info.salesperson?.nick_name,
})" />
<Image
:src="info.qr_code"
:preview="false"
:width="160"
@click="
openQrCode({
qr_code: {
qr_code: info.qr_code,
store: info.store,
},
type: 'salesperson',
nick_name: info.salesperson?.nick_name,
})
"
/>
</div>
</Card>
</GlassCard>
<Row :gutter="16">
<Col :span="6">
<Card>
<div class="text-gray-500">累计获客</div>
<div class="text-2xl font-semibold text-blue-600">
<GlassCard padding="md" :specular="false" tone="primary">
<div class="text-[hsl(var(--muted-foreground))]">累计获客</div>
<div class="text-2xl font-semibold text-[hsl(var(--primary))]">
{{ info.stats?.user_number ?? 0 }}
</div>
</Card>
</GlassCard>
</Col>
<Col :span="6">
<Card>
<div class="text-gray-500">累计服务费</div>
<div class="text-2xl font-semibold">
<GlassCard padding="md" :specular="false" tone="primary">
<div class="text-[hsl(var(--muted-foreground))]">累计服务费</div>
<div class="text-2xl font-semibold text-[hsl(var(--foreground))]">
¥{{ info.stats?.money ?? '0.00' }}
</div>
</Card>
</GlassCard>
</Col>
<Col :span="6">
<Card>
<div class="text-gray-500">待结算</div>
<div class="text-2xl font-semibold text-orange-600">
<GlassCard padding="md" :specular="false" tone="warning">
<div class="text-[hsl(var(--muted-foreground))]">待结算</div>
<div class="text-2xl font-semibold text-[hsl(var(--warning))]">
¥{{ info.stats?.pending_amount ?? '0.00' }}
</div>
</Card>
</GlassCard>
</Col>
<Col :span="6">
<Card>
<div class="text-gray-500">已结算</div>
<div class="text-2xl font-semibold text-green-600">
<GlassCard padding="md" :specular="false" tone="success">
<div class="text-[hsl(var(--muted-foreground))]">已结算</div>
<div class="text-2xl font-semibold text-[hsl(var(--success))]">
¥{{ info.stats?.settled_amount ?? '0.00' }}
</div>
</Card>
</GlassCard>
</Col>
</Row>
</template>

View File

@@ -5,27 +5,28 @@ import { Page, useVbenModal } from '@vben/common-ui';
import {
Button,
Card,
Empty,
Input,
message,
Popover,
Skeleton,
Space,
Tag,
message,
} from 'ant-design-vue';
import { GlassCard } from '#/components/glass-card';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import {
getPlatformSalespersonListApi,
openAdminAccountApi,
} from '#/views/business/store/settings/api';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import QrCodePreview from '#/views/business/store/settings/components/SalespersonQrCodePreview.vue';
import SalespersonCommissionDrawer from '#/views/system/store/components/SalespersonCommissionDrawer.vue';
import SalespersonCreateModal from '#/views/business/store/settings/components/SalespersonCreateModal.vue';
import SalespersonUserBindDrawer from '#/views/business/store/settings/components/SalespersonUserBindDrawer.vue';
import SalespersonInvitedDrawer from '#/views/business/store/settings/components/SalespersonInvitedDrawer.vue';
import QrCodePreview from '#/views/business/store/settings/components/SalespersonQrCodePreview.vue';
import SalespersonUserBindDrawer from '#/views/business/store/settings/components/SalespersonUserBindDrawer.vue';
import SalespersonCommissionDrawer from '#/views/system/store/components/SalespersonCommissionDrawer.vue';
const listData = ref<{ total: number; items: any[] }>({ total: 0, items: [] });
const listData = ref<{ items: any[]; total: number; }>({ total: 0, items: [] });
const loading = ref(false);
const storeKeyword = ref('');
const nickName = ref('');
@@ -34,16 +35,18 @@ const page = ref(1);
const pageSize = ref(20);
const openingAccountId = ref(0);
const commissionDrawerRef = ref<InstanceType<typeof SalespersonCommissionDrawer>>();
const commissionDrawerRef =
ref<InstanceType<typeof SalespersonCommissionDrawer>>();
const invitedDrawerRef = ref<InstanceType<typeof SalespersonInvitedDrawer>>();
const [QrCodePreviewModal, QrCodePreviewApi] = useVbenModal({
connectedComponent: QrCodePreview,
});
const [SalespersonCreateModalComponent, SalespersonCreateModalApi] = useVbenModal({
connectedComponent: SalespersonCreateModal,
});
const [SalespersonCreateModalComponent, SalespersonCreateModalApi] =
useVbenModal({
connectedComponent: SalespersonCreateModal,
});
async function getList() {
loading.value = true;
@@ -165,82 +168,94 @@ function tcmBaseLabel(type: number) {
v-else
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<Card
v-for="item in listData.items"
:key="item.id"
:loading="loading"
class="rounded-lg shadow-sm"
>
<div class="mb-3 flex items-start justify-between gap-2">
<div>
<h3 class="text-lg font-semibold">{{ item.nick_name || '未知' }}</h3>
<p class="text-sm text-gray-500">{{ item.store_name }}</p>
</div>
<Space direction="vertical" align="end" size="small">
<Tag :color="splitTypeColor(item.split_type)">
西药·{{ splitTypeLabel(item.split_type) }}
<template v-if="Number(item.split_type) === 1">
· {{ item.split }}%
</template>
</Tag>
<Tag v-if="Number(item.tcm_split) > 0" color="green">
中药·{{ item.tcm_split }}%·{{ tcmBaseLabel(item.tcm_base_type) }}
</Tag>
<Tag :color="item.has_admin_account ? 'success' : 'default'">
{{ item.has_admin_account ? '已开通后台' : '未开通后台' }}
</Tag>
</Space>
</div>
<div class="mb-3 space-y-1 text-sm text-gray-600">
<div><SensitiveText :record="item" field="phone" /></div>
<div v-if="item.parent_nick_name" class="text-xs text-gray-500">
邀请人:{{ item.parent_nick_name }}
</div>
<Popover trigger="click" title="获客记录">
<template #content>
<div class="w-[420px]">
<SalespersonUserBindDrawer :open="true" :salesperson-id="item.id" />
</div>
</template>
<div class="flex cursor-pointer justify-between hover:text-blue-500">
<span>累计获客</span>
<span class="font-medium text-blue-600">{{
item.count?.user_number ?? 0
}}</span>
<!-- 推广员卡片:玻璃外壳 + 悬停微光;Skeleton 承接原 antd Card 的 loading 占位,刷新列表时行为不变 -->
<GlassCard v-for="item in listData.items" :key="item.id" hoverable>
<Skeleton :loading="loading" active>
<div class="mb-3 flex items-start justify-between gap-2">
<div>
<h3 class="text-lg font-semibold">
{{ item.nick_name || '未知' }}
</h3>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{{ item.store_name }}
</p>
</div>
</Popover>
<div class="flex justify-between">
<span>待结算</span>
<span class="text-orange-600">¥{{ item.count?.pending_amount || '0.00' }}</span>
<Space direction="vertical" align="end" size="small">
<Tag :color="splitTypeColor(item.split_type)">
西药·{{ splitTypeLabel(item.split_type) }}
<template v-if="Number(item.split_type) === 1">
· {{ item.split }}%
</template>
</Tag>
<Tag v-if="Number(item.tcm_split) > 0" color="green">
中药·{{ item.tcm_split }}%·{{
tcmBaseLabel(item.tcm_base_type)
}}
</Tag>
<Tag :color="item.has_admin_account ? 'success' : 'default'">
{{ item.has_admin_account ? '已开通后台' : '未开通后台' }}
</Tag>
</Space>
</div>
</div>
<Space wrap>
<Button size="small" @click="openEditModal(item)">编辑</Button>
<Button size="small" type="primary" @click="openCommission(item)">
服务费与结算
</Button>
<Button size="small" @click="invitedDrawerRef?.open(item)">
下级推广员
</Button>
<Button
v-if="item.qr_code?.qr_code"
size="small"
@click="openQrCode(item)"
>
二维码
</Button>
<Button
v-if="!item.has_admin_account"
size="small"
:loading="openingAccountId === item.id"
@click="handleOpenAdminAccount(item)"
>
开通后台
</Button>
</Space>
</Card>
<div class="mb-3 space-y-1 text-sm text-[hsl(var(--foreground)/0.8)]">
<div><SensitiveText :record="item" field="phone" /></div>
<div
v-if="item.parent_nick_name"
class="text-xs text-[hsl(var(--muted-foreground))]"
>
邀请人:{{ item.parent_nick_name }}
</div>
<Popover trigger="click" title="获客记录">
<template #content>
<div class="w-[420px]">
<SalespersonUserBindDrawer
:open="true"
:salesperson-id="item.id"
/>
</div>
</template>
<div
class="flex cursor-pointer justify-between hover:text-[hsl(var(--primary))]"
>
<span>累计获客</span>
<span class="font-medium text-[hsl(var(--primary))]">{{
item.count?.user_number ?? 0
}}</span>
</div>
</Popover>
<div class="flex justify-between">
<span>待结算</span>
<span class="text-[hsl(var(--warning))]">¥{{ item.count?.pending_amount || '0.00' }}</span>
</div>
</div>
<Space wrap>
<Button size="small" @click="openEditModal(item)">编辑</Button>
<Button size="small" type="primary" @click="openCommission(item)">
服务费与结算
</Button>
<Button size="small" @click="invitedDrawerRef?.open(item)">
下级推广员
</Button>
<Button
v-if="item.qr_code?.qr_code"
size="small"
@click="openQrCode(item)"
>
二维码
</Button>
<Button
v-if="!item.has_admin_account"
size="small"
:loading="openingAccountId === item.id"
@click="handleOpenAdminAccount(item)"
>
开通后台
</Button>
</Space>
</Skeleton>
</GlassCard>
</div>
</Page>
</template>

View File

@@ -25,3 +25,14 @@ export async function getPrescriptionSourceApi(data: any) {
export async function getPrescriptionCheckStatusApi(data: any) {
return requestClient.get<any>(`${prefix}check-status`, { params: data });
}
/**
* 管理端修正异常处方状态(过期未审 → 待审/已拒绝)
*/
export async function changePrescriptionStatusApi(data: {
id: number;
reason: string;
status: number;
}) {
return requestClient.post<any>(`${prefix}change-status`, data);
}

View File

@@ -8,15 +8,23 @@
*/
import type { ActionItem } from '#/components/table-action';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue';
import { Empty, message, Spin } from 'ant-design-vue';
import { TableAction } from '#/components/table-action';
import { CardActions } from '#/components/table-action';
import { pillStyle, tagColorHex } from '#/utils/tagColor';
import { getPrescriptionListApi } from '../api';
import {
canChangePrescriptionStatus,
PRESCRIPTION_IS_ONLINE_FALLBACK,
PRESCRIPTION_IS_ONLINE_MAP,
PRESCRIPTION_ORDER_TYPE_MAP,
@@ -29,6 +37,8 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
/** 异常处方改状态 */
changeStatus: [row: Record<string, any>];
/** 查看处方详情(复用父级弹窗) */
openDetail: [prescriptionId: number];
/** 处方溯源(复用父级弹窗) */
@@ -63,25 +73,41 @@ function statusMeta(row: Record<string, any>) {
/** 处方来源元数据(未知兜底线下) */
function isOnlineMeta(row: Record<string, any>) {
return PRESCRIPTION_IS_ONLINE_MAP[Number(row?.is_online)] ?? PRESCRIPTION_IS_ONLINE_FALLBACK;
return (
PRESCRIPTION_IS_ONLINE_MAP[Number(row?.is_online)] ??
PRESCRIPTION_IS_ONLINE_FALLBACK
);
}
/** 行操作:与列表 #action 槽一致(查看处方/处方溯源),通过 emit 复用父级弹窗 */
function buildActions(row: Record<string, any>): ActionItem[] {
return [
{
label: '查看处方',
type: 'link',
icon: 'marketeq:eye',
onClick: () => emit('openDetail', Number(row.id)),
},
{
label: '处方溯源',
type: 'link',
icon: 'mdi:timeline-text',
onClick: () => emit('openSource', Number(row.id)),
},
];
/** 行操作:主按钮 + 更多(CardActions),与列表语义一致 */
function buildCardActions(row: Record<string, any>): {
actions: ActionItem[];
dropDownActions: ActionItem[];
} {
return {
actions: [
{
label: '查看处方',
type: 'link',
icon: 'marketeq:eye',
onClick: () => emit('openDetail', Number(row.id)),
},
{
label: '处方溯源',
type: 'link',
icon: 'mdi:timeline-text',
onClick: () => emit('openSource', Number(row.id)),
},
{
label: '改状态',
type: 'link',
icon: 'ant-design:edit-outlined',
ifShow: canChangePrescriptionStatus(row),
onClick: () => emit('changeStatus', row),
},
],
dropDownActions: [],
};
}
/**
@@ -99,10 +125,12 @@ async function fetchPage(targetPage: number, reset = false) {
const res = await getPrescriptionListApi({
page: targetPage,
pageSize: 12,
...(props.formValues || {}),
...props.formValues,
});
if (seq !== requestSeq) return;
items.value = reset ? (res?.items ?? []) : [...items.value, ...(res?.items ?? [])];
items.value = reset
? (res?.items ?? [])
: [...items.value, ...(res?.items ?? [])];
page.value = res?.page ?? targetPage;
pageCount.value = res?.page_count ?? 1;
} catch (error) {
@@ -133,7 +161,7 @@ async function refreshKeepPage() {
const res = await getPrescriptionListApi({
page: 1,
pageSize: loaded,
...(props.formValues || {}),
...props.formValues,
});
if (seq !== requestSeq) return;
items.value = res?.items ?? [];
@@ -194,7 +222,7 @@ onBeforeUnmount(() => {
<div v-if="items.length" class="rx-card-grid">
<div v-for="row in items" :key="row.id" class="rx-card">
<!-- 角落径向荧光(与提现管理统计岛同款质感装饰) -->
<div class="rx-card__glow" />
<div class="rx-card__glow"></div>
<!-- 头部:诊所名(可点)+ 状态彩点 -->
<div class="rx-card__head">
<button
@@ -203,10 +231,14 @@ onBeforeUnmount(() => {
class="rx-card__store"
@click="emit('openStore', Number(row.store?.id || row.store_id))"
>
<span class="rx-card__store-name">{{ row.store?.name || '—' }}</span>
<span class="rx-card__store-name">{{
row.store?.name || '—'
}}</span>
<span class="rx-card__store-arrow">›</span>
</button>
<span v-else class="rx-card__store-name">{{ row.store?.name || '—' }}</span>
<span v-else class="rx-card__store-name">{{
row.store?.name || '—'
}}</span>
<span
v-if="statusMeta(row)"
class="rx-card__status"
@@ -227,7 +259,11 @@ onBeforeUnmount(() => {
</div>
<!-- 语义胶囊:处方类型 / 来源 -->
<div class="rx-card__pills">
<span v-if="typeMeta(row)" class="rx-pill" :style="pillStyle(typeMeta(row)!.color)">
<span
v-if="typeMeta(row)"
class="rx-pill"
:style="pillStyle(typeMeta(row)!.color)"
>
{{ typeMeta(row)!.label }}
</span>
<span class="rx-pill" :style="pillStyle(isOnlineMeta(row).color)">
@@ -246,21 +282,31 @@ onBeforeUnmount(() => {
</span>
</div>
<!-- 拒绝原因(仅已拒绝时显示) -->
<div v-if="Number(row.status) === 2 && row.reject_reason" class="rx-card__reject">
<div
v-if="Number(row.status) === 2 && row.reject_reason"
class="rx-card__reject"
>
拒绝原因:{{ row.reject_reason }}
</div>
<!-- 弹性占位:高度富余时把 footer 推到底(同行等高卡对齐),紧凑时保底 8px 间距 -->
<div class="rx-card__spacer"></div>
<!-- 底部操作:与列表 #action 槽同语义 -->
<!-- 底部操作:主按钮 + 更多,对齐商品订单 CardActions -->
<div class="rx-card__footer">
<TableAction :actions="buildActions(row)" :drop-down-actions="[]" />
<CardActions
:actions="buildCardActions(row).actions"
:drop-down-actions="buildCardActions(row).dropDownActions"
:max-visible="1"
/>
</div>
</div>
</div>
<Empty v-else-if="!loading" description="暂无处方" />
</Spin>
<div ref="sentinelEl" class="h-8"></div>
<div v-if="loadingMore" class="py-3 text-center text-xs text-[hsl(var(--muted-foreground))]">
<div
v-if="loadingMore"
class="py-3 text-center text-xs text-[hsl(var(--muted-foreground))]"
>
加载中…
</div>
<div
@@ -275,6 +321,7 @@ onBeforeUnmount(() => {
<style scoped>
.rx-card-scroll {
height: 100%;
/* 左右留 6px:卡片 hover 阴影不被容器裁出切边 */
padding: 4px 6px 20px;
overflow: auto;
@@ -299,9 +346,9 @@ onBeforeUnmount(() => {
hsl(var(--primary) / 4%),
hsl(var(--card, var(--background)))
);
backdrop-filter: blur(8px);
border: 1px solid hsl(var(--border));
border-radius: 12px;
backdrop-filter: blur(8px);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
@@ -324,7 +371,11 @@ onBeforeUnmount(() => {
width: 110px;
height: 110px;
pointer-events: none;
background: radial-gradient(circle, hsl(var(--primary) / 18%) 0%, transparent 70%);
background: radial-gradient(
circle,
hsl(var(--primary) / 18%) 0%,
transparent 70%
);
opacity: 0.4;
transition: opacity 0.2s ease;
}
@@ -342,9 +393,9 @@ onBeforeUnmount(() => {
.rx-card__store {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 2px;
align-items: center;
min-width: 0;
padding: 0;
cursor: pointer;
background: none;
@@ -353,11 +404,11 @@ onBeforeUnmount(() => {
.rx-card__store-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.2px;
color: hsl(var(--foreground));
text-overflow: ellipsis;
letter-spacing: -0.2px;
white-space: nowrap;
}
@@ -444,10 +495,10 @@ onBeforeUnmount(() => {
.rx-kv > em {
overflow: hidden;
text-overflow: ellipsis;
font-style: normal;
font-weight: 500;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,91 @@
<script lang="ts" setup>
/**
* 异常处方改状态弹窗:仅过期未审 / 超时自动失效 → 待审(0) 或 已拒绝(2)
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Form, FormItem, Input, message, Select } from 'ant-design-vue';
import { changePrescriptionStatusApi } from '../api';
import { PRESCRIPTION_CHANGE_STATUS_OPTIONS } from '../config/constants';
const row = ref<null | Record<string, any>>(null);
const targetStatus = ref<number>(0);
const reason = ref('');
const onSuccess = ref<(() => void) | null>(null);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
if (!row.value?.id) return;
if (reason.value.trim() === '') {
message.error('请填写修改原因');
return;
}
modalApi.setState({ confirmLoading: true, loading: true });
try {
await changePrescriptionStatusApi({
id: Number(row.value.id),
status: Number(targetStatus.value),
reason: reason.value.trim(),
});
message.success('状态已更新');
onSuccess.value?.();
modalApi.close();
} catch (error) {
console.error(error);
} finally {
modalApi.setState({ confirmLoading: false, loading: false });
}
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{
onSuccess?: () => void;
row: Record<string, any>;
}>();
row.value = data?.row ?? null;
onSuccess.value = data?.onSuccess ?? null;
// 超时已拒绝默认改回待审;过期待审默认仍待审(重建队列)
targetStatus.value = Number(row.value?.status) === 2 ? 0 : 0;
reason.value = '';
} else {
row.value = null;
onSuccess.value = null;
reason.value = '';
}
},
});
</script>
<template>
<Modal title="修正处方状态" class="w-[480px]">
<Form layout="vertical">
<FormItem label="处方号">
<span>{{ row?.prescription_no || row?.id || '—' }}</span>
</FormItem>
<FormItem label="目标状态" required>
<Select
v-model:value="targetStatus"
:options="PRESCRIPTION_CHANGE_STATUS_OPTIONS"
class="w-full"
placeholder="请选择"
/>
</FormItem>
<FormItem label="修改原因" required>
<Input.TextArea
v-model:value="reason"
:rows="3"
placeholder="说明系统异常或处理依据,便于事后追溯"
allow-clear
/>
</FormItem>
</Form>
</Modal>
</template>

View File

@@ -52,3 +52,32 @@ export const PRESCRIPTION_IS_ONLINE_OPTIONS = [
{ label: '在线复诊(药店)', value: 3 },
{ label: '线下就诊', value: 0 },
];
/** 异常处方可改目标状态(禁止直接改已通过) */
export const PRESCRIPTION_CHANGE_STATUS_OPTIONS = [
{ label: '待审核', value: 0 },
{ label: '已拒绝', value: 2 },
];
/**
* 是否可改状态:过期仍待审,或超时自动失效的已拒绝
* auto_expire_time 后端已格式化为时间字符串时用 Date 解析
*/
export function canChangePrescriptionStatus(row: Record<string, any>): boolean {
const status = Number(row?.status);
const rejectReason = String(row?.reject_reason || '');
if (status === 2 && rejectReason.includes('处方审核超时自动失效')) {
return true;
}
if (status !== 0) return false;
const raw = row?.auto_expire_time;
if (raw == null || raw === '' || raw === 0) return false;
let expireTs = 0;
if (typeof raw === 'number') {
expireTs = raw;
} else {
const t = Date.parse(String(raw).replaceAll(/-/g, '/'));
expireTs = Number.isFinite(t) ? Math.floor(t / 1000) : 0;
}
return expireTs > 0 && expireTs < Math.floor(Date.now() / 1000);
}

View File

@@ -1,7 +1,7 @@
import type { VbenFormProps } from '#/adapter/form';
import { getOrderPrescriptionTypeOption } from '#/views/business/order/product-order/api';
import { monthToTodayRangeString } from '#/utils/search-time-range';
import { getOrderPrescriptionTypeOption } from '#/views/business/order/product-order/api';
import { PRESCRIPTION_IS_ONLINE_OPTIONS } from './constants';
@@ -17,7 +17,8 @@ export const formOptions: VbenFormProps = {
},
},
// 响应式多列:抽出 grid 后 vben form 默认单列(一行一个字段),必须显式给栅格
wrapperClass: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
wrapperClass:
'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
schema: [
{
// 放第一位:时间范围有默认值(本月至今),默认收起时必须始终可见,否则用户不知道当前查的是哪段数据
@@ -91,6 +92,31 @@ export const formOptions: VbenFormProps = {
fieldName: 'is_online',
label: '处方来源',
},
{
// 门店多选:与商品订单同组件,后端 resolveRequestStoreIds + whereIn
component: 'StoreMultiSearch',
componentProps: {
placeholder: '输入名称或拼音首拼',
},
defaultValue: [],
fieldName: 'store_ids',
modelPropName: 'value',
label: '门店',
},
{
// 是否过期:后端按 auto_expire_time 与当前时间比较(索引范围,无重关联)
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: '已过期', value: 1 },
{ label: '未过期', value: 0 },
],
placeholder: '请选择',
},
fieldName: 'is_expired',
label: '是否过期',
},
],
// 控制表单是否显示折叠按钮
showCollapseButton: true,

View File

@@ -17,18 +17,20 @@ import { Button, Tabs, Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { useFreshSearchTime } from '#/composables/use-fresh-search-time';
import { TableAction } from '#/components/table-action';
import { monthToTodayRangeString } from '#/utils/search-time-range';
import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch';
import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
import StoreNameLink from '#/components/store-card/StoreNameLink.vue';
import { TableAction } from '#/components/table-action';
import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch';
import { useFreshSearchTime } from '#/composables/use-fresh-search-time';
import { monthToTodayRangeString } from '#/utils/search-time-range';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import { getPrescriptionListApi } from './api';
import ChangeStatusModal from './components/change-status-modal.vue';
import PrescriptionOrderCardList from './components/PrescriptionOrderCardList.vue';
import PrescriptionSource from './components/source.vue';
import {
canChangePrescriptionStatus,
PRESCRIPTION_IS_ONLINE_FALLBACK,
PRESCRIPTION_IS_ONLINE_MAP,
PRESCRIPTION_ORDER_TYPE_MAP,
@@ -37,6 +39,10 @@ import {
import { formOptions } from './config/search';
import { gridOptions as baseGridOptions } from './config/table';
const cardListRef = ref<InstanceType<typeof PrescriptionOrderCardList> | null>(
null,
);
const hasTopTableDropDownActions = ref(false);
/** 审核状态 Tab:0待审核 / 1已通过 / 2已拒绝 */
const statusTab = ref('0');
@@ -89,7 +95,7 @@ async function readLiveSearchValues(): Promise<Record<string, any>> {
try {
await nextTick();
const live = await searchFormApi.getValues();
return { ...(live || {}) };
return { ...live };
} catch {
return { ...searchValues.value };
}
@@ -141,7 +147,11 @@ const [Grid, gridApi] = useVbenVxeGrid({
proxyConfig: {
ajax: {
/** 始终合并顶部统一搜索条件 + 状态 Tab */
query: async ({ page }: { page: { currentPage: number; pageSize: number } }) => {
query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
searchValues.value = await readLiveSearchValues();
return await getPrescriptionListApi({
page: page.currentPage,
@@ -168,7 +178,8 @@ const freshSearchTime = useFreshSearchTime({
},
});
freshSearchTimeBridge.resetSearchTime = freshSearchTime.resetSearchTime;
freshSearchTimeBridge.markSearchTimeTouched = freshSearchTime.markSearchTimeTouched;
freshSearchTimeBridge.markSearchTimeTouched =
freshSearchTime.markSearchTimeTouched;
// Tab 切换:列表模式重查(卡片模式由 cardFormValues computed 触发 CardList watch)
watch(statusTab, () => {
@@ -196,6 +207,10 @@ const [PrescriptionSourceModal, PrescriptionSourceModalApi] = useVbenModal({
connectedComponent: PrescriptionSource,
});
const [ChangeStatusModalComp, ChangeStatusModalApi] = useVbenModal({
connectedComponent: ChangeStatusModal,
});
const openPrescriptionDetail = (values: number) => {
PrescriptionDetailModalApi.setData({ values });
PrescriptionDetailModalApi.open();
@@ -206,6 +221,52 @@ const openPrescriptionSourceModal = (id: number) => {
PrescriptionSourceModalApi.open();
};
/** 操作后刷新:列表保留当前页;卡片走组件 reload(若有)或靠 formValues 触发 */
function refreshAfterMutation() {
if (viewMode.value === 'list') {
gridApi.query();
} else {
(cardListRef.value as any)?.reload?.() ??
(cardListRef.value as any)?.refreshKeepPage?.();
}
}
const openChangeStatus = (row: Record<string, any>) => {
ChangeStatusModalApi.setData({
row,
onSuccess: refreshAfterMutation,
});
ChangeStatusModalApi.open();
};
/** 列表/卡片共用行操作 */
function buildRowActions(row: Record<string, any>) {
return {
actions: [
{
label: '查看处方',
type: 'link' as const,
icon: 'marketeq:eye',
onClick: () => openPrescriptionDetail(row.id),
},
{
label: '处方溯源',
type: 'link' as const,
icon: 'mdi:timeline-text',
onClick: () => openPrescriptionSourceModal(row.id),
},
{
label: '改状态',
type: 'link' as const,
icon: 'ant-design:edit-outlined',
ifShow: canChangePrescriptionStatus(row),
onClick: () => openChangeStatus(row),
},
],
dropDownActions: [] as any[],
};
}
/** 列表槽用元数据取值 helper:v-if 双重索引无法类型收窄,统一在这里兜底 */
function rowTypeMeta(row: Record<string, any>) {
return PRESCRIPTION_ORDER_TYPE_MAP[Number(row?.prescription_type)] ?? null;
@@ -214,7 +275,10 @@ function rowStatusMeta(row: Record<string, any>) {
return PRESCRIPTION_STATUS_MAP[Number(row?.status)] ?? null;
}
function rowIsOnlineMeta(row: Record<string, any>) {
return PRESCRIPTION_IS_ONLINE_MAP[Number(row?.is_online)] ?? PRESCRIPTION_IS_ONLINE_FALLBACK;
return (
PRESCRIPTION_IS_ONLINE_MAP[Number(row?.is_online)] ??
PRESCRIPTION_IS_ONLINE_FALLBACK
);
}
</script>
@@ -222,6 +286,7 @@ function rowIsOnlineMeta(row: Record<string, any>) {
<Page auto-content-height title="处方管理">
<PrescriptionDetailModal />
<PrescriptionSourceModal />
<ChangeStatusModalComp />
<StoreCardModalComp />
<div class="mb-3">
<Tabs :active-key="statusTab" @change="onStatusTabChange">
@@ -231,7 +296,9 @@ function rowIsOnlineMeta(row: Record<string, any>) {
</Tabs>
</div>
<!-- 顶部统一搜索(双视图共用,替代原 grid 内嵌表单) -->
<div class="mb-3 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card,var(--background)))] p-3">
<div
class="mb-3 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card,var(--background)))] p-3"
>
<SearchForm />
</div>
<!-- 视图切换:列表 / 卡片看板 -->
@@ -242,9 +309,7 @@ function rowIsOnlineMeta(row: Record<string, any>) {
<template #toolbar-actions>
<TableAction :actions="[]" :drop-down-actions="[]">
<template #more>
<Button style="margin-left: 16px">
批量操作
</Button>
<Button style="margin-left: 16px"> 批量操作 </Button>
</template>
</TableAction>
</template>
@@ -266,8 +331,8 @@ function rowIsOnlineMeta(row: Record<string, any>) {
<Tag v-if="rowStatusMeta(row)" :color="rowStatusMeta(row)!.color">
{{ rowStatusMeta(row)!.label
}}<template v-if="row.status === 2 && row.reject_reason">
:{{ row.reject_reason }}</template
>
:{{ row.reject_reason }}
</template>
</Tag>
</div>
</template>
@@ -279,31 +344,20 @@ function rowIsOnlineMeta(row: Record<string, any>) {
<template #toolbar-tools></template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '查看处方',
type: 'link',
icon: 'marketeq:eye',
onClick: openPrescriptionDetail.bind(null, row.id),
},
{
label: '处方溯源',
type: 'link',
icon: 'mdi:timeline-text',
onClick: openPrescriptionSourceModal.bind(null, row.id),
},
]"
:drop-down-actions="[]"
:actions="buildRowActions(row).actions"
:drop-down-actions="buildRowActions(row).dropDownActions"
/>
</template>
</Grid>
<!-- 卡片看板视图:展示内容与列表一致,弹窗复用父级(纯查看操作,无需操作后刷新句柄) -->
<!-- 卡片看板视图:展示内容与列表一致,弹窗复用父级 -->
<div v-else class="rx-card-wrap">
<PrescriptionOrderCardList
ref="cardListRef"
:form-values="cardFormValues"
@open-detail="openPrescriptionDetail"
@open-source="openPrescriptionSourceModal"
@open-store="openStoreCard"
@change-status="openChangeStatus"
/>
</div>
</Page>

View File

@@ -11,12 +11,25 @@
*/
import { onMounted, ref, watch } from 'vue';
import { Button, Empty, Image, message, Pagination, Spin, Switch, Tag } from 'ant-design-vue';
import {
Button,
Empty,
Image,
message,
Pagination,
Spin,
Switch,
Tag,
} from 'ant-design-vue';
import { CardActions } from '#/components/table-action';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import SalespersonCommissionPanel from '#/views/business/order/components/SalespersonCommissionPanel.vue';
import { CardActions } from '#/components/table-action';
import {
copyShipAddressText,
formatShipAddressCopyText,
} from '#/util/ship-address-copy';
import { pillStyle, tagColorHex } from '#/utils/tagColor';
import SalespersonCommissionPanel from '#/views/business/order/components/SalespersonCommissionPanel.vue';
import { getOrderList } from '../api';
import {
@@ -28,8 +41,7 @@ import {
} from '../config/constants';
const props = defineProps<{
/** 顶部统一搜索条件(含 search_time / patient_keyword / doctor_keyword 等) */
formValues?: Record<string, any>;
accruingOrderIds?: number[];
/** 父级构造行操作(与列表 #action 槽同一份定义) */
buildRowActions: (row: Record<string, any>) => {
actions: any[];
@@ -38,22 +50,25 @@ const props = defineProps<{
/** 金额是否可点调价(与列表 price-info 槽一致) */
canPercentAdjust?: (row: Record<string, any>) => boolean;
canViewInviterCommission?: boolean;
accruingOrderIds?: number[];
/** 顶部统一搜索条件(含 search_time / patient_keyword / doctor_keyword 等) */
formValues?: Record<string, any>;
reversingOrderIds?: number[];
}>();
const emit = defineEmits<{
openStore: [storeId: number];
openDoctor: [row: Record<string, any>];
openUserPatients: [payload: { userId: number; nickname: string; avatarurl: string }];
openPatient: [payload: { upId: number; patientName: string }];
openWarehouse: [row: Record<string, any>, wh: { id: number; name: string }];
toggleFreeShipping: [row: Record<string, any>, checked: boolean];
percentAdjust: [row: Record<string, any>];
accrueSalesperson: [row: Record<string, any>];
goRegister: [row: Record<string, any>];
openChinaErpSyncLog: [row: Record<string, any>];
accrueSalesperson: [row: Record<string, any>];
openDoctor: [row: Record<string, any>];
openPatient: [payload: { patientName: string; upId: number; }];
openStore: [storeId: number];
openUserPatients: [
payload: { avatarurl: string; nickname: string; userId: number; },
];
openWarehouse: [row: Record<string, any>, wh: { id: number; name: string }];
percentAdjust: [row: Record<string, any>];
reverseSalesperson: [row: Record<string, any>, salespersonId?: number];
toggleFreeShipping: [row: Record<string, any>, checked: boolean];
}>();
const items = ref<any[]>([]);
@@ -99,12 +114,16 @@ function headTypeMeta(row: Record<string, any>) {
/** 首个商品(商品区首图/首名展示用) */
function firstItem(row: Record<string, any>) {
return Array.isArray(row.product_order_items) ? row.product_order_items[0] : null;
return Array.isArray(row.product_order_items)
? row.product_order_items[0]
: null;
}
/** 商品总件数(中药按剂量折算与明细 chips 同口径) */
function totalPieces(row: Record<string, any>) {
const list = Array.isArray(row.product_order_items) ? row.product_order_items : [];
const list = Array.isArray(row.product_order_items)
? row.product_order_items
: [];
return list.reduce((sum: number, item: any) => {
const factor =
Number(row.prescription_type) === 1 && Number(item.dosage) > 0
@@ -124,6 +143,19 @@ function expressAddress(row: Record<string, any>) {
return [region, detail].filter(Boolean).join(' ') || '—';
}
/** 一键复制收货信息 */
function copyCardShipAddress(row: Record<string, any>) {
const text = formatShipAddressCopyText(row.address, {
name: row.express_name || row.patient,
mobile: row.address?.mobile || row.patient_mobile,
express_name: row.express_name,
express_mobile: row.express_mobile,
express_region: row.express_region,
express_address: row.express_address,
});
void copyShipAddressText(text);
}
/** 挂号金额(与列表 formatRegisterAmount 同口径) */
function registerAmount(row: Record<string, any>) {
if (row.register_id == null || row.register_id === '') return '—';
@@ -156,7 +188,8 @@ function hasPayUser(row: Record<string, any>) {
/** 代付展示文案:[类型]名字(医生代付回落医生名,就诊人代付回落就诊人名) */
function payUserText(row: Record<string, any>) {
const t = Number(row.pay_user_type || 0);
const typeLabel = t === 2 ? '医生' : t === 3 ? '就诊人' : t === 1 ? '用户' : '—';
const typeLabel =
t === 2 ? '医生' : t === 3 ? '就诊人' : t === 1 ? '用户' : '—';
const nick = String(row.pay_user?.nickname || '').trim();
let name = nick;
if (!name && t === 2 && row.doctor?.name) name = String(row.doctor.name);
@@ -205,7 +238,7 @@ async function fetchPage(targetPage: number) {
const res = await getOrderList({
page: targetPage,
pageSize: pageSize.value,
...(props.formValues || {}),
...props.formValues,
});
if (seq !== requestSeq) return;
items.value = res?.items ?? [];
@@ -240,11 +273,11 @@ defineExpose({ reload, refreshKeepPage });
/** 翻页后回到卡片区顶部,避免停在页面最底部看不到新的一页 */
async function onPageChange(nextPage: number, nextSize: number) {
if (nextSize !== pageSize.value) {
if (nextSize === pageSize.value) {
page.value = nextPage;
} else {
pageSize.value = nextSize;
page.value = 1;
} else {
page.value = nextPage;
}
await fetchPage(page.value);
scrollEl.value?.scrollIntoView({ block: 'start' });
@@ -276,10 +309,14 @@ onMounted(() => {
class="po-card__store"
@click="emit('openStore', row.store.id)"
>
<span class="po-card__store-name">{{ row.store?.name || '—' }}</span>
<span class="po-card__store-name">{{
row.store?.name || '—'
}}</span>
<span class="po-card__store-arrow">›</span>
</button>
<span v-else class="po-card__store-name">{{ row.store?.name || '—' }}</span>
<span v-else class="po-card__store-name">{{
row.store?.name || '—'
}}</span>
<span
v-if="statusMeta(row)"
class="po-card__status"
@@ -299,7 +336,11 @@ onMounted(() => {
<span class="shrink-0">{{ row.created_at || '—' }}</span>
</div>
<div class="po-card__pills">
<span v-if="headTypeMeta(row)" class="po-pill" :style="pillStyle(headTypeMeta(row)!.color)">
<span
v-if="headTypeMeta(row)"
class="po-pill"
:style="pillStyle(headTypeMeta(row)!.color)"
>
{{ headTypeMeta(row)!.label }}
</span>
<span class="po-pill" :style="pillStyle(isOnlineMeta(row).color)">
@@ -312,10 +353,18 @@ onMounted(() => {
>
代支付
</span>
<span v-if="row.delivery_method === 1" class="po-pill" :style="pillStyle('blue')">
<span
v-if="row.delivery_method === 1"
class="po-pill"
:style="pillStyle('blue')"
>
到店自提
</span>
<span v-else-if="row.delivery_method === 0" class="po-pill" :style="pillStyle('green')">
<span
v-else-if="row.delivery_method === 0"
class="po-pill"
:style="pillStyle('green')"
>
上门快递
</span>
<span
@@ -328,7 +377,10 @@ onMounted(() => {
{{ row.is_sync_erp === 1 ? 'Erp已同步' : 'Erp未同步' }} ›
</span>
<template
v-if="Array.isArray(row.delivery_warehouses) && row.delivery_warehouses.length"
v-if="
Array.isArray(row.delivery_warehouses) &&
row.delivery_warehouses.length
"
>
<button
v-for="wh in row.delivery_warehouses"
@@ -360,12 +412,14 @@ onMounted(() => {
<div class="po-card__goods-name">
{{
firstItem(row)
? firstItem(row).display_drug_name || firstItem(row).drug_name
? firstItem(row).display_drug_name ||
firstItem(row).drug_name
: '无商品明细'
}}
</div>
<div v-if="firstItem(row)" class="po-card__goods-count">
{{ row.product_order_items.length }} 种 · 共 {{ totalPieces(row) }} 件
{{ row.product_order_items.length }} 种 · 共
{{ totalPieces(row) }} 件
<span class="po-card__goods-toggle">
{{ expandedIds.has(Number(row.id)) ? '收起' : '展开' }}
<i :class="expandedIds.has(Number(row.id)) ? 'up' : ''">›</i>
@@ -388,7 +442,11 @@ onMounted(() => {
</div>
<!-- 展开后的全部商品 chips -->
<div
v-if="expandedIds.has(Number(row.id)) && Array.isArray(row.product_order_items) && row.product_order_items.length"
v-if="
expandedIds.has(Number(row.id)) &&
Array.isArray(row.product_order_items) &&
row.product_order_items.length
"
class="po-card__chips"
>
<span
@@ -450,7 +508,7 @@ onMounted(() => {
<!-- 行5 收货内联行:收件人 + 电话 + 地址一行截断 -->
<div class="po-card__express">
<i>收货</i>
<span class="truncate">
<span class="min-w-0 truncate">
{{ row.address?.name || row.express_name || row.patient || '—' }}
<SensitiveText
:record="row.address"
@@ -459,9 +517,21 @@ onMounted(() => {
/>
· {{ expressAddress(row) }}
</span>
<Button
v-if="Number(row.delivery_method) !== 1"
type="link"
size="small"
class="shrink-0 px-0"
@click.stop="copyCardShipAddress(row)"
>
复制
</Button>
</div>
<!-- 行6 推广员内联行(有则显示) -->
<div v-if="row.salesperson?.id" class="po-card__express po-card__salesperson">
<div
v-if="row.salesperson?.id"
class="po-card__express po-card__salesperson"
>
<i>推广</i>
<SalespersonCommissionPanel
:row="row"
@@ -471,15 +541,21 @@ onMounted(() => {
@accrue="(r) => emit('accrueSalesperson', r)"
@reverse="(r, spId) => emit('reverseSalesperson', r, spId)"
>
<div class="po-card__salesperson-inline flex min-w-0 flex-1 items-center gap-1.5">
<div
class="po-card__salesperson-inline flex min-w-0 flex-1 items-center gap-1.5"
>
<Image
:src="row.salesperson.avatar || '/img/user-default-avatar.png'"
:src="
row.salesperson.avatar || '/img/user-default-avatar.png'
"
:width="16"
:height="16"
:preview="false"
class="shrink-0 rounded-full object-cover"
/>
<span class="truncate">{{ row.salesperson.nick_name || '—' }}</span>
<span class="truncate">{{
row.salesperson.nick_name || '—'
}}</span>
<span class="po-card__salesperson-status">
{{ row.salesperson.commission_status_text || '—' }}
</span>
@@ -542,7 +618,10 @@ onMounted(() => {
checked-children="包邮"
un-checked-children="不包邮"
size="small"
@change="(checked: any) => emit('toggleFreeShipping', row, Boolean(checked))"
@change="
(checked: any) =>
emit('toggleFreeShipping', row, Boolean(checked))
"
/>
</label>
<!-- 卡片交互与列表解耦:只露出「详情」主操作,其余收进「···」菜单,避免按钮堆叠 -->
@@ -619,9 +698,9 @@ onMounted(() => {
.po-card__store {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 2px;
align-items: center;
min-width: 0;
padding: 0;
cursor: pointer;
background: none;
@@ -630,10 +709,10 @@ onMounted(() => {
.po-card__store-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 16px;
font-weight: 600;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -742,10 +821,10 @@ onMounted(() => {
.po-card__goods-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 500;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -840,9 +919,9 @@ onMounted(() => {
.po-kv > em {
overflow: hidden;
text-overflow: ellipsis;
font-style: normal;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -850,9 +929,9 @@ onMounted(() => {
max-width: 180px;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
color: hsl(var(--primary));
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
background: none;
@@ -916,6 +995,7 @@ onMounted(() => {
.po-card__amount-parts {
display: grid;
/* 药品/挂号/诊疗/采购/运费文案短,三列更紧凑不浪费横向空间 */
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
gap: 4px 12px;

View File

@@ -1,19 +1,46 @@
<script lang="ts" setup>
import type { QuickDiscountOption } from '#/utils/pricePercentAdjust';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { Button, Card, Descriptions, Image, Modal as AntdModal, Space, Tabs, Tag, message } from 'ant-design-vue';
import {
Modal as AntdModal,
Button,
Card,
Descriptions,
Image,
message,
Space,
Tabs,
Tag,
} from 'ant-design-vue';
import {
adjustOrderPercent,
getOrderPriceAdjustConfig,
} from '#/api/order/priceAdjust';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import {
copyShipAddressText,
formatShipAddressCopyText,
} from '#/util/ship-address-copy';
import { formatDoctorOrderText } from '#/utils/prescriptionDoctorOrder';
import {
formatPriceDiscountLabel,
normalizeQuickOptions,
} from '#/utils/pricePercentAdjust';
import { retrySettleApi } from '#/views/business/order/api/order-ops';
import OrderPricePercentAdjustDrawer from '#/views/business/order/components/OrderPricePercentAdjustDrawer.vue';
import {
openOrderPopup,
ORDER_POPUP_Z_INDEX,
} from '#/views/business/order/utils/popup-z-index';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import OrderPricePercentAdjustDrawer from '#/views/business/order/components/OrderPricePercentAdjustDrawer.vue';
import { getOrderPriceAdjustConfig, adjustOrderPercent } from '#/api/order/priceAdjust';
import type { QuickDiscountOption } from '#/utils/pricePercentAdjust';
import { formatPriceDiscountLabel, normalizeQuickOptions } from '#/utils/pricePercentAdjust';
import {
expressDetailByOrderId,
getOrderInfo,
@@ -21,12 +48,6 @@ import {
recalculateMarketPriceApi,
syncLegacyShipmentApi,
} from '../api';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { formatDoctorOrderText } from '#/utils/prescriptionDoctorOrder';
import {
ORDER_POPUP_Z_INDEX,
openOrderPopup,
} from '#/views/business/order/utils/popup-z-index';
import LogisticsModal from './logistics-modal.vue';
defineOptions({
@@ -48,11 +69,11 @@ const retrySettling = ref(false);
/** 从溯源抽屉打开:底下已有溯源,底栏不再重复露出「查看溯源」 */
const fromTrace = ref(false);
/** 页面级打开溯源(对齐就诊人:父层不关,子层叠上去) */
const onOpenTraceFn = ref<null | ((payload: Record<string, any>) => void)>(
const onOpenTraceFn = ref<((payload: Record<string, any>) => void) | null>(
null,
);
/** 页面级打开处方详情,供底部「打印处方」复用已有打印页 */
const onOpenPrescriptionFn = ref<null | ((pId: number) => void)>(null);
const onOpenPrescriptionFn = ref<((pId: number) => void) | null>(null);
/**
* 超管可见:已支付且未取消/未退款时可重新结算(补齐残缺分账)
@@ -205,8 +226,11 @@ async function handleRecalculateMarketPrice() {
return;
}
try {
const preview = await previewRecalculateMarketPriceApi({ order_id: orderId });
const oldPrice = preview?.old_market_price ?? data.value?.market_price ?? '0.00';
const preview = await previewRecalculateMarketPriceApi({
order_id: orderId,
});
const oldPrice =
preview?.old_market_price ?? data.value?.market_price ?? '0.00';
const newPrice = preview?.expected_market_price ?? '0.00';
const allBuyZero = Boolean(preview?.all_buy_price_zero);
AntdModal.confirm({
@@ -228,8 +252,8 @@ async function handleRecalculateMarketPrice() {
gridApi.value?.reload?.();
},
});
} catch (err) {
console.error(err);
} catch (error) {
console.error(error);
}
}
@@ -319,13 +343,15 @@ const packageTabs = computed(() => {
const fromExpress = Array.isArray((expressDetail.value as any)?.packages)
? (expressDetail.value as any).packages
: [];
if (fromExpress.length) return fromExpress;
const fromOrder = Array.isArray(data.value?.packages) ? data.value.packages : [];
if (fromExpress.length > 0) return fromExpress;
const fromOrder = Array.isArray(data.value?.packages)
? data.value.packages
: [];
return fromOrder;
});
const activePkg = ref('0');
/** 详情内「发本包」回调(由列表传入时仅平台包裹可发) */
const onShipPackage = ref<null | ((warehouseId: number) => void)>(null);
const onShipPackage = ref<((warehouseId: number) => void) | null>(null);
/**
* Tab 文案:有仓名才拼括号;空串不兜底「平台/仓」
@@ -362,6 +388,20 @@ function openLogisticsModal() {
);
}
/** 一键复制收货信息(名字 手机 省市区+详细) */
function copyOrderShipAddress() {
const row = data.value || {};
const text = formatShipAddressCopyText(row.address, {
name: row.express_name,
mobile: row.address?.mobile || row.express_mobile,
express_name: row.express_name,
express_mobile: row.express_mobile,
express_region: row.express_region,
express_address: row.express_address,
});
void copyShipAddressText(text);
}
/**
* 超管手动同步历史运单到分包裹表,成功后刷新详情与物流
*/
@@ -473,7 +513,11 @@ function prescriptionStatusColor() {
</Descriptions.Item>
<Descriptions.Item label="采购价">
{{ data.market_price || '0.00' }} 元
<Tag v-if="Number(data.market_price) === 0" color="error" class="ml-1">
<Tag
v-if="Number(data.market_price) === 0"
color="error"
class="ml-1"
>
采购价异常
</Tag>
<Button
@@ -544,10 +588,18 @@ function prescriptionStatusColor() {
<Descriptions.Item label="处方类型">
<Tag v-if="data.prescription_type === 1" color="orange">中药</Tag>
<Tag v-else-if="data.prescription_type === 2" color="blue">西药</Tag>
<Tag v-else-if="data.prescription_type === 3" color="purple">保健食品</Tag>
<Tag v-else-if="data.prescription_type === 5" color="pink">产品服务包</Tag>
<Tag v-else-if="data.prescription_type === 6" color="cyan">非药品</Tag>
<Tag v-else-if="data.prescription_type === 7" color="geekblue">医疗器械</Tag>
<Tag v-else-if="data.prescription_type === 3" color="purple">
保健食品
</Tag>
<Tag v-else-if="data.prescription_type === 5" color="pink">
产品服务包
</Tag>
<Tag v-else-if="data.prescription_type === 6" color="cyan">
非药品
</Tag>
<Tag v-else-if="data.prescription_type === 7" color="geekblue">
医疗器械
</Tag>
<Tag v-else>其他</Tag>
</Descriptions.Item>
<Descriptions.Item v-if="showPrescriptionStatus" label="处方状态">
@@ -558,7 +610,20 @@ function prescriptionStatusColor() {
</Descriptions>
<div class="mt-4">
<h3>{{ deliveryMethod === 0 ? '收货人' : '就诊人' }}信息</h3>
<div class="flex items-center justify-between gap-2">
<h3 class="m-0">
{{ deliveryMethod === 0 ? '收货人' : '就诊人' }}信息
</h3>
<Button
v-if="deliveryMethod === 0"
type="link"
size="small"
class="px-0"
@click="copyOrderShipAddress"
>
一键复制
</Button>
</div>
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
@@ -585,19 +650,17 @@ function prescriptionStatusColor() {
</Descriptions.Item>
<Descriptions.Item v-if="deliveryMethod === 0" label="配送地址">
{{
(
[
data.address?.province,
data.address?.region || data.express_region,
data.address?.detail_address || data.express_address,
]
.filter(Boolean)
.join(' ') ||
[data.express_region, data.express_address]
.filter(Boolean)
.join(' ') ||
'—'
)
[
data.address?.province,
data.address?.region || data.express_region,
data.address?.detail_address || data.express_address,
]
.filter(Boolean)
.join(' ') ||
[data.express_region, data.express_address]
.filter(Boolean)
.join(' ') ||
'—'
}}
</Descriptions.Item>
<Descriptions.Item v-else label="自提地址">
@@ -619,7 +682,9 @@ function prescriptionStatusColor() {
class="tcm-detail-chip w-[220px] max-w-full flex-shrink-0 rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-600 dark:bg-gray-900"
>
<div class="font-medium">
<Tag v-if="Number(item.type) === 8" color="lime" class="mr-1">辅料</Tag>
<Tag v-if="Number(item.type) === 8" color="lime" class="mr-1">
辅料
</Tag>
【{{ item.drug?.drug_number }}】{{ item.drug_name }}
</div>
<div class="mt-1 text-gray-600 dark:text-gray-400">
@@ -661,7 +726,10 @@ function prescriptionStatusColor() {
</div>
<div>
<h4>{{ item.drug_name }}</h4>
<p>规格: {{ item.drug.specification || item.drug?.unit?.name || 'g' }}</p>
<p>
规格:
{{ item.drug.specification || item.drug?.unit?.name || 'g' }}
</p>
<p>数量: {{ item.number }}</p>
<p>单价: {{ item.price }} 元</p>
<p>
@@ -679,7 +747,14 @@ function prescriptionStatusColor() {
</div>
</Card>
</div>
<div class="mt-4" v-if="data.order_type !== 2 && data.order_type !== 3 && data.prescription.content">
<div
class="mt-4"
v-if="
data.order_type !== 2 &&
data.order_type !== 3 &&
data.prescription.content
"
>
<h3>处方信息</h3>
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
@@ -738,7 +813,11 @@ function prescriptionStatusColor() {
>
<div
class="mb-3 flex justify-end"
v-if="Number(pkg.is_send) === 0 && Number(pkg.warehouse_id) === 0 && onShipPackage"
v-if="
Number(pkg.is_send) === 0 &&
Number(pkg.warehouse_id) === 0 &&
onShipPackage
"
>
<Button
type="primary"
@@ -777,7 +856,12 @@ function prescriptionStatusColor() {
<div v-else class="text-gray-400 py-2">
本包裹尚未发货
<span v-if="Array.isArray(pkg.items) && pkg.items.length">
({{ pkg.items.map((p: any) => p.drug_name || p.name).filter(Boolean).join('、') }})
({{
pkg.items
.map((p: any) => p.drug_name || p.name)
.filter(Boolean)
.join('、')
}})
</span>
</div>
</Tabs.TabPane>
@@ -815,10 +899,7 @@ function prescriptionStatusColor() {
<Button v-if="canShowTrace" type="primary" @click="openOrderTrace">
查看溯源
</Button>
<Button
v-if="canPrintPrescription"
@click="openPrintPrescription"
>
<Button v-if="canPrintPrescription" @click="openPrintPrescription">
打印处方
</Button>
<Button

View File

@@ -2,7 +2,7 @@
/**
* 独立「物流动态」弹窗:只请求 express-detail,不展示整单其它信息。
* 仓名为空时 Tab 仅显示「包裹N」,与后端诊所开关抹名约定一致。
* 已发货包裹展示脱敏收件人/查件手机号,并支持修改手机号与快递单号。
* 已发货包裹展示脱敏收件人/查件手机号/收货地址,并支持修改手机号与快递单号。
* 第三方轨迹查询失败时展示 last_sync_error(快递100返回文案)。
*/
import { computed, reactive, ref } from 'vue';
@@ -11,21 +11,25 @@ import { useVbenModal } from '@vben/common-ui';
import {
Alert,
Modal as AModal,
Button,
Descriptions,
Form,
FormItem,
Input,
Modal as AModal,
message,
Spin,
Tabs,
Tag,
Timeline,
message,
} from 'ant-design-vue';
import ExpressCompanySelect from '#/components/form/components/express-company-select.vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import {
copyShipAddressText,
formatShipAddressCopyText,
} from '#/util/ship-address-copy';
import { expressDetailByOrderId, updateExpressNosApi } from '../api';
@@ -36,7 +40,7 @@ defineOptions({
const loading = ref(false);
const expressDetail = ref<Record<string, any>>({});
const activePkg = ref('0');
const orderId = ref<number | null>(null);
const orderId = ref<null | number>(null);
/** 改运单弹窗 */
const editVisible = ref(false);
@@ -65,6 +69,17 @@ function packageTabLabel(pkg: Record<string, any>, idx: number): string {
return name ? `包裹${no}(${name})` : `包裹${no}`;
}
/**
* 收件人姓名:优先顶层 express_name,再回落 address.name(与后端 resolveReceiver 对齐)
*/
function resolveReceiverName(detail: Record<string, any>): string {
const fromTop = String(detail?.express_name || '').trim();
if (fromTop) {
return fromTop;
}
return String(detail?.address?.name || '').trim();
}
/**
* 收件人姓名脱敏:保留首字,其余用 *
*/
@@ -79,11 +94,37 @@ function maskExpressName(name: unknown): string {
return str.slice(0, 1) + '*'.repeat(str.length - 1);
}
/**
* 收货地址:省市区 + 详细地址;兼容顶层 express_* 与 address 快照
*/
function formatReceiverAddress(detail: Record<string, any>): string {
const region = String(
detail?.express_region || detail?.address?.region || '',
).trim();
const addr = String(
detail?.express_address || detail?.address?.detail_address || '',
).trim();
return [region, addr].filter(Boolean).join(' ') || '-';
}
/** 一键复制收货信息(优先明文手机) */
function copyLogisticsShipAddress(detail: Record<string, any>) {
const text = formatShipAddressCopyText(detail?.address, {
name: resolveReceiverName(detail),
mobile: detail?.address?.mobile || detail?.mobile || detail?.express_mobile,
express_name: detail?.express_name,
express_mobile: detail?.express_mobile || detail?.mobile,
express_region: detail?.express_region,
express_address: detail?.express_address,
});
void copyShipAddressText(text);
}
/**
* 解析运单 ID:优先 express_no_id,其次 express.id
*/
function resolveExpressNoId(
express: Record<string, any> | null | undefined,
express: null | Record<string, any> | undefined,
fallbackId?: number,
): number {
const fromExpress = Number(express?.id || 0);
@@ -117,14 +158,17 @@ function getColor(status: any) {
/**
* 读取运单上的第三方查询失败文案(无轨迹时展示给运营)
*/
function syncFailMessage(express: Record<string, any> | null | undefined): string {
function syncFailMessage(
express: null | Record<string, any> | undefined,
): string {
return String(express?.last_sync_error || '').trim();
}
async function loadExpress(id: number, keepActive = false) {
loading.value = true;
try {
expressDetail.value = (await expressDetailByOrderId({ order_id: id })) || {};
expressDetail.value =
(await expressDetailByOrderId({ order_id: id })) || {};
if (!keepActive) {
activePkg.value = '0';
}
@@ -137,10 +181,10 @@ async function loadExpress(id: number, keepActive = false) {
* 打开改运单表单(包裹或旧单运单共用)
*/
function openEditExpress(payload: {
express_no_id: number;
express_no?: string;
mobile?: string;
express_company_code?: string;
express_no?: string;
express_no_id: number;
mobile?: string;
}) {
const id = Number(payload.express_no_id || 0);
if (id <= 0) {
@@ -240,7 +284,7 @@ const [Modal, modalApi] = useVbenModal({
bordered
>
<Descriptions.Item label="收件人">
{{ maskExpressName(expressDetail.express_name) }}
{{ maskExpressName(resolveReceiverName(expressDetail)) }}
</Descriptions.Item>
<Descriptions.Item label="手机号">
<span class="inline-flex items-center gap-2">
@@ -257,7 +301,8 @@ const [Modal, modalApi] = useVbenModal({
),
express_no: pkg.express.express_no,
mobile: pkg.express.mobile,
express_company_code: pkg.express.express_company_code,
express_company_code:
pkg.express.express_company_code,
})
"
>
@@ -265,6 +310,19 @@ const [Modal, modalApi] = useVbenModal({
</Button>
</span>
</Descriptions.Item>
<Descriptions.Item label="收货地址" :span="2">
<span class="inline-flex items-start gap-2">
<span>{{ formatReceiverAddress(expressDetail) }}</span>
<Button
type="link"
size="small"
class="!px-0 shrink-0"
@click="copyLogisticsShipAddress(expressDetail)"
>
复制
</Button>
</span>
</Descriptions.Item>
<Descriptions.Item label="物流公司">
{{ pkg.express.express_company_name || '-' }}
</Descriptions.Item>
@@ -283,7 +341,8 @@ const [Modal, modalApi] = useVbenModal({
),
express_no: pkg.express.express_no,
mobile: pkg.express.mobile,
express_company_code: pkg.express.express_company_code,
express_company_code:
pkg.express.express_company_code,
})
"
>
@@ -298,13 +357,20 @@ const [Modal, modalApi] = useVbenModal({
<div class="mt-4">
<h4 class="mb-2">物流追踪</h4>
<Timeline
v-if="Array.isArray(pkg.express.detail) && pkg.express.detail.length"
v-if="
Array.isArray(pkg.express.detail) &&
pkg.express.detail.length
"
>
<Timeline.Item
v-for="(detail, dIdx) in pkg.express.detail"
:key="dIdx"
>
<Tag :color="getColor(detail.status)">{{ detail.status }}</Tag>
<Tag :color="getColor(detail.status)">
{{
detail.status
}}
</Tag>
<p>{{ detail.detail_at }}</p>
<p>{{ detail.detail }}</p>
</Timeline.Item>
@@ -328,13 +394,18 @@ const [Modal, modalApi] = useVbenModal({
bordered
>
<Descriptions.Item label="收件人">
{{ maskExpressName(expressDetail.express_name) }}
{{ maskExpressName(resolveReceiverName(expressDetail)) }}
</Descriptions.Item>
<Descriptions.Item label="手机号">
<span class="inline-flex items-center gap-2">
<SensitiveText :record="expressDetail" field="mobile" />
<Button
v-if="resolveExpressNoId(expressDetail, expressDetail.express_no_id)"
v-if="
resolveExpressNoId(
expressDetail,
expressDetail.express_no_id,
)
"
type="link"
size="small"
class="!px-0"
@@ -354,6 +425,19 @@ const [Modal, modalApi] = useVbenModal({
</Button>
</span>
</Descriptions.Item>
<Descriptions.Item label="收货地址" :span="2">
<span class="inline-flex items-start gap-2">
<span>{{ formatReceiverAddress(expressDetail) }}</span>
<Button
type="link"
size="small"
class="!px-0 shrink-0"
@click="copyLogisticsShipAddress(expressDetail)"
>
复制
</Button>
</span>
</Descriptions.Item>
<Descriptions.Item label="物流公司">
{{ expressDetail.express_company_name || '-' }}
</Descriptions.Item>
@@ -361,7 +445,12 @@ const [Modal, modalApi] = useVbenModal({
<span class="inline-flex items-center gap-2">
<span>{{ expressDetail.express_no || '-' }}</span>
<Button
v-if="resolveExpressNoId(expressDetail, expressDetail.express_no_id)"
v-if="
resolveExpressNoId(
expressDetail,
expressDetail.express_no_id,
)
"
type="link"
size="small"
class="!px-0"
@@ -388,7 +477,10 @@ const [Modal, modalApi] = useVbenModal({
<div class="mt-4">
<h4 class="mb-2">物流追踪</h4>
<Timeline
v-if="Array.isArray(expressDetail.detail) && expressDetail.detail.length"
v-if="
Array.isArray(expressDetail.detail) &&
expressDetail.detail.length
"
>
<Timeline.Item
v-for="(detail, index) in expressDetail.detail"

View File

@@ -2,6 +2,7 @@
/**
* 商品订单发货弹窗(左右分栏)
* 左:收件信息默认只读展示,可改/取消;右:快递单号/公司/备注
* 收件口径:姓名/地址可选,手机号必填;未修改收件则只提交发货
* 注意:收件表单用 v-show 常驻挂载,避免加载中 setValues 等不到节点而卡住
*/
import { computed, ref } from 'vue';
@@ -58,23 +59,22 @@ const fullAddressText = computed(() => {
* 从订单行/详情解析收件信息(接口失败时的兜底)
*/
function pickReceiverFromOrder(data: Record<string, any> = {}) {
const addr = data?.address && typeof data.address === 'object' ? data.address : {};
const addr =
data?.address && typeof data.address === 'object' ? data.address : {};
return {
express_name: String(data?.express_name || addr.name || ''),
express_mobile: String(data?.express_mobile || addr.mobile || ''),
express_region: String(data?.express_region || addr.region || ''),
express_address: String(
data?.express_address || addr.detail_address || '',
),
express_address: String(data?.express_address || addr.detail_address || ''),
};
}
/** 规范化收件四字段 */
function normalizeReceiver(next: {
express_name?: string;
express_mobile?: string;
express_region?: string;
express_address?: string;
express_mobile?: string;
express_name?: string;
express_region?: string;
}) {
return {
express_name: String(next.express_name || ''),
@@ -88,10 +88,10 @@ function normalizeReceiver(next: {
* 写入展示态;表单常驻挂载,setValues 不阻塞 UI(避免挂载时序卡死)
*/
function applyReceiver(next: {
express_name?: string;
express_mobile?: string;
express_region?: string;
express_address?: string;
express_mobile?: string;
express_name?: string;
express_region?: string;
}) {
const payload = normalizeReceiver(next);
receiverView.value = { ...payload };
@@ -108,8 +108,8 @@ async function loadShipReceiver(fallback?: Record<string, any>) {
try {
const res = await getShipReceiver(orderId.value);
applyReceiver(res || {});
} catch (err) {
console.error(err);
} catch (error) {
console.error(error);
if (fallback) {
applyReceiver(pickReceiverFromOrder(fallback));
}
@@ -164,8 +164,8 @@ async function fillStoreAddress() {
}
applyReceiver(res || {});
message.success('已填入诊所地址');
} catch (err) {
console.error(err);
} catch (error) {
console.error(error);
} finally {
storeAddrLoading.value = false;
}
@@ -178,23 +178,24 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
// 若仍在编辑收件,先落回展示态并校验
// 收件口径:地址/姓名可选;仅手机号必填。未改收件则按当前展示值发货
if (receiverEditing.value) {
const re = await receiverFormApi.validate();
if (!re.valid) return;
await syncReceiverViewFromForm();
receiverEditing.value = false;
} else {
// 展示态也再校验一次,防止空收件直接发货
const re = await receiverFormApi.validate();
if (!re.valid) {
receiverEditing.value = true;
return;
}
}
const mobile = String(receiverView.value.express_mobile || '').trim();
if (!mobile) {
message.warning('请填写收件手机号');
receiverEditing.value = true;
await receiverFormApi.setValues({ ...receiverView.value });
return;
}
const se = await shipFormApi.validate();
if (!se.valid) return;
const receiver = await receiverFormApi.getValues();
// 以展示态为准(已与表单同步),避免未编辑时表单空值覆盖
const receiver = { ...receiverView.value };
const ship = await shipFormApi.getValues();
const wid = Number(ship.warehouse_id);
const payload: Record<string, any> = {
@@ -219,8 +220,8 @@ const [Modal, modalApi] = useVbenModal({
message.success('发货成功');
gridApi.value?.reload?.();
modalApi.close();
} catch (err) {
console.error(err);
} catch (error) {
console.error(error);
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
@@ -334,7 +335,9 @@ const [Modal, modalApi] = useVbenModal({
<span class="receiver-view__label">地址</span>
<span class="receiver-view__value">{{ fullAddressText }}</span>
</div>
<p class="receiver-view__hint">点击此处可修改收件信息</p>
<p class="receiver-view__hint">
地址可选;无修改则只发货。手机号必填,点击可修改
</p>
</template>
</div>
<!-- 编辑表单常驻挂载(v-show),避免 setValues 等不到节点 -->
@@ -358,79 +361,92 @@ const [Modal, modalApi] = useVbenModal({
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
min-height: 420px;
height: 100%;
min-height: 420px;
}
.ship-pane {
display: flex;
flex-direction: column;
min-height: 0;
padding: 16px;
border-radius: 10px;
border: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
border-radius: 10px;
box-shadow: 0 0 6px hsl(var(--primary) / 10%);
}
.ship-pane--receiver {
border-color: hsl(var(--primary) / 28%);
box-shadow: 0 0 8px hsl(var(--primary) / 14%);
}
.ship-pane__head {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.ship-pane__title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: hsl(var(--foreground));
}
.ship-pane__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
gap: 4px;
align-items: center;
}
.receiver-view {
flex: 1;
display: flex;
flex: 1;
flex-direction: column;
gap: 14px;
padding: 8px 4px;
cursor: pointer;
border-radius: 8px;
outline: none;
border-radius: 8px;
transition: background 0.15s ease;
}
.receiver-view.is-loading {
cursor: wait;
pointer-events: none;
cursor: wait;
}
.receiver-view:hover,
.receiver-view:focus-visible {
background: hsl(var(--primary) / 0.06);
background: hsl(var(--primary) / 6%);
}
.receiver-view__row {
display: flex;
align-items: baseline;
gap: 12px;
align-items: baseline;
}
.receiver-view__row--block {
flex-direction: column;
gap: 6px;
}
.receiver-view__label {
flex-shrink: 0;
width: 52px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.receiver-view__row--block .receiver-view__label {
width: auto;
}
.receiver-view__value {
flex: 1;
font-size: 15px;
@@ -438,25 +454,29 @@ const [Modal, modalApi] = useVbenModal({
color: hsl(var(--foreground));
word-break: break-all;
}
.receiver-view__hint {
margin: auto 0 0;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.receiver-edit {
flex: 1;
min-height: 0;
overflow: auto;
}
.receiver-loading {
flex: 1;
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 120px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
@media (max-width: 768px) {
.ship-layout {
grid-template-columns: 1fr;

View File

@@ -12,19 +12,19 @@ export const receiverFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-12',
schema: [
{
// 收件人可选:无地址/无姓名仍可发货,仅手机号必填
component: 'VbenInput',
componentProps: {
placeholder: '请输入收件人',
placeholder: '选填,可不填',
maxlength: 50,
},
fieldName: 'express_name',
label: '收件人',
rules: 'required',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入收件手机号',
placeholder: '请输入收件手机号(必填)',
maxlength: 11,
},
fieldName: 'express_mobile',
@@ -34,23 +34,23 @@ export const receiverFormProps: VbenFormProps = {
{
component: 'VbenInput',
componentProps: {
placeholder: '省市区,可与详细地址合并填写',
placeholder: '选填,省市区可与详细地址合并',
maxlength: 100,
},
fieldName: 'express_region',
label: '所在地区',
},
{
// 详细地址可选:未填或未修改时只做发货,不拦单
component: 'Textarea',
componentProps: {
placeholder: '请输入详细地址',
placeholder: '选填,可不填;未修改则按原信息发货',
rows: 3,
maxlength: 255,
showCount: true,
},
fieldName: 'express_address',
label: '详细地址',
rules: 'required',
},
],
showDefaultActions: false,

File diff suppressed because it is too large Load Diff

View File

@@ -99,3 +99,13 @@ export async function getRegisterVoucherQrcodeApi(registerId: number) {
register_id: registerId,
});
}
/**
* 管理端重开接诊:已结束 → 接诊中,并重建 24h 自动结束队列
*/
export async function reopenRegisterConsultingApi(data: {
reason: string;
register_id: number;
}) {
return requestClient.post<any>(`${prefix}reopen-consulting`, data);
}

View File

@@ -9,11 +9,18 @@
*/
import type { ActionItem } from '#/components/table-action';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue';
import { Empty, Image, message, Spin } from 'ant-design-vue';
import { TableAction } from '#/components/table-action';
import { CardActions } from '#/components/table-action';
import { pillStyle, tagColorHex } from '#/utils/tagColor';
import { getRegisterListApi } from '../api';
@@ -24,27 +31,31 @@ import {
} from '../config/constants';
const props = defineProps<{
/** 顶部统一搜索条件(含 patient_keyword / doctor_keyword / type / is_pay 等) */
formValues?: Record<string, any>;
/** 是否可退款(与列表行操作同一份判定,父级传入保证口径一致) */
canRefund: (row: Record<string, any>) => boolean;
/** 是否可重开接诊(已结束 → 接诊中) */
canReopenConsulting: (row: Record<string, any>) => boolean;
/** 是否可改期(与列表行操作同一份判定,父级传入保证口径一致) */
canReschedule: (row: Record<string, any>) => boolean;
/** 顶部统一搜索条件(含 patient_keyword / doctor_keyword / type / is_pay 等) */
formValues?: Record<string, any>;
}>();
const emit = defineEmits<{
/** 点卡片打开挂号详情弹窗 */
openDetail: [row: Record<string, any>];
/** 退款(复用父级退款弹窗) */
refund: [registerId: number];
/** 改期(复用父级改期弹窗) */
reschedule: [row: Record<string, any>];
/** 点处方号打开处方详情 */
openPrescription: [prescriptionId: number];
/** 点诊所名打开诊所卡片 */
openStore: [storeId: number];
/** 打印预览挂号凭证 */
printReceipt: [row: Record<string, any>];
/** 退款(复用父级退款弹窗) */
refund: [registerId: number];
/** 重开接诊 */
reopenConsulting: [row: Record<string, any>];
/** 改期(复用父级改期弹窗) */
reschedule: [row: Record<string, any>];
}>();
const items = ref<any[]>([]);
@@ -83,36 +94,49 @@ function money(value: unknown) {
return Number.isFinite(n) ? n.toFixed(2) : '—';
}
/** 行操作:详情 / 打印预览 / 改期 / 退款(显隐与列表一致),通过 emit 复用父级弹窗 */
function buildActions(row: Record<string, any>): ActionItem[] {
return [
{
label: '详情',
type: 'link',
icon: 'marketeq:eye',
onClick: () => emit('openDetail', row),
},
{
label: '打印预览',
type: 'link',
icon: 'ant-design:printer-outlined',
onClick: () => emit('printReceipt', row),
},
{
label: '改期',
type: 'link',
icon: 'ant-design:calendar-outlined',
ifShow: props.canReschedule(row),
onClick: () => emit('reschedule', row),
},
{
label: '退款',
type: 'link',
icon: 'mingcute:refund-dollar-fill',
ifShow: props.canRefund(row),
onClick: () => emit('refund', Number(row.id)),
},
];
/** 行操作:主按钮详情 + 更多(打印/改期/退款/重开),对齐商品订单 CardActions */
function buildCardActions(row: Record<string, any>): {
actions: ActionItem[];
dropDownActions: ActionItem[];
} {
return {
actions: [
{
label: '详情',
type: 'link',
icon: 'marketeq:eye',
onClick: () => emit('openDetail', row),
},
{
label: '打印预览',
type: 'link',
icon: 'ant-design:printer-outlined',
onClick: () => emit('printReceipt', row),
},
{
label: '改期',
type: 'link',
icon: 'ant-design:calendar-outlined',
ifShow: props.canReschedule(row),
onClick: () => emit('reschedule', row),
},
{
label: '退款',
type: 'link',
icon: 'mingcute:refund-dollar-fill',
ifShow: props.canRefund(row),
onClick: () => emit('refund', Number(row.id)),
},
{
label: '重开接诊',
type: 'link',
icon: 'ant-design:reload-outlined',
ifShow: props.canReopenConsulting(row),
onClick: () => emit('reopenConsulting', row),
},
],
dropDownActions: [],
};
}
/**
@@ -130,10 +154,12 @@ async function fetchPage(targetPage: number, reset = false) {
const res: any = await getRegisterListApi({
page: targetPage,
pageSize: 12,
...(props.formValues || {}),
...props.formValues,
});
if (seq !== requestSeq) return;
items.value = reset ? (res?.items ?? []) : [...items.value, ...(res?.items ?? [])];
items.value = reset
? (res?.items ?? [])
: [...items.value, ...(res?.items ?? [])];
page.value = res?.page ?? targetPage;
pageCount.value = res?.page_count ?? 1;
} catch (error) {
@@ -164,7 +190,7 @@ async function refreshKeepPage() {
const res: any = await getRegisterListApi({
page: 1,
pageSize: loaded,
...(props.formValues || {}),
...props.formValues,
});
if (seq !== requestSeq) return;
items.value = res?.items ?? [];
@@ -233,19 +259,25 @@ onBeforeUnmount(() => {
@keydown.enter="emit('openDetail', row)"
>
<!-- 角落径向荧光(与提现管理统计岛同款质感装饰) -->
<div class="rg-card__glow" />
<div class="rg-card__glow"></div>
<!-- 头部:诊所名(可点)+ 状态彩点 -->
<div class="rg-card__head">
<button
v-if="row.store?.id || row.store_id"
type="button"
class="rg-card__store"
@click.stop="emit('openStore', Number(row.store?.id || row.store_id))"
@click.stop="
emit('openStore', Number(row.store?.id || row.store_id))
"
>
<span class="rg-card__store-name">{{ row.store?.name || '—' }}</span>
<span class="rg-card__store-name">{{
row.store?.name || '—'
}}</span>
<span class="rg-card__store-arrow">›</span>
</button>
<span v-else class="rg-card__store-name">{{ row.store?.name || '—' }}</span>
<span v-else class="rg-card__store-name">{{
row.store?.name || '—'
}}</span>
<span
v-if="statusMeta(row)"
class="rg-card__status"
@@ -266,13 +298,24 @@ onBeforeUnmount(() => {
</div>
<!-- 语义胶囊行:挂号方式/支付/退款,右侧挂号费金额(C 端惯例右上金额) -->
<div class="rg-card__pills">
<span v-if="typeMeta(row)" class="rg-pill" :style="pillStyle(typeMeta(row)!.color)">
<span
v-if="typeMeta(row)"
class="rg-pill"
:style="pillStyle(typeMeta(row)!.color)"
>
{{ typeMeta(row)!.label }}
</span>
<span class="rg-pill" :style="pillStyle(row.is_pay === 1 ? 'green' : 'red')">
<span
class="rg-pill"
:style="pillStyle(row.is_pay === 1 ? 'green' : 'red')"
>
{{ row.is_pay === 1 ? '已支付' : '未支付' }}
</span>
<span v-if="refundMeta(row)" class="rg-pill" :style="pillStyle(refundMeta(row)!.color)">
<span
v-if="refundMeta(row)"
class="rg-pill"
:style="pillStyle(refundMeta(row)!.color)"
>
{{ refundMeta(row)!.label }}
</span>
<span class="rg-card__amount-value"><i>¥</i>{{ money(row.price) }}</span>
@@ -296,7 +339,9 @@ onBeforeUnmount(() => {
<em class="rg-card__salesperson">
<Image
v-if="row.salesperson?.id"
:src="row.salesperson.avatar || '/img/user-default-avatar.png'"
:src="
row.salesperson.avatar || '/img/user-default-avatar.png'
"
:width="16"
:height="16"
:preview="false"
@@ -308,7 +353,10 @@ onBeforeUnmount(() => {
</span>
</div>
<!-- 关联处方(有才显示) -->
<div v-if="Array.isArray(row.prescription) && row.prescription.length" class="rg-card__rx">
<div
v-if="Array.isArray(row.prescription) && row.prescription.length"
class="rg-card__rx"
>
<button
v-for="item in row.prescription"
:key="item.id"
@@ -323,14 +371,21 @@ onBeforeUnmount(() => {
<div class="rg-card__spacer"></div>
<!-- 底部操作:详情/退款(阻止冒泡避免触发卡片点击) -->
<div class="rg-card__footer" @click.stop>
<TableAction :actions="buildActions(row)" :drop-down-actions="[]" />
<CardActions
:actions="buildCardActions(row).actions"
:drop-down-actions="buildCardActions(row).dropDownActions"
:max-visible="1"
/>
</div>
</div>
</div>
<Empty v-else-if="!loading" description="暂无挂号订单" />
</Spin>
<div ref="sentinelEl" class="h-8"></div>
<div v-if="loadingMore" class="py-3 text-center text-xs text-[hsl(var(--muted-foreground))]">
<div
v-if="loadingMore"
class="py-3 text-center text-xs text-[hsl(var(--muted-foreground))]"
>
加载中…
</div>
<div
@@ -345,6 +400,7 @@ onBeforeUnmount(() => {
<style scoped>
.rg-card-scroll {
height: 100%;
/* 左右留 6px:通栏卡片 hover 阴影不被容器裁出切边 */
padding: 4px 6px 20px;
overflow: auto;
@@ -370,9 +426,9 @@ onBeforeUnmount(() => {
hsl(var(--primary) / 4%),
hsl(var(--card, var(--background)))
);
backdrop-filter: blur(8px);
border: 1px solid hsl(var(--border));
border-radius: 12px;
backdrop-filter: blur(8px);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
@@ -395,7 +451,11 @@ onBeforeUnmount(() => {
width: 110px;
height: 110px;
pointer-events: none;
background: radial-gradient(circle, hsl(var(--primary) / 18%) 0%, transparent 70%);
background: radial-gradient(
circle,
hsl(var(--primary) / 18%) 0%,
transparent 70%
);
opacity: 0.4;
transition: opacity 0.2s ease;
}
@@ -413,9 +473,9 @@ onBeforeUnmount(() => {
.rg-card__store {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 2px;
align-items: center;
min-width: 0;
padding: 0;
cursor: pointer;
background: none;
@@ -424,11 +484,11 @@ onBeforeUnmount(() => {
.rg-card__store-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.2px;
color: hsl(var(--foreground));
text-overflow: ellipsis;
letter-spacing: -0.2px;
white-space: nowrap;
}
@@ -517,10 +577,10 @@ onBeforeUnmount(() => {
.rg-kv > em {
overflow: hidden;
text-overflow: ellipsis;
font-style: normal;
font-weight: 500;
color: hsl(var(--foreground));
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -559,8 +619,8 @@ onBeforeUnmount(() => {
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.3px;
color: hsl(var(--foreground));
letter-spacing: -0.3px;
}
.rg-card__amount-value i {

View File

@@ -0,0 +1,84 @@
<script lang="ts" setup>
/**
* 挂号重开接诊弹窗:已结束(3) → 接诊中(2),重建 24h 自动结束队列
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Form, FormItem, Input, message } from 'ant-design-vue';
import { reopenRegisterConsultingApi } from '../api';
const row = ref<null | Record<string, any>>(null);
const reason = ref('');
const onSuccess = ref<(() => void) | null>(null);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
if (!row.value?.id) return;
if (reason.value.trim() === '') {
message.error('请填写重开原因');
return;
}
modalApi.setState({ confirmLoading: true, loading: true });
try {
await reopenRegisterConsultingApi({
register_id: Number(row.value.id),
reason: reason.value.trim(),
});
message.success('已重开为接诊中');
onSuccess.value?.();
modalApi.close();
} catch (error) {
console.error(error);
} finally {
modalApi.setState({ confirmLoading: false, loading: false });
}
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{
onSuccess?: () => void;
row: Record<string, any>;
}>();
row.value = data?.row ?? null;
onSuccess.value = data?.onSuccess ?? null;
reason.value = '';
} else {
row.value = null;
onSuccess.value = null;
reason.value = '';
}
},
});
</script>
<template>
<Modal title="重开接诊" class="w-[480px]">
<Alert
type="warning"
show-icon
class="mb-3"
message="将把本单从「已结束」改回「接诊中」,并重新生效 24 小时自动结束策略。不会重新触发接诊分账与患者通知。"
/>
<Form layout="vertical">
<FormItem label="订单号">
<span>{{ row?.order_no || row?.id || '—' }}</span>
</FormItem>
<FormItem label="重开原因" required>
<Input.TextArea
v-model:value="reason"
:rows="3"
placeholder="例如:医生误点结束,需继续修改处方"
allow-clear
/>
</FormItem>
</Form>
</Modal>
</template>

View File

@@ -21,7 +21,8 @@ export const formOptions: VbenFormProps = {
},
},
// 响应式多列:抽出 grid 后 vben form 默认单列(一行一个字段),必须显式给栅格
wrapperClass: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
wrapperClass:
'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
schema: [
{
// 放第一位:时间范围有默认值(本月至今),默认收起时必须始终可见,否则用户不知道当前查的是哪段数据
@@ -56,6 +57,17 @@ export const formOptions: VbenFormProps = {
fieldName: 'transaction_id',
label: '支付单号',
},
{
// 门店多选:与商品订单同组件,后端 resolveRequestStoreIds
component: 'StoreMultiSearch',
componentProps: {
placeholder: '输入名称或拼音首拼',
},
defaultValue: [],
fieldName: 'store_ids',
modelPropName: 'value',
label: '门店',
},
{
// 就诊人姓名/手机号模糊(后端 PersonKeywordScopeService,whereHas 关联表)
component: 'Input',

View File

@@ -16,27 +16,28 @@ import { useRoute } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Image, Tag, message } from 'ant-design-vue';
import { Button, Image, message, Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { useFreshSearchTime } from '#/composables/use-fresh-search-time';
import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
import StoreNameLink from '#/components/store-card/StoreNameLink.vue';
import { TableAction } from '#/components/table-action';
import { monthToTodayRangeString } from '#/utils/search-time-range';
import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import { useFreshSearchTime } from '#/composables/use-fresh-search-time';
import { monthToTodayRangeString } from '#/utils/search-time-range';
import {
ORDER_POPUP_Z_INDEX,
openOrderPopup,
ORDER_POPUP_Z_INDEX,
} from '#/views/business/order/utils/popup-z-index';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import { getRegisterListApi } from './api';
import Refund from './components/refund.vue';
import RegisterDetailModal from './components/RegisterDetailModal.vue';
import RegisterOrderCardList from './components/RegisterOrderCardList.vue';
import RegisterReceiptPrintModal from './components/RegisterReceiptPrintModal.vue';
import ReopenConsultingModal from './components/reopen-consulting-modal.vue';
import RescheduleModal from './components/reschedule-modal.vue';
import {
REGISTER_REFUND_STATUS_MAP,
@@ -134,7 +135,7 @@ async function readLiveSearchValues(): Promise<Record<string, any>> {
try {
await nextTick();
const live = await searchFormApi.getValues();
return { ...(live || {}) };
return { ...live };
} catch {
return { ...searchValues.value };
}
@@ -202,7 +203,8 @@ const freshSearchTime = useFreshSearchTime({
},
});
freshSearchTimeBridge.resetSearchTime = freshSearchTime.resetSearchTime;
freshSearchTimeBridge.markSearchTimeTouched = freshSearchTime.markSearchTimeTouched;
freshSearchTimeBridge.markSearchTimeTouched =
freshSearchTime.markSearchTimeTouched;
// 记忆持久化由 useViewMode 内部完成;切回列表时 grid 被 v-if 卸载过需主动重查
watch(viewMode, (mode, prev) => {
@@ -320,11 +322,70 @@ function canReschedule(row: Record<string, any>) {
return row.is_pay === 1 && row.status === 1 && row.is_cancel !== 1;
}
/** 仅「已结束」可重开为接诊中(医生误点结束需继续改方) */
function canReopenConsulting(row: Record<string, any>) {
return Number(row.status) === 3;
}
function openRescheduleModal(row: Record<string, any>) {
rescheduleModalApi.setData({ row, gridApi: gridApiProxy });
rescheduleModalApi.open();
}
const [ReopenConsultingModalComp, reopenConsultingModalApi] = useVbenModal({
connectedComponent: ReopenConsultingModal,
});
function openReopenConsulting(row: Record<string, any>) {
reopenConsultingModalApi.setData({
row,
onSuccess: () => refreshCurrentView(),
});
reopenConsultingModalApi.open();
}
/** 列表行操作(与卡片 CardActions 同源口径) */
function buildRowActions(row: Record<string, any>) {
return {
actions: [
{
label: '详情',
type: 'link' as const,
icon: 'marketeq:eye',
onClick: () => openRegisterDetail(row),
},
{
label: '打印预览',
type: 'link' as const,
icon: 'ant-design:printer-outlined',
onClick: () => openRegisterReceiptPrint(row),
},
{
label: '改期',
type: 'link' as const,
icon: 'ant-design:calendar-outlined',
ifShow: canReschedule(row),
onClick: () => openRescheduleModal(row),
},
{
label: '退款',
type: 'link' as const,
icon: 'mingcute:refund-dollar-fill',
ifShow: canRefund(row),
onClick: () => openRefundModal(row.id),
},
{
label: '重开接诊',
type: 'link' as const,
icon: 'ant-design:reload-outlined',
ifShow: canReopenConsulting(row),
onClick: () => openReopenConsulting(row),
},
],
dropDownActions: [] as any[],
};
}
function formatRegisterPrice(price: unknown) {
const n = Number(price);
if (!Number.isFinite(n)) {
@@ -355,6 +416,7 @@ function rowRefundMeta(row: Record<string, any>) {
<PrescriptionDetailModal />
<RefundModal />
<RescheduleModalComp />
<ReopenConsultingModalComp />
<StoreCardModalComp />
<RegisterReceiptPrintModalComp />
<!-- 挂号详情弹窗:处方号/诊所名透传;打印预览由详情内部自挂载 -->
@@ -434,35 +496,8 @@ function rowRefundMeta(row: Record<string, any>) {
<template #toolbar-tools></template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '详情',
type: 'link',
icon: 'marketeq:eye',
onClick: openRegisterDetail.bind(null, row),
},
{
label: '打印预览',
type: 'link',
icon: 'ant-design:printer-outlined',
onClick: openRegisterReceiptPrint.bind(null, row),
},
{
label: '改期',
type: 'link',
icon: 'ant-design:calendar-outlined',
ifShow: canReschedule(row),
onClick: openRescheduleModal.bind(null, row),
},
{
label: '退款',
type: 'link',
icon: 'mingcute:refund-dollar-fill',
ifShow: canRefund(row),
onClick: openRefundModal.bind(null, row.id),
},
]"
:drop-down-actions="[]"
:actions="buildRowActions(row).actions"
:drop-down-actions="buildRowActions(row).dropDownActions"
/>
</template>
</Grid>
@@ -473,12 +508,14 @@ function rowRefundMeta(row: Record<string, any>) {
:form-values="searchValues"
:can-refund="canRefund"
:can-reschedule="canReschedule"
:can-reopen-consulting="canReopenConsulting"
@open-detail="openRegisterDetail"
@refund="openRefundModal"
@reschedule="openRescheduleModal"
@open-prescription="openPrescriptionDetail"
@open-store="openStoreCard"
@print-receipt="openRegisterReceiptPrint"
@reopen-consulting="openReopenConsulting"
/>
</div>
</Page>

View File

@@ -0,0 +1,14 @@
import { requestClient } from '#/api/request';
const prefix = 'analytics/';
/**
* 平台统计面板(xk-api GET analytics/platform-panel)
* 入参 search_time + module(当前 Tab),每次只返回该模块数据;is_super_admin 每响应都带
*/
export async function getPlatformPanelApi(data: {
module: string;
search_time: any;
}) {
return requestClient.get<any>(`${prefix}platform-panel`, { params: data });
}

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
/**
* 分析页 · 简易排行列表(录入数 / 营业额)
* 进度条相对第一名比例绘制;金额类用 ¥,数量类不加前缀
*/
import { computed } from 'vue';
import { formatMoney } from '#/views/finance/visualization-chart/utils/chart-theme';
export interface RankRow {
name: string;
count?: number;
amount?: number;
}
const props = defineProps<{
emptyText?: string;
items: RankRow[];
/** count | amount */
valueKey?: 'amount' | 'count';
}>();
const rows = computed(() => {
const key = props.valueKey || 'count';
const top = Number(props.items[0]?.[key] || 0);
return props.items.map((item, index) => {
const value = Number(item[key] || 0);
return {
...item,
rank: index + 1,
value,
progress: top > 0 ? Math.max(4, (value / top) * 100) : 0,
display:
key === 'amount'
? `¥${formatMoney(value)}`
: value.toLocaleString('zh-CN'),
};
});
});
</script>
<template>
<div class="rank">
<div v-if="rows.length === 0" class="rank-empty">
{{ emptyText || '暂无数据' }}
</div>
<div v-for="row in rows" :key="`${row.rank}-${row.name}`" class="rank-row">
<span
class="rank-badge"
:class="`rank-badge--${row.rank <= 3 ? row.rank : 'n'}`"
>
{{ row.rank }}
</span>
<div class="rank-main">
<div class="rank-name" :title="row.name">{{ row.name }}</div>
<div class="rank-bar">
<div class="rank-bar-fill" :style="{ width: `${row.progress}%` }"></div>
</div>
</div>
<div class="rank-value">{{ row.display }}</div>
</div>
</div>
</template>
<style scoped>
.rank {
display: flex;
flex-direction: column;
gap: 10px;
}
.rank-empty {
padding: 28px 0;
font-size: 13px;
color: hsl(var(--muted-foreground));
text-align: center;
}
.rank-row {
display: flex;
gap: 10px;
align-items: center;
}
.rank-badge {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
font-size: 12px;
font-weight: 700;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 60%);
border-radius: 8px;
}
.rank-badge--1 {
color: rgb(230 140 0);
background: linear-gradient(
145deg,
rgb(255 214 10 / 35%),
rgb(255 159 10 / 18%)
);
}
.rank-badge--2 {
color: hsl(var(--foreground) / 70%);
background: linear-gradient(
145deg,
rgb(203 213 225 / 45%),
rgb(148 163 184 / 18%)
);
}
.rank-badge--3 {
color: rgb(234 110 40);
background: linear-gradient(
145deg,
rgb(251 146 60 / 30%),
rgb(234 88 12 / 15%)
);
}
.rank-main {
flex: 1;
min-width: 0;
}
.rank-name {
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.rank-bar {
height: 5px;
overflow: hidden;
background: hsl(var(--muted) / 60%);
border-radius: 999px;
}
.rank-bar-fill {
height: 100%;
background: linear-gradient(
90deg,
hsl(var(--primary) / 55%),
hsl(var(--primary))
);
border-radius: inherit;
transition: width 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rank-value {
flex-shrink: 0;
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,9 @@ export async function getDeliveryWarehouseOptionsByDrugs(data: {
/**
* 医生应用特色方到开方区
*/
export async function applySpecialPrescriptionApi(data: { register_id: number }) {
export async function applySpecialPrescriptionApi(data: {
register_id: number;
}) {
return requestClient.post<any>('special-prescription/apply', data);
}
@@ -50,11 +52,13 @@ export async function getProductListDoctorReception(data: any) {
* xk-api GET doctor-reception/used-excipient-list
*/
export async function getUsedExcipientListDoctorReception(data: {
store_id?: number;
register_id?: number;
limit?: number;
register_id?: number;
store_id?: number;
}) {
return requestClient.get<any>(`${prefix}used-excipient-list`, { params: data });
return requestClient.get<any>(`${prefix}used-excipient-list`, {
params: data,
});
}
/**
@@ -62,9 +66,9 @@ export async function getUsedExcipientListDoctorReception(data: {
* 为什么:供货比例变更后关系表已更新,快照价不一致时要让医生确认是否按最新价计算
*/
export async function compareChineseDrugPricesApi(data: {
drugs: Array<Record<string, any>>;
register_id?: number;
store_id?: number;
drugs: Array<Record<string, any>>;
}) {
return requestClient.post<any>(`${prefix}compare-chinese-drug-prices`, data);
}
@@ -86,15 +90,15 @@ export async function getProcessRulePresetList(params: { store_id: number }) {
/** 保存煎药预设方案 doctor-reception POST save-process-rule-preset */
export async function saveProcessRulePreset(data: {
store_id: number;
name: string;
child_process_rule_id?: null | number;
id?: number;
is_default?: number;
name: string;
package_method_id?: null | number;
process_rule_id?: null | number;
process_rule_note_id?: null | number;
rule_type: number;
package_method_id?: number | null;
process_rule_id?: number | null;
child_process_rule_id?: number | null;
process_rule_note_id?: number | null;
store_id: number;
}) {
return requestClient.post<any>(`${prefix}save-process-rule-preset`, data);
}
@@ -171,14 +175,14 @@ export async function getPrescriptionTypeOptionsApi(params?: {
store_id?: number;
}) {
return requestClient.get<{
allowed?: boolean;
default: number;
list: Array<{
value: number;
label: string;
icon?: string;
icon_text?: string;
label: string;
value: number;
}>;
allowed?: boolean;
message?: string;
}>(`${prefix}prescription-type-options`, {
params,
@@ -197,8 +201,8 @@ export async function receptionApi(id: any) {
*/
export async function withdrawPrescriptionApi(data: {
prescription_id: number;
register_id: number;
reason?: string;
register_id: number;
}) {
return requestClient.post<any>(`${prefix}withdraw-prescription`, data);
}
@@ -207,25 +211,29 @@ export async function withdrawPrescriptionApi(data: {
* 本次挂号处方列表(分页,支持 keyword 按订单号/订单ID筛选)
*/
export async function getRegisterPrescriptionListApi(params: {
register_id: number;
keyword?: string;
page?: number;
page_size?: number;
keyword?: string;
register_id: number;
}) {
return requestClient.get<any>(`${prefix}register-prescription-list`, { params });
return requestClient.get<any>(`${prefix}register-prescription-list`, {
params,
});
}
/**
* 患者历史处方(排除当前挂号),支持 keyword 按订单号/订单ID筛选
*/
export async function getPatientPrescriptionHistoryApi(params: {
patient_id: number;
page?: number;
page_size?: number;
exclude_register_id?: number;
keyword?: string;
page?: number;
page_size?: number;
patient_id: number;
}) {
return requestClient.get<any>(`${prefix}patient-prescription-history`, { params });
return requestClient.get<any>(`${prefix}patient-prescription-history`, {
params,
});
}
/**
@@ -334,10 +342,7 @@ export async function getCommonPrescriptionListApi(storeId?: number) {
* @param id 常用方ID
* @param type 类型:west-西药,chinese-中药,granular-颗粒药
*/
export async function getCommonPrescriptionDetailApi(
id: number,
type: string,
) {
export async function getCommonPrescriptionDetailApi(id: number, type: string) {
return requestClient.get<any[]>(`${commonPrescriptionPrefix}detail`, {
params: { id, type },
});
@@ -406,18 +411,18 @@ export interface CommonPrescriptionChineseDrug {
* @description 将当前处方保存为西药常用方模板,后端会自动创建 recipe 记录
*/
export async function saveWestCommonPrescriptionApi(data: {
/** 常用方名称 */
name: string;
/** 药品详细信息数组 */
drugs: CommonPrescriptionWestDrug[];
/** 诊所ID */
store_id: number;
/** 类别 1-自费 2-医保(可选) */
category?: string;
/** 临床诊断(可选) */
clinical_diagnose?: string;
/** 医嘱(可选) */
doctor_order?: string;
/** 类别 1-自费 2-医保(可选) */
category?: string;
/** 药品详细信息数组 */
drugs: CommonPrescriptionWestDrug[];
/** 常用方名称 */
name: string;
/** 诊所ID */
store_id: number;
}) {
return requestClient.post<any>(`${commonPrescriptionPrefix}save-west`, data);
}
@@ -427,32 +432,32 @@ export async function saveWestCommonPrescriptionApi(data: {
* @description 将当前处方保存为中药常用方模板,后端会自动创建 recipe 记录
*/
export async function saveChineseCommonPrescriptionApi(data: {
/** 常用方名称 */
name: string;
/** 药品详细信息数组 */
drugs: CommonPrescriptionChineseDrug[];
/** 诊所ID */
store_id: number;
/** 临床诊断(可选) */
clinical_diagnose?: string;
/** 医嘱(可选) */
doctor_order?: string;
/** 类别 1-自费 2-医保(可选) */
category?: string;
/** 剂量/天数(可选,默认7) */
dosage?: number;
/** 煎法子规则ID(委托调剂时) */
child_process_rule_id?: number;
/** 临床诊断(可选) */
clinical_diagnose?: string;
/** 每日次数(可选,默认2) */
day_dosage?: number;
/** 1=自制剂 2=委托调剂(与后端 saveChinese 一致) */
rule_type?: number;
/** 医嘱(可选) */
doctor_order?: string;
/** 剂量/天数(可选,默认7) */
dosage?: number;
/** 药品详细信息数组 */
drugs: CommonPrescriptionChineseDrug[];
/** 常用方名称 */
name: string;
/** 包法ID(自制剂时) */
package_method_id?: number;
/** 制剂规则ID(委托调剂时) */
process_rule_id?: number;
/** 煎法子规则ID(委托调剂时) */
child_process_rule_id?: number;
/** 规格/备注规则ID(委托调剂时) */
process_rule_note_id?: number;
/** 1=自制剂 2=委托调剂(与后端 saveChinese 一致) */
rule_type?: number;
/** 诊所ID */
store_id: number;
}) {
return requestClient.post<any>(
`${commonPrescriptionPrefix}save-chinese`,
@@ -465,22 +470,22 @@ export async function saveChineseCommonPrescriptionApi(data: {
* @description 将当前处方保存为颗粒药常用方模板,后端会自动创建 recipe 记录
*/
export async function saveGranularCommonPrescriptionApi(data: {
/** 常用方名称 */
name: string;
/** 药品详细信息数组 */
drugs: CommonPrescriptionChineseDrug[];
/** 诊所ID */
store_id: number;
/** 临床诊断(可选) */
clinical_diagnose?: string;
/** 医嘱(可选) */
doctor_order?: string;
/** 类别 1-自费 2-医保(可选) */
category?: string;
/** 剂量/天数(可选,默认7) */
dosage?: number;
/** 临床诊断(可选) */
clinical_diagnose?: string;
/** 每日次数(可选,默认2) */
day_dosage?: number;
/** 医嘱(可选) */
doctor_order?: string;
/** 剂量/天数(可选,默认7) */
dosage?: number;
/** 药品详细信息数组 */
drugs: CommonPrescriptionChineseDrug[];
/** 常用方名称 */
name: string;
/** 诊所ID */
store_id: number;
}) {
return requestClient.post<any>(
`${commonPrescriptionPrefix}save-granular`,
@@ -490,23 +495,23 @@ export async function saveGranularCommonPrescriptionApi(data: {
/** AI 给处方 */
export async function aiGeneratePrescriptionApi(data: {
register_id: number;
store_id?: number;
prescription_type: number;
asr_duration_ms?: number;
asr_provider?: string;
/** 前端确认过的主诉(可编辑) */
chief_complaint?: string;
/** 当前病历表单快照(可未保存),供 AI 综合参考 */
medical_record?: Record<string, any>;
/** 中药:是否委托调剂 */
use_entrusted_process?: 0 | 1 | boolean;
prescription_type: number;
/** 中药委托调剂:制剂要求 id(yii_process_rule pid=0) */
process_rule_id?: number;
register_id: number;
return_dosage_process_detail?: 0 | 1 | boolean;
store_id?: number;
transcript?: string;
/** 中药:是否委托调剂 */
use_entrusted_process?: 0 | 1 | boolean;
/** 语音辨证:dialectic */
voice_mode?: string;
transcript?: string;
asr_provider?: string;
asr_duration_ms?: number;
return_dosage_process_detail?: 0 | 1 | boolean;
}) {
return requestClient.post<any>(`${prefix}ai-generate-prescription`, data);
}
@@ -530,15 +535,15 @@ export async function aiVoiceAsrApi(file: File, durationMs = 0) {
/** 语音口述落方(只抽药名剂量) */
export async function aiGenerateVoicePrescriptionApi(data: {
register_id: number;
store_id?: number;
prescription_type: number;
transcript: string;
asr_provider?: string;
asr_duration_ms?: number;
use_entrusted_process?: 0 | 1 | boolean;
asr_provider?: string;
prescription_type: number;
process_rule_id?: number;
register_id: number;
return_dosage_process_detail?: 0 | 1 | boolean;
store_id?: number;
transcript: string;
use_entrusted_process?: 0 | 1 | boolean;
}) {
return requestClient.post<any>(
`${prefix}ai-generate-voice-prescription`,
@@ -561,32 +566,34 @@ export async function aiAgreeConsentApi(data: {
/** 本挂号 AI 生成历史(轻量列表) */
export async function aiListGenerationsApi(data: {
limit?: number;
register_id: number;
scene?: string;
limit?: number;
}) {
return requestClient.get<any>(`${prefix}ai-list-generations`, { params: data });
return requestClient.get<any>(`${prefix}ai-list-generations`, {
params: data,
});
}
/** AI 生成详情(点选历史) */
export async function aiGenerationDetailApi(data: { id: number }) {
return requestClient.get<any>(`${prefix}ai-generation-detail`, { params: data });
return requestClient.get<any>(`${prefix}ai-generation-detail`, {
params: data,
});
}
/** 标记 AI 生成记录为已读(熄灭历史未读微光) */
export async function aiMarkGenerationReadApi(data: {
generation_id: number;
}) {
export async function aiMarkGenerationReadApi(data: { generation_id: number }) {
return requestClient.post<any>(`${prefix}ai-mark-generation-read`, data);
}
/** AI 处方药名模糊对照 */
export async function aiMatchPrescriptionDrugsApi(data: {
generation_id?: number;
items?: Array<Record<string, any>>;
prescription_type?: number;
register_id?: number;
store_id?: number;
prescription_type?: number;
items?: Array<Record<string, any>>;
}) {
return requestClient.post<any>(`${prefix}ai-match-prescription-drugs`, data);
}
@@ -611,14 +618,18 @@ export async function goldenFormulaListApi(data: {
keyword?: string;
page?: number;
pageSize?: number;
store_id?: number;
source_id?: number;
store_id?: number;
}) {
return requestClient.get<any>(`${prefix}golden-formula-list`, { params: data });
return requestClient.get<any>(`${prefix}golden-formula-list`, {
params: data,
});
}
/** 金方来源下拉(导入弹窗筛选用) */
export async function goldenFormulaSourceOptionApi(data?: { store_id?: number }) {
export async function goldenFormulaSourceOptionApi(data?: {
store_id?: number;
}) {
return requestClient.get<any>(`${prefix}golden-formula-source-option`, {
params: data || {},
});
@@ -629,15 +640,17 @@ export async function goldenFormulaDetailApi(data: {
id: number;
store_id?: number;
}) {
return requestClient.get<any>(`${prefix}golden-formula-detail`, { params: data });
return requestClient.get<any>(`${prefix}golden-formula-detail`, {
params: data,
});
}
/** 金方导入药名对照 */
export async function goldenFormulaApplyApi(data: {
id: number;
store_id?: number;
register_id?: number;
prescription_type?: number;
register_id?: number;
store_id?: number;
}) {
return requestClient.post<any>(`${prefix}golden-formula-apply`, data);
}
@@ -645,12 +658,12 @@ export async function goldenFormulaApplyApi(data: {
/** 保存医生金方匹配选择(改选/确认导入) */
export async function goldenFormulaSaveMatchApi(data: {
id: number;
store_id?: number;
items: Array<{
drug_name: string;
selected_drug_id: number;
selected_drug_name?: string;
}>;
store_id?: number;
}) {
return requestClient.post<any>(`${prefix}golden-formula-save-match`, data);
}
@@ -662,11 +675,11 @@ export async function goldenFormulaSaveMatchApi(data: {
*/
export async function createUserPatientApi(data: Record<string, any>) {
return requestClient.post<{
user_patient_id: number;
register_id: number;
waive_register_fee: number;
price: string;
is_pay: number;
price: string;
register_id: number;
user_patient_id: number;
waive_register_fee: number;
}>(`${prefix}create-user-patient`, data);
}
@@ -690,36 +703,74 @@ export async function getDoctorCreatedUserPatientListApi(params: {
}) {
return requestClient.get<{
items: Array<{
id: number;
user_id: number;
name: string;
mobile?: string;
sex?: number;
age?: number;
created_at?: string;
edit_mode?: 'full' | 'limited';
id: number;
is_claimed: number;
latest_register_id: number;
latest_register_status: number;
latest_register_is_pay: number;
latest_register_status: number;
mobile?: string;
name: string;
sex?: number;
ship_address?: Record<string, string>;
user_id: number;
}>;
total: number;
page: number;
page_size: number;
total: number;
}>(`${prefix}doctor-created-user-patient-list`, { params });
}
/** 检查本医生创建就诊人姓名/手机是否重复 */
export async function checkCreatedUserPatientDuplicateApi(params: {
mobile: string;
name: string;
}) {
return requestClient.get<{
exact: boolean;
items: Array<{
age?: number;
id: number;
is_claimed: number;
match_mobile: number;
match_name: number;
mobile: string;
name: string;
sex?: number;
}>;
}>(`${prefix}check-created-user-patient-duplicate`, { params });
}
/** 医生创建就诊人详情 */
export async function getCreatedUserPatientDetailApi(userPatientId: number) {
return requestClient.get<Record<string, any>>(
`${prefix}created-user-patient-detail`,
{ params: { user_patient_id: userPatientId } },
);
}
/** 更新医生创建的就诊人 */
export async function updateCreatedUserPatientApi(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(
`${prefix}update-created-user-patient`,
data,
);
}
/** 选择医生创建的就诊人开始线下接诊 */
export async function startOfflineReceptionByPatientApi(data: {
user_patient_id: number;
waive_register_fee?: number;
}) {
return requestClient.post<{
user_patient_id: number;
register_id: number;
is_pay: number;
status: number;
action: 'claim_qrcode' | 'proxy_pay' | 'reception';
is_pay: number;
price: string;
register_id: number;
status: number;
user_patient_id: number;
}>(`${prefix}start-offline-reception-by-patient`, data);
}
@@ -732,12 +783,12 @@ export async function getProxyPayQrcodeApi(orderId: number) {
/** 处方图 OCR(DeepSeek) */
export async function ocrPrescriptionImageApi(data: {
/** 传方识别前粘贴的收货人信息,仅 transfer 生效 */
consignee_text?: string;
image_urls: string[];
mode: 'reception' | 'transfer';
register_id?: number;
store_id?: number;
/** 传方识别前粘贴的收货人信息,仅 transfer 生效 */
consignee_text?: string;
}) {
return requestClient.post<any>(`${prefix}ocr-prescription-image`, data);
}
@@ -745,28 +796,28 @@ export async function ocrPrescriptionImageApi(data: {
/** 确认识图传方:建患者/挂号 + 待导入传方单 */
export async function confirmTransferImageOcrApi(data: Record<string, any>) {
return requestClient.post<{
transfer_id: number;
register_id: number;
user_patient_id: number;
patient_action: string;
register_id: number;
transfer_id: number;
user_patient_id: number;
}>(`${prefix}confirm-transfer-image-ocr`, data);
}
/** 识图传方主动匹配就诊人 */
export async function matchTransferPatientApi(data: {
patient_name?: string;
patient_mobile?: string;
name?: string;
mobile?: string;
name?: string;
patient_mobile?: string;
patient_name?: string;
}) {
return requestClient.post<{
matched: boolean;
user_patient_id: number;
name: string;
mobile: string;
sex: number;
age: number;
address: string;
age: number;
matched: boolean;
mobile: string;
name: string;
sex: number;
user_patient_id: number;
}>(`${prefix}match-transfer-patient`, data);
}

View File

@@ -7,7 +7,7 @@
import { computed, nextTick, ref, watch } from 'vue';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons-vue';
import { Checkbox, Input, InputNumber, Modal, message } from 'ant-design-vue';
import { Checkbox, Input, InputNumber, message, Modal } from 'ant-design-vue';
import {
deleteProcessRulePreset,
@@ -19,51 +19,51 @@ import {
/** 中药配置变更载荷 */
export type ChineseMedicineConfigChange = {
ruleType?: number;
packageMethodId?: number | null;
processRuleId?: number | null;
childProcessRuleId?: number | null;
processRuleNoteId?: number | null;
dosage?: number;
childProcessRuleId?: null | number;
dayDosage?: number;
dosage?: number;
packageMethodId?: null | number;
processRuleId?: null | number;
processRuleNoteId?: null | number;
ruleType?: number;
};
/** 预设方案列表项 */
export type ProcessRulePresetItem = {
id: number;
name: string;
rule_type: number;
package_method_id: number;
process_rule_id: number;
child_process_rule_id: number;
process_rule_note_id: number;
childProcessRuleId?: null | number;
id: number;
is_default: number;
name: string;
package_method_id: number;
packageMethodId?: null | number;
process_rule_id: number;
process_rule_note_id: number;
processRuleId?: null | number;
processRuleNoteId?: null | number;
rule_type: number;
ruleType?: number;
packageMethodId?: number | null;
processRuleId?: number | null;
childProcessRuleId?: number | null;
processRuleNoteId?: number | null;
};
const props = withDefaults(
defineProps<{
ruleType: number;
packageMethodId?: number | null;
processRuleId?: number | null;
childProcessRuleId?: number | null;
processRuleNoteId?: number | null;
dosage: number;
dayDosage: number;
processRuleList?: Array<{ id: number; name: string }>;
childProcessRuleList?: Array<{ id: number; name: string }>;
processRuleNoteList?: Array<{ id: number; note?: string; name?: string }>;
disabled?: boolean;
/** 未填字段名;父页提交失败传入,改选后由父清空 */
errorKeys?: string[];
/** 当前履约门店,用于加载/保存预设 */
storeId?: number | null;
/** 进入中药且无草稿覆盖时自动应用默认方案 */
autoApplyDefault?: boolean;
childProcessRuleId?: null | number;
childProcessRuleList?: Array<{ id: number; name: string }>;
dayDosage: number;
disabled?: boolean;
dosage: number;
/** 未填字段名;父页提交失败传入,改选后由父清空 */
errorKeys?: string[];
packageMethodId?: null | number;
processRuleId?: null | number;
processRuleList?: Array<{ id: number; name: string }>;
processRuleNoteId?: null | number;
processRuleNoteList?: Array<{ id: number; name?: string; note?: string; }>;
ruleType: number;
/** 当前履约门店,用于加载/保存预设 */
storeId?: null | number;
}>(),
{
processRuleList: () => [],
@@ -88,7 +88,7 @@ const packageMethodList = [
const presets = ref<ProcessRulePresetItem[]>([]);
const presetsLoading = ref(false);
const activePresetId = ref<number | null>(null);
const activePresetId = ref<null | number>(null);
/** 每个门店只自动应用一次,避免反复覆盖医生手动改选 */
const autoAppliedStoreIds = ref<Set<number>>(new Set());
/** 新增/编辑预设弹窗 */
@@ -99,15 +99,15 @@ const editForm = ref({
name: '',
is_default: false,
ruleType: 1 as number,
packageMethodId: 2 as number | null,
processRuleId: null as number | null,
childProcessRuleId: null as number | null,
processRuleNoteId: null as number | null,
packageMethodId: 2 as null | number,
processRuleId: null as null | number,
childProcessRuleId: null as null | number,
processRuleNoteId: null as null | number,
});
const editProcessRuleList = ref<Array<{ id: number; name: string }>>([]);
const editChildProcessRuleList = ref<Array<{ id: number; name: string }>>([]);
const editProcessRuleNoteList = ref<
Array<{ id: number; note?: string; name?: string }>
Array<{ id: number; name?: string; note?: string; }>
>([]);
/** 未填字段 → 区块标红 */
@@ -223,13 +223,16 @@ async function loadPresets() {
list = res.result;
}
presets.value = list;
if (!list.length) {
if (list.length === 0) {
console.warn('[煎药预设] 接口返回空列表 store_id=', sid, 'raw=', res);
}
await maybeAutoApplyDefault(sid);
} catch (e) {
maybeAutoApplyDefault(sid);
if (!props.autoApplyDefault) {
syncActivePresetFromConfig();
}
} catch (error) {
// 失败只记日志,不弹 toast,避免打断正常开方
console.error('加载煎药预设失败 store_id=', sid, e);
console.error('加载煎药预设失败 store_id=', sid, error);
presets.value = [];
} finally {
presetsLoading.value = false;
@@ -238,18 +241,52 @@ async function loadPresets() {
/**
* 自动应用默认方案(每门店一次)
* 为什么:开方进入中药希望直接落到医生常用煎药组合
* 优先级:修改(草稿/已填) > 默认方案 > 旧硬编码;父页 autoApplyDefault=false 时绝不覆盖
* 为什么不在 autoApply=false 时标记已应用:父页可能稍后才放开(草稿判定异步),需能补触发
*/
async function maybeAutoApplyDefault(storeId: number) {
function maybeAutoApplyDefault(storeId: number) {
if (!props.autoApplyDefault || props.disabled) return;
if (autoAppliedStoreIds.value.has(storeId)) return;
// 列表还在拉时不标记,等 loadPresets finally 后再试
if (presetsLoading.value) return;
const def = presets.value.find((p) => Number(p.is_default) === 1);
if (!def) {
autoAppliedStoreIds.value.add(storeId);
// 无论有无默认都记一次,避免无默认时反复空跑;无默认则走父页旧逻辑(剂包等)
autoAppliedStoreIds.value.add(storeId);
if (def) {
applyPreset(def);
} else {
syncActivePresetFromConfig();
}
}
/**
* 按当前制剂配置高亮匹配的预设(刷新恢复后没有 activePresetId)
*/
function syncActivePresetFromConfig() {
const rt = Number(props.ruleType || 0);
if (!rt || presets.value.length === 0) {
activePresetId.value = null;
return;
}
autoAppliedStoreIds.value.add(storeId);
applyPreset(def);
const hit = presets.value.find((p) => {
const prt = Number(p.ruleType ?? p.rule_type ?? 0);
if (prt !== rt) return false;
if (rt === 1) {
return (
Number(p.packageMethodId ?? p.package_method_id ?? 0) ===
Number(props.packageMethodId || 0)
);
}
return (
Number(p.processRuleId ?? p.process_rule_id ?? 0) ===
Number(props.processRuleId || 0) &&
Number(p.childProcessRuleId ?? p.child_process_rule_id ?? 0) ===
Number(props.childProcessRuleId || 0) &&
Number(p.processRuleNoteId ?? p.process_rule_note_id ?? 0) ===
Number(props.processRuleNoteId || 0)
);
});
activePresetId.value = hit ? hit.id : null;
}
/** 点击方案:整包回填(不含剂数) */
@@ -260,7 +297,9 @@ function applyPreset(item: ProcessRulePresetItem) {
if (ruleType === 1) {
emitChange({
ruleType: 1,
packageMethodId: Number((item.packageMethodId ?? item.package_method_id) || 2),
packageMethodId: Number(
(item.packageMethodId ?? item.package_method_id) || 2,
),
processRuleId: null,
childProcessRuleId: null,
processRuleNoteId: null,
@@ -270,11 +309,14 @@ function applyPreset(item: ProcessRulePresetItem) {
emitChange({
ruleType: 2,
packageMethodId: null,
processRuleId: Number((item.processRuleId ?? item.process_rule_id) || 0) || null,
processRuleId:
Number((item.processRuleId ?? item.process_rule_id) || 0) || null,
childProcessRuleId:
Number((item.childProcessRuleId ?? item.child_process_rule_id) || 0) || null,
Number((item.childProcessRuleId ?? item.child_process_rule_id) || 0) ||
null,
processRuleNoteId:
Number((item.processRuleNoteId ?? item.process_rule_note_id) || 0) || null,
Number((item.processRuleNoteId ?? item.process_rule_note_id) || 0) ||
null,
});
}
@@ -296,7 +338,7 @@ async function loadEditProcessRules(pid = 0, ruleId = 0) {
* 打开新增/编辑弹窗;新增时用当前页面配置预填。
* 注意:必须用 openPresetEditor() 调新增;若误传 MouseEvent,不能当成编辑项(event 无业务 id)。
*/
async function openPresetEditor(item?: ProcessRulePresetItem | null) {
async function openPresetEditor(item?: null | ProcessRulePresetItem) {
if (props.disabled) return;
const sid = Number(props.storeId || 0);
if (sid <= 0) {
@@ -304,7 +346,11 @@ async function openPresetEditor(item?: ProcessRulePresetItem | null) {
return;
}
// 仅业务预设可进编辑:必须有正 id + name;排除误传入的 MouseEvent(有 type 无 name)
const raw = item as ProcessRulePresetItem | (Event & { id?: number }) | null | undefined;
const raw = item as
| (Event & { id?: number })
| null
| ProcessRulePresetItem
| undefined;
const isEditItem = !!(
raw &&
Number((raw as ProcessRulePresetItem).id) > 0 &&
@@ -328,13 +374,15 @@ async function openPresetEditor(item?: ProcessRulePresetItem | null) {
: null,
childProcessRuleId:
ruleType === 2
? Number((item.childProcessRuleId ?? item.child_process_rule_id) || 0) ||
null
? Number(
(item.childProcessRuleId ?? item.child_process_rule_id) || 0,
) || null
: null,
processRuleNoteId:
ruleType === 2
? Number((item.processRuleNoteId ?? item.process_rule_note_id) || 0) ||
null
? Number(
(item.processRuleNoteId ?? item.process_rule_note_id) || 0,
) || null
: null,
};
} else {
@@ -344,8 +392,10 @@ async function openPresetEditor(item?: ProcessRulePresetItem | null) {
name: '',
is_default: false,
ruleType,
packageMethodId: ruleType === 1 ? Number(props.packageMethodId || 2) : null,
processRuleId: ruleType === 2 ? Number(props.processRuleId || 0) || null : null,
packageMethodId:
ruleType === 1 ? Number(props.packageMethodId || 2) : null,
processRuleId:
ruleType === 2 ? Number(props.processRuleId || 0) || null : null,
childProcessRuleId:
ruleType === 2 ? Number(props.childProcessRuleId || 0) || null : null,
processRuleNoteId:
@@ -420,7 +470,7 @@ async function submitPresetEditor() {
const err = validateEditForm();
if (err) {
message.warning(err);
return Promise.reject();
throw undefined;
}
editSaving.value = true;
try {
@@ -431,8 +481,10 @@ async function submitPresetEditor() {
name: String(editForm.value.name || '').trim(),
is_default: editForm.value.is_default ? 1 : 0,
rule_type: ruleType,
package_method_id: ruleType === 1 ? Number(editForm.value.packageMethodId) : 0,
process_rule_id: ruleType === 2 ? Number(editForm.value.processRuleId) : 0,
package_method_id:
ruleType === 1 ? Number(editForm.value.packageMethodId) : 0,
process_rule_id:
ruleType === 2 ? Number(editForm.value.processRuleId) : 0,
child_process_rule_id:
ruleType === 2 ? Number(editForm.value.childProcessRuleId) : 0,
process_rule_note_id:
@@ -441,9 +493,9 @@ async function submitPresetEditor() {
message.success(editForm.value.id ? '已更新方案' : '已保存方案');
editVisible.value = false;
await loadPresets();
} catch (e) {
console.error(e);
return Promise.reject(e);
} catch (error) {
console.error(error);
return Promise.reject(error);
} finally {
editSaving.value = false;
}
@@ -456,8 +508,8 @@ async function handleSetDefault(item: ProcessRulePresetItem) {
await setProcessRulePresetDefault({ id: item.id });
message.success('已设为默认');
await loadPresets();
} catch (e) {
console.error(e);
} catch (error) {
console.error(error);
}
}
@@ -502,6 +554,8 @@ function onPresetContextMenu(item: ProcessRulePresetItem, e: MouseEvent) {
watch(
() => Number(props.storeId || 0),
(sid) => {
// 换门店重置「已自动应用」标记,允许新门店再走默认方案
autoAppliedStoreIds.value = new Set();
if (sid > 0) {
nextTick(() => loadPresets());
} else {
@@ -510,12 +564,47 @@ watch(
},
{ immediate: true },
);
/**
* 父页草稿判定完成后才放开 autoApplyDefault;此处补触发默认方案
* 为什么:避免选患者时 CMC 先挂载、草稿尚未恢复就把默认方案盖上去
*/
watch(
() => props.autoApplyDefault,
(allow) => {
if (!allow) return;
const sid = Number(props.storeId || 0);
if (sid > 0) {
maybeAutoApplyDefault(sid);
}
},
);
watch(
() =>
[
props.ruleType,
props.packageMethodId,
props.processRuleId,
props.childProcessRuleId,
props.processRuleNoteId,
presets.value.length,
] as const,
() => {
if (!props.autoApplyDefault) {
syncActivePresetFromConfig();
}
},
);
</script>
<template>
<div class="chinese-medicine-config">
<!-- 制剂方式 + 预设方案同一行,中间格挡隔离 -->
<div class="config-section mb-4" :class="{ 'sec-error': errorMap.ruleType }">
<div
class="config-section mb-4"
:class="{ 'sec-error': errorMap.ruleType }"
>
<div class="section-title mb-2">
<span class="title-text">制剂方式</span>
<span class="req-star">*</span>
@@ -526,12 +615,12 @@ watch(
class="pill"
:class="{ active: ruleType === 1, disabled }"
@click="handleRuleTypeChange(1)"
>自制剂</span>
>自制剂</span>
<span
class="pill"
:class="{ active: ruleType === 2, disabled }"
@click="handleRuleTypeChange(2)"
>委托调剂</span>
>委托调剂</span>
</div>
<span class="row-divider" aria-hidden="true"></span>
<div class="preset-side">
@@ -560,22 +649,26 @@ watch(
class="preset-edit-btn"
title="编辑方案"
@click.stop="openPresetEditor(item)"
>编辑</button>
>
编辑
</button>
</span>
<span
class="pill outline add-preset"
:class="{ disabled: disabled || presetsLoading }"
title="新增预设方案"
@click="openPresetEditor()"
>+</span>
>+</span>
<span
v-if="!presetsLoading && Number(storeId || 0) > 0 && !presets.length"
v-if="
!presetsLoading && Number(storeId || 0) > 0 && !presets.length
"
class="preset-empty-tip"
>暂无方案</span>
>暂无方案</span>
<span
v-else-if="!presetsLoading && Number(storeId || 0) <= 0"
class="preset-empty-tip"
>请先选门店</span>
>请先选门店</span>
</div>
</div>
</div>
@@ -604,7 +697,9 @@ watch(
/>
</div>
<div class="edit-row edit-row-actions">
<Checkbox v-model:checked="editForm.is_default">设为默认方案</Checkbox>
<Checkbox v-model:checked="editForm.is_default">
设为默认方案
</Checkbox>
<button
v-if="editForm.id"
type="button"
@@ -616,7 +711,9 @@ watch(
is_default: editForm.is_default ? 1 : 0,
} as ProcessRulePresetItem)
"
>删除此方案</button>
>
删除此方案
</button>
</div>
<div class="edit-row">
<span class="edit-label">制剂方式</span>
@@ -625,12 +722,12 @@ watch(
class="pill"
:class="{ active: editForm.ruleType === 1 }"
@click="onEditRuleTypeChange(1)"
>自制剂</span>
>自制剂</span>
<span
class="pill"
:class="{ active: editForm.ruleType === 2 }"
@click="onEditRuleTypeChange(2)"
>委托调剂</span>
>委托调剂</span>
</div>
</div>
<div v-if="editForm.ruleType === 1" class="edit-row">
@@ -642,7 +739,7 @@ watch(
class="pill outline"
:class="{ active: editForm.packageMethodId === pm.id }"
@click="editForm.packageMethodId = pm.id"
>{{ pm.name }}</span>
>{{ pm.name }}</span>
</div>
</div>
<template v-if="editForm.ruleType === 2">
@@ -655,7 +752,7 @@ watch(
class="pill outline"
:class="{ active: editForm.processRuleId === pr.id }"
@click="onEditProcessRuleClick(pr.id)"
>{{ pr.name }}</span>
>{{ pr.name }}</span>
</div>
</div>
<div v-if="editChildProcessRuleList.length" class="edit-row">
@@ -667,7 +764,7 @@ watch(
class="pill outline"
:class="{ active: editForm.childProcessRuleId === cr.id }"
@click="onEditChildProcessRuleClick(cr.id)"
>{{ cr.name }}</span>
>{{ cr.name }}</span>
</div>
</div>
<div v-if="editProcessRuleNoteList.length" class="edit-row">
@@ -679,7 +776,7 @@ watch(
class="pill outline"
:class="{ active: editForm.processRuleNoteId === nr.id }"
@click="editForm.processRuleNoteId = nr.id"
>{{ nr.note || nr.name }}</span>
>{{ nr.note || nr.name }}</span>
</div>
</div>
</template>
@@ -703,7 +800,7 @@ watch(
class="pill outline"
:class="{ active: packageMethodId === item.id, disabled }"
@click="handlePackageMethodClick(item.id)"
>{{ item.name }}</span>
>{{ item.name }}</span>
</div>
<div v-if="errorMap.packageMethodId" class="err-tip">请选择包法</div>
</div>
@@ -725,7 +822,7 @@ watch(
class="pill outline"
:class="{ active: processRuleId === item.id, disabled }"
@click="handleProcessRuleClick(item)"
>{{ item.name }}</span>
>{{ item.name }}</span>
</div>
<div v-if="errorMap.processRuleId" class="err-tip">请选择制剂要求</div>
</div>
@@ -745,9 +842,11 @@ watch(
class="pill outline"
:class="{ active: childProcessRuleId === item.id, disabled }"
@click="handleChildProcessRuleClick(item)"
>{{ item.name }}</span>
>{{ item.name }}</span>
</div>
<div v-if="errorMap.childProcessRuleId" class="err-tip">
请选择煎法说明
</div>
<div v-if="errorMap.childProcessRuleId" class="err-tip">请选择煎法说明</div>
</div>
<div
v-if="processRuleNoteList?.length"
@@ -765,16 +864,21 @@ watch(
class="pill outline"
:class="{ active: processRuleNoteId === item.id, disabled }"
@click="handleProcessRuleNoteClick(item)"
>{{ item.note || item.name }}</span>
>{{ item.note || item.name }}</span>
</div>
<div v-if="errorMap.processRuleNoteId" class="err-tip">
请选择附加备注
</div>
<div v-if="errorMap.processRuleNoteId" class="err-tip">请选择附加备注</div>
</div>
</template>
<!-- 剂数与频次 -->
<div class="config-section border-t border-dashed border-border pt-4">
<div class="mt-2 flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2" :class="{ 'field-error': errorMap.dosage }">
<div
class="flex items-center gap-2"
:class="{ 'field-error': errorMap.dosage }"
>
<span class="title-text">剂数</span>
<span class="req-star">*</span>
<InputNumber
@@ -786,15 +890,24 @@ watch(
@update:value="(v) => updateDosage(Number(v) || 1)"
>
<template #addonBefore>
<MinusOutlined @click="!disabled && updateDosage(dosage > 1 ? dosage - 1 : 1)" />
<MinusOutlined
@click="!disabled && updateDosage(dosage > 1 ? dosage - 1 : 1)"
/>
</template>
<template #addonAfter>
<PlusOutlined @click="!disabled && updateDosage(dosage < 100 ? dosage + 1 : 100)" />
<PlusOutlined
@click="
!disabled && updateDosage(dosage < 100 ? dosage + 1 : 100)
"
/>
</template>
</InputNumber>
<span class="text-sm text-muted-foreground">剂(天)</span>
</div>
<div class="flex items-center gap-2" :class="{ 'field-error': errorMap.dayDosage }">
<div
class="flex items-center gap-2"
:class="{ 'field-error': errorMap.dayDosage }"
>
<span class="title-text">用药频次</span>
<span class="req-star">*</span>
<InputNumber
@@ -806,10 +919,20 @@ watch(
@update:value="(v) => updateDayDosage(Number(v) || 1)"
>
<template #addonBefore>
<MinusOutlined @click="!disabled && updateDayDosage(dayDosage > 1 ? dayDosage - 1 : 1)" />
<MinusOutlined
@click="
!disabled &&
updateDayDosage(dayDosage > 1 ? dayDosage - 1 : 1)
"
/>
</template>
<template #addonAfter>
<PlusOutlined @click="!disabled && updateDayDosage(dayDosage < 100 ? dayDosage + 1 : 100)" />
<PlusOutlined
@click="
!disabled &&
updateDayDosage(dayDosage < 100 ? dayDosage + 1 : 100)
"
/>
</template>
</InputNumber>
<span class="text-sm text-muted-foreground">次/天</span>
@@ -824,192 +947,222 @@ watch(
<style scoped>
.section-title {
display: flex;
align-items: center;
gap: 2px;
align-items: center;
}
.title-text {
font-size: 14px;
font-weight: 700;
color: hsl(var(--foreground));
}
.req-star {
margin-left: 2px;
color: hsl(var(--destructive, 0 84% 60%));
font-size: 14px;
line-height: 1;
color: hsl(var(--destructive, 0 84% 60%));
}
.err-tip {
margin-top: 6px;
font-size: 12px;
color: hsl(var(--destructive, 0 84% 60%));
}
.sec-error {
padding: 10px;
margin-left: -10px;
margin-right: -10px;
border-radius: 8px;
border: 1px solid hsl(var(--destructive, 0 84% 60%) / 45%);
box-shadow: 0 0 6px hsl(var(--destructive, 0 84% 60%) / 18%);
margin-left: -10px;
background: hsl(var(--destructive, 0 84% 60%) / 6%);
border: 1px solid hsl(var(--destructive, 0 84% 60%) / 45%);
border-radius: 8px;
box-shadow: 0 0 6px hsl(var(--destructive, 0 84% 60%) / 18%);
}
.field-error {
padding: 4px 8px;
border-radius: 8px;
border: 1px solid hsl(var(--destructive, 0 84% 60%) / 45%);
border-radius: 8px;
box-shadow: 0 0 6px hsl(var(--destructive, 0 84% 60%) / 18%);
}
.rule-preset-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 12px;
align-items: center;
}
.row-divider {
width: 1px;
flex-shrink: 0;
align-self: stretch;
width: 1px;
min-height: 28px;
background: hsl(var(--border));
flex-shrink: 0;
}
.preset-side {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-width: 0;
}
.preset-label {
flex-shrink: 0;
font-size: 12px;
color: hsl(var(--muted-foreground));
flex-shrink: 0;
}
.pill-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pill {
position: relative;
padding: 4px 16px;
background-color: hsl(var(--muted) / 0.55);
color: hsl(var(--muted-foreground));
border-radius: 20px;
font-size: 13px;
color: hsl(var(--muted-foreground));
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s;
user-select: none;
background-color: hsl(var(--muted) / 55%);
border: 1px solid transparent;
border-radius: 20px;
transition: all 0.2s;
}
.pill.active {
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground, 0 0% 100%));
border-color: hsl(var(--primary));
font-weight: 600;
color: hsl(var(--primary-foreground, 0 0% 100%));
background-color: hsl(var(--primary));
border-color: hsl(var(--primary));
}
.pill.outline {
background-color: hsl(var(--card, var(--background)));
border-color: hsl(var(--border));
}
.pill.outline.active {
background-color: hsl(var(--primary) / 0.12);
color: hsl(var(--primary));
background-color: hsl(var(--primary) / 12%);
border-color: hsl(var(--primary));
}
.pill.disabled {
opacity: 0.5;
pointer-events: none;
opacity: 0.5;
}
.preset-pill.is-default {
border-color: hsl(var(--primary) / 40%);
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.default-badge {
display: inline-block;
margin-left: 2px;
padding: 0 4px;
margin-left: 2px;
font-size: 10px;
font-style: normal;
line-height: 14px;
border-radius: 3px;
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
background: hsl(var(--primary) / 15%);
border-radius: 3px;
}
.preset-pill {
display: inline-flex;
align-items: center;
gap: 4px;
align-items: center;
max-width: 100%;
}
.preset-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 常显编辑入口:不依赖悬停/右键,避免「点了没反应」 */
.preset-edit-btn {
flex-shrink: 0;
margin: 0;
padding: 0 6px;
margin: 0;
font-size: 11px;
line-height: 18px;
border-radius: 10px;
border: 1px solid hsl(var(--primary) / 40%);
background: hsl(var(--background));
color: hsl(var(--primary));
cursor: pointer;
pointer-events: auto;
cursor: pointer;
background: hsl(var(--background));
border: 1px solid hsl(var(--primary) / 40%);
border-radius: 10px;
}
.preset-edit-btn:hover {
background: hsl(var(--primary) / 0.1);
background: hsl(var(--primary) / 10%);
}
.preset-del-btn {
border-color: hsl(var(--destructive, 0 84% 60%) / 40%);
color: hsl(var(--destructive, 0 84% 60%));
border-color: hsl(var(--destructive, 0 84% 60%) / 40%);
}
.pill.outline.active .preset-edit-btn,
.preset-pill.is-default .preset-edit-btn {
border-color: hsl(var(--primary) / 50%);
}
.preset-empty-tip {
font-size: 12px;
color: hsl(var(--muted-foreground));
line-height: 28px;
color: hsl(var(--muted-foreground));
}
.add-preset {
min-width: 32px;
text-align: center;
font-weight: 700;
font-size: 16px;
font-weight: 700;
line-height: 1.2;
text-align: center;
}
.preset-edit-form {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 8px;
}
.edit-row {
display: flex;
flex-direction: column;
gap: 8px;
}
.edit-row-actions {
flex-direction: row;
flex-flow: row wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.edit-label {
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.dosage-input {
width: 140px;
}
.dosage-input :deep(.ant-input-number-input) {
min-width: 48px;
text-align: left;

View File

@@ -1,37 +1,54 @@
<script lang="ts" setup>
/**
* 医生创建就诊人记录:搜索 + 分页列表 + 选择接诊
* 医生创建就诊人记录:搜索 + 分页列表 + 收货摘要/复制/编辑 + 选择接诊
* 未支付时回调父页打开认领码;已可接诊时回调父页选中挂号
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Empty, Input, message, Pagination, Spin, Table, Tag } from 'ant-design-vue';
import {
Button,
Empty,
Input,
message,
Pagination,
Spin,
Table,
Tag,
} from 'ant-design-vue';
import {
copyShipAddressText,
formatShipAddressCopyText,
} from '#/util/ship-address-copy';
import {
getDoctorCreatedUserPatientListApi,
startOfflineReceptionByPatientApi,
} from '#/views/doctor/doctor-reception/api';
import EditCreatedUserPatientModal from './EditCreatedUserPatientModal.vue';
export type DoctorCreatedPatientStartResult = {
user_patient_id: number;
register_id: number;
is_pay: number;
status: number;
action: 'claim_qrcode' | 'proxy_pay' | 'reception';
is_pay: number;
price: string;
register_id: number;
status: number;
user_patient_id: number;
};
type ListItem = {
id: number;
name: string;
mobile?: string;
sex?: number;
age?: number;
created_at?: string;
edit_mode?: 'full' | 'limited';
id: number;
is_claimed: number;
latest_register_status: number;
mobile?: string;
name: string;
sex?: number;
ship_address?: Record<string, string>;
};
const loading = ref(false);
@@ -42,19 +59,24 @@ const pageSize = ref(10);
const total = ref(0);
const list = ref<ListItem[]>([]);
const onStartCb = ref<
null | ((res: DoctorCreatedPatientStartResult) => void | Promise<void>)
((res: DoctorCreatedPatientStartResult) => Promise<void> | void) | null
>(null);
const columns = [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
{ title: '手机', dataIndex: 'mobile', key: 'mobile', width: 120 },
{ title: '年龄', dataIndex: 'age', key: 'age', width: 60 },
{ title: '认领', key: 'is_claimed', width: 80 },
{ title: '最近挂号', key: 'register_status', width: 100 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 160 },
{ title: '操作', key: 'action', width: 90, fixed: 'right' as const },
{ title: '姓名', dataIndex: 'name', key: 'name', width: 90 },
{ title: '手机', dataIndex: 'mobile', key: 'mobile', width: 110 },
{ title: '年龄', dataIndex: 'age', key: 'age', width: 50 },
{ title: '收货地址', key: 'ship', width: 220 },
{ title: '认领', key: 'is_claimed', width: 70 },
{ title: '最近挂号', key: 'register_status', width: 90 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 150 },
{ title: '操作', key: 'action', width: 150, fixed: 'right' as const },
];
const [EditModal, editModalApi] = useVbenModal({
connectedComponent: EditCreatedUserPatientModal,
});
/**
* 挂号状态文案(与 RegisterStatusEnum 对齐)
*/
@@ -70,6 +92,35 @@ function registerStatusText(status: number): string {
return map[status] || '—';
}
/** 收货摘要一行文案 */
function shipSummary(row: ListItem): string {
const ship = row.ship_address || {};
const region =
String(ship.region || '').trim() ||
[ship.province, ship.city, ship.area].filter(Boolean).join('');
const detail = String(ship.detail_address || '').trim();
const place = region + detail;
if (!place && !ship.name && !ship.mobile) return '未填写';
return [ship.name || '', ship.mobile || '', place].filter(Boolean).join(' ');
}
/** 一键复制收货信息 */
function copyShip(row: ListItem) {
void copyShipAddressText(formatShipAddressCopyText(row.ship_address || {}));
}
/** 打开编辑弹窗;保存后刷新列表 */
function openEdit(row: ListItem) {
if (!row?.id) return;
editModalApi.setData({
user_patient_id: row.id,
onSaved: () => {
void fetchList();
},
});
editModalApi.open();
}
/**
* 拉取医生创建记录列表
*/
@@ -83,8 +134,8 @@ async function fetchList() {
});
list.value = Array.isArray(res?.items) ? res.items : [];
total.value = Number(res?.total || 0);
} catch (e: any) {
message.error(e?.message || '加载创建记录失败');
} catch (error: any) {
message.error(error?.message || '加载创建记录失败');
} finally {
loading.value = false;
}
@@ -115,8 +166,8 @@ async function handleStart(row: ListItem) {
message.success('操作成功');
modalApi.close();
await onStartCb.value?.(res as DoctorCreatedPatientStartResult);
} catch (e: any) {
message.error(e?.message || '接诊失败');
} catch (error: any) {
message.error(error?.message || '接诊失败');
} finally {
startingId.value = 0;
}
@@ -133,7 +184,7 @@ const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData() as {
onStart?: (res: DoctorCreatedPatientStartResult) => void | Promise<void>;
onStart?: (res: DoctorCreatedPatientStartResult) => Promise<void> | void;
};
onStartCb.value = data?.onStart || null;
keyword.value = '';
@@ -144,7 +195,8 @@ const [Modal, modalApi] = useVbenModal({
</script>
<template>
<Modal class="w-[860px]" title="医生创建记录">
<Modal class="w-[980px]" title="医生创建记录">
<EditModal />
<div class="mb-3 flex gap-2">
<Input
v-model:value="keyword"
@@ -162,10 +214,26 @@ const [Modal, modalApi] = useVbenModal({
:pagination="false"
row-key="id"
size="small"
:scroll="{ x: 720 }"
:scroll="{ x: 920 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'is_claimed'">
<template v-if="column.key === 'ship'">
<div class="flex items-start gap-1">
<span class="line-clamp-2 text-xs text-[hsl(var(--foreground))]">
{{ shipSummary(record) }}
</span>
<Button
v-if="shipSummary(record) !== '未填写'"
type="link"
size="small"
class="shrink-0 px-0"
@click="copyShip(record)"
>
复制
</Button>
</div>
</template>
<template v-else-if="column.key === 'is_claimed'">
<Tag :color="record.is_claimed ? 'success' : 'warning'">
{{ record.is_claimed ? '已认领' : '未认领' }}
</Tag>
@@ -174,6 +242,9 @@ const [Modal, modalApi] = useVbenModal({
{{ registerStatusText(record.latest_register_status) }}
</template>
<template v-else-if="column.key === 'action'">
<Button type="link" size="small" @click="openEdit(record)">
编辑
</Button>
<Button
type="link"
size="small"

View File

@@ -0,0 +1,528 @@
<script lang="ts" setup>
import type { AddressRecognizeResult } from '#/components/address-paste-block/AddressPasteBlock.vue';
import type { AddressValue } from '#/util/address-index';
/**
* 编辑医生创建就诊人:左右分栏对齐新建弹窗(左收货、右基础);按 edit_mode 锁定基础区
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
Button,
Col,
Form,
FormItem,
Input,
InputNumber,
message,
Radio,
RadioGroup,
Row,
} from 'ant-design-vue';
import AddressPasteBlock from '#/components/address-paste-block/AddressPasteBlock.vue';
import RegionAddressPicker from '#/components/form/components/region-address-picker.vue';
import {
ensureResolvedAddress,
formatAddressDisplay,
resolveAddressPairFromNames,
} from '#/util/address-index';
import {
copyShipAddressText,
formatShipAddressCopyText,
} from '#/util/ship-address-copy';
import {
getCreatedUserPatientDetailApi,
getCurrentStoreTypeApi,
updateCreatedUserPatientApi,
} from '#/views/doctor/doctor-reception/api';
/** 当前诊所地址(get-current-store-type.store_address) */
type ClinicAddress = {
available: boolean;
city: string;
city_id: number;
contact: string;
mobile: string;
path_label: string;
position: string;
province: string;
province_id: number;
store_name: string;
};
const saving = ref(false);
const editMode = ref<'full' | 'limited'>('full');
const userPatientId = ref(0);
const clinicAddress = ref<ClinicAddress | null>(null);
const form = ref({
name: '',
mobile: '',
id_card: '',
birthday: '',
sex: 1,
age: undefined as number | undefined,
ship_region: undefined as AddressValue | undefined,
ship_detail_address: '',
ship_name: '',
ship_mobile: '',
});
const pasteCurrentValues = computed(() => ({
name: String(form.value.ship_name || '').trim(),
phone: String(form.value.ship_mobile || '').trim(),
region: form.value.ship_region
? formatAddressDisplay(form.value.ship_region) || '已选省市区'
: '',
detail: String(form.value.ship_detail_address || '').trim(),
}));
const pasteFilled = computed(() => ({
name: !!pasteCurrentValues.value.name,
phone: !!pasteCurrentValues.value.phone,
region: !!pasteCurrentValues.value.region,
detail: !!pasteCurrentValues.value.detail,
}));
const shipCopyText = computed(() =>
formatShipAddressCopyText({
name: form.value.ship_name || form.value.name,
mobile: form.value.ship_mobile || form.value.mobile,
region: pasteCurrentValues.value.region,
detail_address: form.value.ship_detail_address,
}),
);
/** 诊所地址展示文案 */
const clinicAddressText = computed(() => {
const a = clinicAddress.value;
if (!a?.available) return '';
return `${a.path_label || ''}${a.position || ''}`;
});
const onSavedCb = ref<(() => void) | null>(null);
/**
* 一键填入诊所地址(省市区 ID + 详细地址;收件人优先诊所联系人)
*/
async function applyClinicAddress() {
const a = clinicAddress.value;
if (!a?.available) {
message.warning('当前诊所未配置完整地址');
return;
}
form.value.ship_region = [a.province_id, a.city_id];
form.value.ship_detail_address = a.position;
await ensureResolvedAddress(form.value.ship_region);
if (a.contact) {
form.value.ship_name = a.contact.slice(0, 10);
} else if (!form.value.ship_name && form.value.name) {
form.value.ship_name = form.value.name.slice(0, 10);
}
if (a.mobile) {
form.value.ship_mobile = a.mobile;
} else if (!form.value.ship_mobile && form.value.mobile) {
form.value.ship_mobile = form.value.mobile;
}
message.success('已填入诊所地址');
}
/** 打开时拉取诊所地址,供左侧一键填入 */
async function loadClinicAddress() {
try {
const res = await getCurrentStoreTypeApi();
clinicAddress.value = (res?.store_address || null) as ClinicAddress | null;
} catch {
clinicAddress.value = null;
}
}
async function loadDetail(id: number) {
const res = await getCreatedUserPatientDetailApi(id);
editMode.value = res?.edit_mode === 'limited' ? 'limited' : 'full';
form.value.name = res?.name || '';
form.value.mobile = res?.mobile || '';
form.value.id_card = res?.id_card || '';
form.value.birthday = res?.birthday || '';
form.value.sex = Number(res?.sex || 1);
form.value.age = res?.age ? Number(res.age) : undefined;
const ship = res?.ship_address || {};
form.value.ship_name = ship.name || '';
form.value.ship_mobile = ship.mobile || '';
form.value.ship_detail_address = ship.detail_address || '';
if (ship.province || ship.city || ship.area) {
const pair = await resolveAddressPairFromNames(
ship.province,
ship.city,
ship.area,
);
if (pair) {
form.value.ship_region = pair;
await ensureResolvedAddress(pair);
}
} else {
form.value.ship_region = undefined;
}
}
async function onAddressRecognized(result: AddressRecognizeResult) {
if (!result) return;
if (result.name) form.value.ship_name = String(result.name).slice(0, 10);
if (result.phone) form.value.ship_mobile = result.phone;
if (result.detail) form.value.ship_detail_address = result.detail;
if (result.province || result.city || result.area) {
const pair = await resolveAddressPairFromNames(
result.province,
result.city,
result.area,
);
if (pair) {
form.value.ship_region = pair;
await ensureResolvedAddress(pair);
}
}
}
async function buildPayload() {
const payload: Record<string, any> = {
user_patient_id: userPatientId.value,
mobile: form.value.mobile,
ship_name: form.value.ship_name,
ship_mobile: form.value.ship_mobile,
ship_detail_address: form.value.ship_detail_address,
};
if (form.value.ship_region?.length) {
const resolved = await ensureResolvedAddress(form.value.ship_region);
payload.ship_province = resolved.province?.label || '';
payload.ship_city = resolved.city?.label || '';
payload.ship_area = resolved.district?.label || '';
payload.ship_region =
resolved.pathLabel || formatAddressDisplay(form.value.ship_region);
}
if (editMode.value === 'full') {
payload.name = form.value.name;
payload.id_card = form.value.id_card;
payload.birthday = form.value.birthday;
payload.sex = form.value.sex;
payload.age = form.value.age;
}
return payload;
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
confirmText: '保存',
contentClass: 'max-h-[75vh] overflow-hidden cup-edit-modal-content',
async onConfirm() {
if (!/^\d{11}$/.test(String(form.value.mobile || ''))) {
message.warning('请填写正确手机号');
return;
}
if (editMode.value === 'full') {
if (!String(form.value.name || '').trim()) {
message.warning('请填写姓名');
return;
}
if (!form.value.id_card && !form.value.birthday) {
message.warning('请填写身份证或生日');
return;
}
}
modalApi.setState({ confirmLoading: true });
saving.value = true;
try {
await updateCreatedUserPatientApi(await buildPayload());
message.success('保存成功');
onSavedCb.value?.();
modalApi.close();
} catch (error: any) {
message.error(error?.message || '保存失败');
} finally {
saving.value = false;
modalApi.setState({ confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData() as {
onSaved?: () => void;
user_patient_id?: number;
};
onSavedCb.value = data?.onSaved || null;
userPatientId.value = Number(data?.user_patient_id || 0);
clinicAddress.value = null;
void loadClinicAddress();
if (userPatientId.value > 0) {
try {
await loadDetail(userPatientId.value);
} catch (error: any) {
message.error(error?.message || '加载失败');
}
}
},
});
</script>
<template>
<Modal class="w-[1120px] max-w-[95vw]" title="编辑就诊人">
<Form layout="vertical" class="edit-user-patient-form">
<div class="cup-layout">
<!-- 左:收货地址(与新建弹窗同栏) -->
<aside class="cup-address-col">
<div class="cup-section-head">
<span class="cup-section-title">收货地址</span>
<span class="cup-section-sub">开方默认使用</span>
</div>
<div v-if="clinicAddress?.available" class="clinic-hint">
<div class="clinic-hint-text">
诊所地址:{{ clinicAddressText }}
</div>
<Button
type="link"
size="small"
class="clinic-hint-btn"
@click="applyClinicAddress"
>
使用诊所地址
</Button>
</div>
<div v-else class="clinic-hint muted">
未配置可用诊所地址时,可粘贴识别或手填
</div>
<div
class="mb-2 flex items-start justify-between gap-2 rounded border border-[hsl(var(--primary)/30%)] bg-[hsl(var(--primary)/0.08)] p-2"
>
<div class="break-all text-xs text-[hsl(var(--foreground))]">
{{ shipCopyText || '暂无收货地址' }}
</div>
<Button
type="link"
size="small"
class="shrink-0 px-0"
@click="copyShipAddressText(shipCopyText)"
>
复制
</Button>
</div>
<AddressPasteBlock
:filled="pasteFilled"
:current-values="pasteCurrentValues"
@recognized="onAddressRecognized"
/>
<FormItem label="省市区">
<RegionAddressPicker v-model:value="form.ship_region" />
</FormItem>
<FormItem label="详细地址">
<Input
v-model:value="form.ship_detail_address"
placeholder="街道门牌等"
/>
</FormItem>
<FormItem label="收件人">
<Input
v-model:value="form.ship_name"
:maxlength="10"
show-count
:placeholder="form.name ? `默认:${form.name}` : '收件人姓名'"
/>
</FormItem>
<FormItem label="收件手机">
<Input
v-model:value="form.ship_mobile"
maxlength="11"
:placeholder="form.mobile ? `默认:${form.mobile}` : '收件手机'"
/>
</FormItem>
</aside>
<!-- 右:基础信息 -->
<div class="cup-main-col">
<div
v-if="editMode === 'limited'"
class="mb-3 rounded border border-[hsl(var(--warning)/30%)] bg-[hsl(var(--warning)/0.1)] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
>
该就诊人已接诊过,仅可修改手机号与收货信息
</div>
<div class="mb-3 text-sm font-medium text-[hsl(var(--foreground))]">
基础信息
</div>
<Row :gutter="12">
<Col :span="12">
<FormItem label="姓名" required>
<Input
v-model:value="form.name"
:disabled="editMode === 'limited'"
placeholder="真实姓名"
/>
</FormItem>
</Col>
<Col :span="12">
<FormItem label="手机号" required>
<Input
v-model:value="form.mobile"
maxlength="11"
placeholder="11位手机号"
/>
</FormItem>
</Col>
<Col :span="12">
<FormItem label="身份证号">
<Input
v-model:value="form.id_card"
:disabled="editMode === 'limited'"
maxlength="18"
placeholder="选填"
/>
</FormItem>
</Col>
<Col :span="12">
<FormItem label="生日">
<Input
v-model:value="form.birthday"
:disabled="editMode === 'limited'"
placeholder="YYYY-MM-DD"
/>
</FormItem>
</Col>
<Col :span="12">
<FormItem label="性别">
<RadioGroup
v-model:value="form.sex"
:disabled="editMode === 'limited'"
>
<Radio :value="1">男</Radio>
<Radio :value="2">女</Radio>
</RadioGroup>
</FormItem>
</Col>
<Col :span="12">
<FormItem label="年龄">
<InputNumber
v-model:value="form.age"
:disabled="editMode === 'limited'"
:min="1"
:max="200"
class="w-full"
/>
</FormItem>
</Col>
</Row>
</div>
</div>
</Form>
</Modal>
</template>
<style scoped>
/* 与新建就诊人弹窗同套左右分栏,左右各自滚动 */
.edit-user-patient-form {
display: flex;
flex-direction: column;
height: min(70vh, 640px);
min-height: 360px;
}
.cup-layout {
display: flex;
flex: 1;
gap: 20px;
align-items: stretch;
height: 100%;
min-height: 0;
}
.cup-address-col {
box-sizing: border-box;
flex-shrink: 0;
width: 380px;
padding: 12px;
overflow: hidden auto;
overscroll-behavior: contain;
background: hsl(var(--muted) / 20%);
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
.cup-main-col {
flex: 1;
min-width: 0;
min-height: 0;
padding-right: 4px;
overflow: hidden auto;
overscroll-behavior: contain;
}
.cup-section-head {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: baseline;
margin-bottom: 10px;
}
.cup-section-title {
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
}
.cup-section-sub {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.clinic-hint {
display: flex;
gap: 4px;
align-items: flex-start;
padding: 8px 10px;
margin-bottom: 10px;
font-size: 12px;
color: hsl(var(--foreground));
background: hsl(var(--primary) / 6%);
border: 1px solid hsl(var(--primary) / 25%);
border-radius: 6px;
}
.clinic-hint.muted {
color: hsl(var(--muted-foreground));
background: hsl(var(--card, var(--background)));
border-color: hsl(var(--border));
}
.clinic-hint-text {
flex: 1;
line-height: 1.45;
word-break: break-all;
}
.clinic-hint-btn {
flex-shrink: 0;
height: auto;
padding: 0;
}
@media (max-width: 900px) {
.edit-user-patient-form {
height: auto;
min-height: 0;
max-height: 70vh;
}
.cup-layout {
flex-direction: column;
height: auto;
}
.cup-address-col {
width: 100%;
max-height: 40vh;
}
.cup-main-col {
max-height: 50vh;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,18 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import { onMounted, ref, watch } from 'vue';
import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
} from '@ant-design/icons-vue';
import { Button, Popconfirm } from 'ant-design-vue';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import {
getQuickReplyList,
createQuickReply,
updateQuickReply,
deleteQuickReply
import {
createQuickReply,
deleteQuickReply,
getQuickReplyList,
updateQuickReply,
} from '../api/index';
const props = defineProps({
@@ -110,13 +116,18 @@ onMounted(() => {
loadQuickReplies();
});
watch(() => props.doctorId, () => {
loadQuickReplies();
});
watch(
() => props.doctorId,
() => {
loadQuickReplies();
},
);
</script>
<template>
<div class="quick-reply-bubbles bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div
class="quick-reply-bubbles bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
>
<div class="bubbles-container">
<div
v-for="item in quickReplies"
@@ -139,11 +150,7 @@ watch(() => props.doctorId, () => {
title="确定删除这条快速回复吗?"
@confirm="handleDeleteReply(item.id)"
>
<Button
type="text"
size="small"
danger
>
<Button type="text" size="small" danger>
<template #icon><DeleteOutlined /></template>
</Button>
</Popconfirm>
@@ -159,7 +166,10 @@ watch(() => props.doctorId, () => {
<span>添加常用语</span>
</div>
<div v-else class="add-modal bg-white dark:bg-gray-700 border-blue-500 dark:border-blue-400">
<div
v-else
class="add-modal bg-white dark:bg-gray-700 border-blue-500 dark:border-blue-400"
>
<input
v-model="newContent"
type="text"
@@ -185,23 +195,23 @@ watch(() => props.doctorId, () => {
<style scoped>
.quick-reply-bubbles {
padding: 8px 12px;
padding: var(--chat-quick-pad-y, 6px) var(--chat-quick-pad-x, 10px);
}
.bubbles-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: var(--chat-quick-gap, 6px);
}
.reply-bubble {
position: relative;
display: inline-flex;
align-items: center;
padding: 6px 12px;
padding: var(--chat-quick-bubble-py, 4px) var(--chat-quick-bubble-px, 10px);
cursor: pointer;
border: 1px solid;
border-radius: 16px;
cursor: pointer;
transition: all 0.3s;
}
@@ -210,31 +220,31 @@ watch(() => props.doctorId, () => {
}
.bubble-text {
font-size: 13px;
font-size: var(--chat-quick-font, 12px);
}
.bubble-actions {
display: none;
margin-left: 8px;
gap: 4px;
margin-left: 8px;
}
.add-bubble {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
align-items: center;
padding: var(--chat-quick-bubble-py, 4px) var(--chat-quick-bubble-px, 10px);
font-size: var(--chat-quick-font, 12px);
cursor: pointer;
border: 1px dashed;
border-radius: 16px;
cursor: pointer;
font-size: 13px;
transition: all 0.3s;
}
.add-modal {
display: flex;
align-items: center;
gap: 8px;
align-items: center;
padding: 4px;
border: 1px solid;
border-radius: 16px;
@@ -243,9 +253,9 @@ watch(() => props.doctorId, () => {
.add-input {
flex: 1;
padding: 4px 8px;
border: none;
outline: none;
font-size: 13px;
outline: none;
border: none;
}
.add-actions {

View File

@@ -1,13 +1,24 @@
<script setup lang="ts">
import { Button } from 'ant-design-vue';
import { ShoppingCartOutlined } from '@ant-design/icons-vue';
import PatientOrderListModal from './PatientOrderListModal.vue';
import { computed } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { ShoppingCartOutlined } from '@ant-design/icons-vue';
import { Button } from 'ant-design-vue';
import { useLayoutDensityStore } from '#/views/business/chat/stores/layoutDensity';
import PatientOrderListModal from './PatientOrderListModal.vue';
const props = defineProps<{
registerId?: number | null;
registerId?: null | number;
}>();
/** 悬浮钮在 ChatArea 外,直接读密度 store 同步大/中/小 */
const layoutDensityStore = useLayoutDensityStore();
const floatSize = computed(() => layoutDensityStore.floatBtnSize);
const floatIconSize = computed(() => Math.round(floatSize.value * 0.4));
const [OrderListDrawer, orderListDrawerApi] = useVbenDrawer({
connectedComponent: PatientOrderListModal,
});
@@ -28,13 +39,19 @@ const handleClick = () => {
<Button
type="primary"
shape="circle"
size="large"
@click="handleClick"
class="float-btn"
style="width: 56px; height: 56px; padding: 0; display: flex; align-items: center; justify-content: center;"
:style="{
width: `${floatSize}px`,
height: `${floatSize}px`,
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}"
>
<template #icon>
<ShoppingCartOutlined :style="{ fontSize: '20px' }" />
<ShoppingCartOutlined :style="{ fontSize: `${floatIconSize}px` }" />
</template>
</Button>
<OrderListDrawer />
@@ -44,21 +61,19 @@ const handleClick = () => {
<style scoped>
.order-list-float-button {
position: fixed;
right: 24px;
top: 50%;
transform: translateY(-50%);
right: 24px;
z-index: 1000;
transform: translateY(-50%);
}
.float-btn {
width: 56px;
height: 56px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
transition: all 0.3s;
}
.float-btn:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
box-shadow: 0 6px 16px rgb(0 0 0 / 20%);
transform: translateY(-2px);
}
</style>

View File

@@ -1,12 +1,18 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import { onMounted, ref, watch } from 'vue';
import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
} from '@ant-design/icons-vue';
import { Button, Popconfirm } from 'ant-design-vue';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import {
getQuickReplyList,
createQuickReply,
updateQuickReply,
deleteQuickReply
import {
createQuickReply,
deleteQuickReply,
getQuickReplyList,
updateQuickReply,
} from '../api/index';
const props = defineProps({
@@ -110,13 +116,18 @@ onMounted(() => {
loadQuickReplies();
});
watch(() => props.doctorId, () => {
loadQuickReplies();
});
watch(
() => props.doctorId,
() => {
loadQuickReplies();
},
);
</script>
<template>
<div class="quick-reply-bubbles bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div
class="quick-reply-bubbles bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
>
<div class="bubbles-container">
<div
v-for="item in quickReplies"
@@ -139,11 +150,7 @@ watch(() => props.doctorId, () => {
title="确定删除这条快速回复吗?"
@confirm="handleDeleteReply(item.id)"
>
<Button
type="text"
size="small"
danger
>
<Button type="text" size="small" danger>
<template #icon><DeleteOutlined /></template>
</Button>
</Popconfirm>
@@ -159,7 +166,10 @@ watch(() => props.doctorId, () => {
<span>添加常用语</span>
</div>
<div v-else class="add-modal bg-white dark:bg-gray-700 border-blue-500 dark:border-blue-400">
<div
v-else
class="add-modal bg-white dark:bg-gray-700 border-blue-500 dark:border-blue-400"
>
<input
v-model="newContent"
type="text"
@@ -185,23 +195,23 @@ watch(() => props.doctorId, () => {
<style scoped>
.quick-reply-bubbles {
padding: 8px 12px;
padding: var(--chat-quick-pad-y, 6px) var(--chat-quick-pad-x, 10px);
}
.bubbles-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: var(--chat-quick-gap, 6px);
}
.reply-bubble {
position: relative;
display: inline-flex;
align-items: center;
padding: 6px 12px;
padding: var(--chat-quick-bubble-py, 4px) var(--chat-quick-bubble-px, 10px);
cursor: pointer;
border: 1px solid;
border-radius: 16px;
cursor: pointer;
transition: all 0.3s;
}
@@ -210,31 +220,31 @@ watch(() => props.doctorId, () => {
}
.bubble-text {
font-size: 13px;
font-size: var(--chat-quick-font, 12px);
}
.bubble-actions {
display: none;
margin-left: 8px;
gap: 4px;
margin-left: 8px;
}
.add-bubble {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
align-items: center;
padding: var(--chat-quick-bubble-py, 4px) var(--chat-quick-bubble-px, 10px);
font-size: var(--chat-quick-font, 12px);
cursor: pointer;
border: 1px dashed;
border-radius: 16px;
cursor: pointer;
font-size: 13px;
transition: all 0.3s;
}
.add-modal {
display: flex;
align-items: center;
gap: 8px;
align-items: center;
padding: 4px;
border: 1px solid;
border-radius: 16px;
@@ -243,9 +253,9 @@ watch(() => props.doctorId, () => {
.add-input {
flex: 1;
padding: 4px 8px;
border: none;
outline: none;
font-size: 13px;
outline: none;
border: none;
}
.add-actions {
@@ -253,4 +263,3 @@ watch(() => props.doctorId, () => {
gap: 4px;
}
</style>

View File

@@ -0,0 +1,193 @@
<script lang="ts" setup>
/**
* 开票中心卡片视图
*
* 展示字段与列表列一一对应(ID/订单号/诊所/抬头类型/抬头名称/金额/分账进度/接收方式/邮箱/状态/申请时间),
* 只改排版:金额放大强调,状态用同色 Tag + 卡片同色系微光。
* 拉数、分页、空态全部交给通用 GlassCardList;Tab 状态通过 extraParams 传入,切 Tab 自动回第 1 页重拉。
* 行操作由父级 buildRowActions 构造,与列表 #action 槽是同一份定义,显隐条件天然一致。
*/
import type { ActionItem } from '#/components/table-action';
import { ref } from 'vue';
import { Tag } from 'ant-design-vue';
import { GlassCard, GlassCardList } from '#/components/glass-card';
import { CardActions } from '#/components/table-action';
import { getInvoiceCenterListApi } from '../api';
import { invoiceStatusTagColor, invoiceStatusTone } from '../config/constants';
defineOptions({ name: 'InvoiceCenterCardList' });
const props = defineProps<{
/** 父级构造行操作(与列表 #action 槽同一份定义) */
buildRowActions: (row: Record<string, any>) => ActionItem[];
/** Tab 状态等固定参数(如 { status: '0' }) */
extraParams?: Record<string, any>;
/** 顶部统一搜索条件 */
formValues?: Record<string, any>;
}>();
const listRef = ref<null | {
refreshKeepPage: () => Promise<void>;
reload: () => Promise<void>;
}>(null);
/**
* 是否至少有一个可见操作
* 为什么要判断:没有可见操作时不渲染 footer,避免卡片底部留一条空分割线
*/
function hasVisibleAction(row: Record<string, any>) {
return props.buildRowActions(row).some((action) => action.ifShow !== false);
}
/** 回第 1 页重拉(筛选变化、切 Tab 后由 GlassCardList 自动触发,这里给父级手动调用) */
async function reload() {
await listRef.value?.reload();
}
/** 原地刷新当前页(受理、拒绝、上传发票等操作成功后,避免跳回第 1 页) */
async function refreshKeepPage() {
await listRef.value?.refreshKeepPage();
}
defineExpose({ reload, refreshKeepPage });
</script>
<template>
<GlassCardList
ref="listRef"
:fetcher="getInvoiceCenterListApi"
:form-values="props.formValues"
:extra-params="props.extraParams"
:min-card-width="340"
empty-text="暂无开票申请"
>
<template #card="{ row }">
<GlassCard
hoverable
icon="lucide:receipt-text"
:tone="invoiceStatusTone(Number(row.status))"
>
<template #title>
<span :title="row.store_name">{{ row.store_name || '-' }}</span>
</template>
<template #subtitle>
<span class="invoice-card__sub">#{{ row.id }} · {{ row.order_no || '-' }}</span>
</template>
<template #extra>
<Tag :color="invoiceStatusTagColor(Number(row.status))" class="!mr-0">
{{ row.status_text || '-' }}
</Tag>
</template>
<!-- 金额强调 + 抬头类型 -->
<div class="invoice-card__amount-row">
<span class="invoice-card__amount"><i>¥</i>{{ row.amount ?? '0' }}</span>
<Tag v-if="row.title_type_text" class="!mr-0">
{{
row.title_type_text
}}
</Tag>
</div>
<div class="invoice-card__title-name" :title="row.title_name">
{{ row.title_name || '-' }}
</div>
<dl class="invoice-card__kv">
<div class="invoice-card__kv-item invoice-card__kv-item--full">
<dt>分账进度</dt>
<dd>
<span>{{ row.party_progress || '-' }}</span>
<Tag v-if="row.my_party" class="!mr-0 ml-1" color="blue">
本方:{{ row.my_party.status_text || '-' }}
</Tag>
</dd>
</div>
<div class="invoice-card__kv-item">
<dt>接收方式</dt>
<dd>{{ row.receive_type_text || '-' }}</dd>
</div>
<div class="invoice-card__kv-item">
<dt>申请时间</dt>
<dd>{{ row.created_at || '-' }}</dd>
</div>
<div class="invoice-card__kv-item invoice-card__kv-item--full">
<dt>邮箱</dt>
<dd :title="row.email">{{ row.email || '-' }}</dd>
</div>
</dl>
<template v-if="hasVisibleAction(row)" #footer>
<CardActions :actions="props.buildRowActions(row)" :max-visible="2" />
</template>
</GlassCard>
</template>
</GlassCardList>
</template>
<style scoped>
.invoice-card__sub {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.invoice-card__amount-row {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
}
/* 金额是开票卡片的核心信息,放大加粗并用主色强调 */
.invoice-card__amount {
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 22px;
font-weight: 700;
line-height: 1.3;
color: hsl(var(--primary));
}
.invoice-card__amount i {
margin-right: 1px;
font-size: 14px;
font-style: normal;
}
.invoice-card__title-name {
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 500;
color: hsl(var(--foreground));
white-space: nowrap;
}
.invoice-card__kv {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
margin: 12px 0 0;
}
.invoice-card__kv-item {
min-width: 0;
}
.invoice-card__kv-item--full {
grid-column: 1 / -1;
}
.invoice-card__kv-item dt {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.invoice-card__kv-item dd {
margin: 2px 0 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: hsl(var(--foreground));
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,34 @@
/**
* 开票中心常量:列表视图与卡片视图共用,避免两处各写一套状态配色
*/
/** 开票申请主状态:0待受理 1开票中 2已完成 9已拒绝 */
export const INVOICE_STATUS = {
PENDING: 0,
PROCESSING: 1,
COMPLETED: 2,
REJECTED: 9,
} as const;
/**
* 主状态 → antd Tag 颜色
* 为什么单独抽出:列表 #status 槽与卡片右上角状态 Tag 必须同色,改一处两边同步
*/
export function invoiceStatusTagColor(status: number): string {
if (status === INVOICE_STATUS.PENDING) return 'processing';
if (status === INVOICE_STATUS.PROCESSING) return 'warning';
if (status === INVOICE_STATUS.COMPLETED) return 'success';
if (status === INVOICE_STATUS.REJECTED) return 'error';
return 'default';
}
/**
* 主状态 → GlassCard 主题色变量名(不带 --)
* 卡片边框微光与状态 Tag 同色系,扫一眼就能分辨待办与已完结
*/
export function invoiceStatusTone(status: number): string {
if (status === INVOICE_STATUS.PROCESSING) return 'warning';
if (status === INVOICE_STATUS.COMPLETED) return 'success';
if (status === INVOICE_STATUS.REJECTED) return 'destructive';
return 'primary';
}

View File

@@ -9,9 +9,14 @@ interface RowType {
}
/**
* 开票中心表格配置;statusFilter 由页面注入当前 Tab 状态
* 开票中心表格配置
* @param getStatus 当前 Tab 状态(页面注入)
* @param getSearchValues 页面顶部统一搜索表单的现值(列表/卡片双视图共用,grid 不再内嵌表单)
*/
export function createGridOptions(getStatus: () => string | number): VxeGridProps<RowType> {
export function createGridOptions(
getStatus: () => number | string,
getSearchValues: () => Promise<Record<string, any>>,
): VxeGridProps<RowType> {
return {
checkboxConfig: {
highlight: true,
@@ -24,10 +29,20 @@ export function createGridOptions(getStatus: () => string | number): VxeGridProp
{ field: 'title_type_text', title: '抬头类型', width: 90 },
{ field: 'title_name', title: '抬头名称', minWidth: 120 },
{ field: 'amount', title: '金额', width: 100 },
{ field: 'party_progress', title: '分账进度', width: 100, slots: { default: 'party_progress' } },
{
field: 'party_progress',
title: '分账进度',
width: 100,
slots: { default: 'party_progress' },
},
{ field: 'receive_type_text', title: '接收方式', width: 100 },
{ field: 'email', title: '邮箱', minWidth: 160 },
{ field: 'status_text', title: '状态', width: 90, slots: { default: 'status' } },
{
field: 'status_text',
title: '状态',
width: 90,
slots: { default: 'status' },
},
{ field: 'created_at', title: '申请时间', minWidth: 160 },
{
title: '操作',
@@ -43,8 +58,9 @@ export function createGridOptions(getStatus: () => string | number): VxeGridProp
rowConfig: { useKey: true },
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
query: async ({ page }) => {
const status = getStatus();
const formValues = await getSearchValues();
return await getInvoiceCenterListApi({
page: page.currentPage,
pageSize: page.pageSize,
@@ -57,8 +73,9 @@ export function createGridOptions(getStatus: () => string | number): VxeGridProp
height: 'auto',
border: false,
toolbarConfig: {
// @ts-ignore
search: true,
// 搜索已抽到页面顶部(双视图共用),关闭 grid 自带的表单开关
// @ts-expect-error
search: false,
refresh: true,
print: false,
export: false,

View File

@@ -1,15 +1,28 @@
<script lang="ts" setup>
/**
* 财务开票中心:各方分别受理/开票(平台仅操作本方份额)
* 顶部统一搜索 + Tabs(双视图共用)+ 列表/卡片切换
*/
import { computed, onMounted, ref } from 'vue';
import type { ActionItem } from '#/components/table-action';
import { computed, onMounted, ref, watch } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Badge, Button, Modal, Space, Tabs, Tag, message } from 'ant-design-vue';
import {
Badge,
Button,
message,
Modal,
Space,
Tabs,
Tag,
} from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import FilePreviewModal from '#/components/file-preview/FilePreviewModal.vue';
import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch';
import { useDualViewSearch } from '#/composables/use-dual-view-search';
import {
acceptInvoiceApi,
@@ -17,7 +30,9 @@ import {
resendInvoiceEmailApi,
} from './api';
import CompleteModal from './components/CompleteModal.vue';
import InvoiceCenterCardList from './components/InvoiceCenterCardList.vue';
import RejectModal from './components/RejectModal.vue';
import { invoiceStatusTagColor } from './config/constants';
import { formOptions } from './config/search';
import { createGridOptions } from './config/table';
@@ -39,6 +54,11 @@ const statusFilter = computed(() =>
activeTab.value === 'all' ? '' : activeTab.value,
);
/** Tab 传给卡片列表的固定参数;切 Tab 时 GlassCardList watch extraParams 回第 1 页 */
const cardExtraParams = computed(() => ({
status: statusFilter.value,
}));
const statusStats = ref<Record<string, number>>({
'0': 0,
'1': 0,
@@ -46,18 +66,51 @@ const statusStats = ref<Record<string, number>>({
'9': 0,
});
const [Grid, gridApi] = useVbenVxeGrid({
/** 列表/卡片双视图偏好 */
const viewMode = useViewMode('invoice-center-view-mode');
const cardListRef = ref<InstanceType<typeof InvoiceCenterCardList> | null>(
null,
);
const { SearchForm, searchValues, readLiveSearchValues } = useDualViewSearch({
formOptions,
gridOptions: createGridOptions(() => statusFilter.value),
viewMode,
onListSearch: () => {
gridApi.reload();
},
});
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: createGridOptions(
() => statusFilter.value,
() => readLiveSearchValues(),
),
});
/** 操作成功后刷新当前视图 + 角标统计 */
function reloadAll() {
gridApi.reload();
if (viewMode.value === 'list') {
gridApi.reload();
} else {
cardListRef.value?.reload?.();
}
loadStatusStats();
}
/** 原地刷新(受理等操作后不跳回第 1 页) */
function refreshCurrentView() {
if (viewMode.value === 'list') {
gridApi.query();
} else {
cardListRef.value?.refreshKeepPage?.();
}
loadStatusStats();
}
const gridApiWithStats = {
reload: reloadAll,
query: refreshCurrentView,
};
const [RejectModalComp, rejectModalApi] = useVbenModal({
@@ -85,13 +138,24 @@ async function loadStatusStats() {
}
}
function handleTabChange(key: string | number) {
function handleTabChange(key: number | string) {
const next = String(key);
activeTab.value = next;
localStorage.setItem(TAB_STORAGE_KEY, next);
reloadAll();
// 卡片靠 extraParams watch;列表需手动 reload 回第 1 页
if (viewMode.value === 'list') {
gridApi.reload();
}
loadStatusStats();
}
/** 切回列表时 grid 被 v-if 卸载过,需重查 */
watch(viewMode, (mode, prev) => {
if (mode === 'list' && prev === 'card') {
gridApi.query();
}
});
/** 本方 party 状态:0待受理 1待开票 2已开票 */
function myPartyStatus(row: any) {
return Number(row?.my_party?.status ?? -1);
@@ -104,7 +168,7 @@ async function handleAccept(row: any) {
async onOk() {
await acceptInvoiceApi({ id: row.id, party_id: row?.my_party?.id });
message.success('本方已受理');
reloadAll();
refreshCurrentView();
},
});
}
@@ -138,19 +202,11 @@ async function handleResend(row: any) {
async onOk() {
await resendInvoiceEmailApi({ id: row.id });
message.success('邮件已重发');
reloadAll();
refreshCurrentView();
},
});
}
function statusColor(status: number) {
if (status === 0) return 'processing';
if (status === 1) return 'warning';
if (status === 2) return 'success';
if (status === 9) return 'error';
return 'default';
}
function openPreview(files: string[]) {
if (!files?.length) {
message.info('暂无文件');
@@ -160,6 +216,51 @@ function openPreview(files: string[]) {
previewOpen.value = true;
}
/**
* 行操作定义(列表 #action 与卡片 CardActions 共用同一份,显隐条件天然一致)
*/
function buildRowActions(row: Record<string, any>): ActionItem[] {
return [
{
label: '受理本方',
type: 'link',
ifShow: myPartyStatus(row) === 0 && row.status !== 9,
onClick: () => handleAccept(row),
},
{
label: '拒绝',
type: 'link',
danger: true,
ifShow: row.status === 0 && myPartyStatus(row) === 0,
onClick: () => openReject(row),
},
{
label: '上传本方发票',
type: 'link',
ifShow: myPartyStatus(row) === 1,
onClick: () => openComplete(row),
},
{
label: '预览本方',
type: 'link',
ifShow: !!row.my_party?.invoice_files?.length,
onClick: () => openPreview(row.my_party.invoice_files),
},
{
label: '查看全部',
type: 'link',
ifShow: row.status === 2 && !!row.invoice_files?.length,
onClick: () => openPreview(row.invoice_files),
},
{
label: '重发邮件',
type: 'link',
ifShow: row.status === 2 && !!row.email,
onClick: () => handleResend(row),
},
];
}
onMounted(() => {
loadStatusStats();
});
@@ -169,102 +270,112 @@ onMounted(() => {
<Page auto-content-height title="开票中心">
<RejectModalComp />
<CompleteModalComp />
<FilePreviewModal v-model:open="previewOpen" :files="previewFiles" title="发票预览" />
<Tabs :active-key="activeTab" class="mb-2" @change="handleTabChange">
<Tabs.TabPane key="0">
<template #tab>
<Badge :count="statusStats['0']" :offset="[10, 0]" :number-style="{ fontSize: '12px' }">
<span>待受理</span>
</Badge>
<FilePreviewModal
v-model:open="previewOpen"
:files="previewFiles"
title="发票预览"
/>
<div class="flex h-full min-h-0 flex-col overflow-hidden">
<!-- Tabs 对列表/卡片都生效,角标统计与状态筛选共用 -->
<Tabs
:active-key="activeTab"
class="mb-2 shrink-0"
@change="handleTabChange"
>
<Tabs.TabPane key="0">
<template #tab>
<Badge
:count="statusStats['0']"
:offset="[10, 0]"
:number-style="{ fontSize: '12px' }"
>
<span>待受理</span>
</Badge>
</template>
</Tabs.TabPane>
<Tabs.TabPane key="1">
<template #tab>
<Badge
:count="statusStats['1']"
:offset="[10, 0]"
:number-style="{ fontSize: '12px' }"
>
<span>开票中</span>
</Badge>
</template>
</Tabs.TabPane>
<Tabs.TabPane key="2">
<template #tab>
<Badge
:count="statusStats['2']"
:offset="[10, 0]"
:number-style="{ fontSize: '12px' }"
>
<span>已完成</span>
</Badge>
</template>
</Tabs.TabPane>
<Tabs.TabPane key="9">
<template #tab>
<Badge
:count="statusStats['9']"
:offset="[10, 0]"
:number-style="{ fontSize: '12px' }"
>
<span>已拒绝</span>
</Badge>
</template>
</Tabs.TabPane>
<Tabs.TabPane key="all" tab="全部" />
</Tabs>
<div
class="mb-3 shrink-0 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card,var(--background)))] p-3"
>
<SearchForm />
</div>
<div class="mb-3 shrink-0">
<ViewModeSwitch v-model="viewMode" />
</div>
<Grid v-if="viewMode === 'list'" class="h-0 min-h-0 flex-1">
<template #toolbar-actions></template>
<template #status="{ row }">
<Tag :color="invoiceStatusTagColor(Number(row.status))">
{{
row.status_text
}}
</Tag>
</template>
</Tabs.TabPane>
<Tabs.TabPane key="1">
<template #tab>
<Badge :count="statusStats['1']" :offset="[10, 0]" :number-style="{ fontSize: '12px' }">
<span>开票中</span>
</Badge>
<template #party_progress="{ row }">
<span>{{ row.party_progress || '-' }}</span>
<Tag v-if="row.my_party" class="ml-1" color="blue">
本方:{{ row.my_party.status_text || '-' }}
</Tag>
</template>
</Tabs.TabPane>
<Tabs.TabPane key="2">
<template #tab>
<Badge :count="statusStats['2']" :offset="[10, 0]" :number-style="{ fontSize: '12px' }">
<span>已完成</span>
</Badge>
<template #action="{ row }">
<Space>
<Button
v-for="action in buildRowActions(row).filter(
(a) => a.ifShow !== false,
)"
:key="action.label"
type="link"
size="small"
:danger="action.danger"
@click="action.onClick?.()"
>
{{ action.label }}
</Button>
</Space>
</template>
</Tabs.TabPane>
<Tabs.TabPane key="9">
<template #tab>
<Badge :count="statusStats['9']" :offset="[10, 0]" :number-style="{ fontSize: '12px' }">
<span>已拒绝</span>
</Badge>
</template>
</Tabs.TabPane>
<Tabs.TabPane key="all" tab="全部" />
</Tabs>
<Grid>
<template #toolbar-actions />
<template #status="{ row }">
<Tag :color="statusColor(row.status)">{{ row.status_text }}</Tag>
</template>
<template #party_progress="{ row }">
<span>{{ row.party_progress || '-' }}</span>
<Tag v-if="row.my_party" class="ml-1" color="blue">
本方:{{ row.my_party.status_text || '-' }}
</Tag>
</template>
<template #action="{ row }">
<Space>
<Button
v-if="myPartyStatus(row) === 0 && row.status !== 9"
type="link"
size="small"
@click="handleAccept(row)"
>
受理本方
</Button>
<Button
v-if="row.status === 0 && myPartyStatus(row) === 0"
type="link"
size="small"
danger
@click="openReject(row)"
>
拒绝
</Button>
<Button
v-if="myPartyStatus(row) === 1"
type="link"
size="small"
@click="openComplete(row)"
>
上传本方发票
</Button>
<Button
v-if="row.my_party?.invoice_files?.length"
type="link"
size="small"
@click="openPreview(row.my_party.invoice_files)"
>
预览本方
</Button>
<Button
v-if="row.status === 2 && row.invoice_files?.length"
type="link"
size="small"
@click="openPreview(row.invoice_files)"
>
查看全部
</Button>
<Button
v-if="row.status === 2 && row.email"
type="link"
size="small"
@click="handleResend(row)"
>
重发邮件
</Button>
</Space>
</template>
</Grid>
</Grid>
<InvoiceCenterCardList
v-else
ref="cardListRef"
class="h-0 min-h-0 flex-1 overflow-auto"
:form-values="searchValues"
:extra-params="cardExtraParams"
:build-row-actions="buildRowActions"
/>
</div>
</Page>
</template>

View File

@@ -0,0 +1,141 @@
<script lang="ts" setup>
/**
* 我的开票卡片视图
* 字段与列表列对齐;拉数/分页交给 GlassCardList;行操作由父级 buildRowActions 注入
*/
import type { ActionItem } from '#/components/table-action';
import { ref } from 'vue';
import { Tag } from 'ant-design-vue';
import { GlassCard, GlassCardList } from '#/components/glass-card';
import { CardActions } from '#/components/table-action';
import { getMyInvoiceListApi } from '../api';
import {
myInvoiceStatusTagColor,
myInvoiceStatusTone,
} from '../config/constants';
defineOptions({ name: 'MyInvoiceCardList' });
const props = defineProps<{
buildRowActions: (row: Record<string, any>) => ActionItem[];
formValues?: Record<string, any>;
}>();
const listRef = ref<null | {
refreshKeepPage: () => Promise<void>;
reload: () => Promise<void>;
}>(null);
function hasVisibleAction(row: Record<string, any>) {
return props.buildRowActions(row).some((action) => action.ifShow !== false);
}
async function reload() {
await listRef.value?.reload();
}
async function refreshKeepPage() {
await listRef.value?.refreshKeepPage();
}
defineExpose({ reload, refreshKeepPage });
</script>
<template>
<GlassCardList
ref="listRef"
:fetcher="getMyInvoiceListApi"
:form-values="props.formValues"
:min-card-width="320"
empty-text="暂无开票待办"
>
<template #card="{ row }">
<GlassCard
hoverable
icon="lucide:file-text"
:tone="myInvoiceStatusTone(Number(row.status))"
>
<template #title>
<span :title="row.party_name">{{ row.party_name || '-' }}</span>
</template>
<template #subtitle>
<span class="my-invoice-card__sub">#{{ row.id }} · {{ row.order_no || '-' }}</span>
</template>
<template #extra>
<Tag
:color="myInvoiceStatusTagColor(Number(row.status))"
class="!mr-0"
>
{{ row.status_text || '-' }}
</Tag>
</template>
<div class="my-invoice-card__amount-row">
<span class="my-invoice-card__amount"><i>¥</i>{{ row.amount ?? '0' }}</span>
</div>
<dl class="my-invoice-card__kv">
<div class="my-invoice-card__kv-item my-invoice-card__kv-item--full">
<dt>抬头</dt>
<dd :title="row.title_name">{{ row.title_name || '-' }}</dd>
</div>
<div class="my-invoice-card__kv-item my-invoice-card__kv-item--full">
<dt>创建时间</dt>
<dd>{{ row.created_at || '-' }}</dd>
</div>
</dl>
<template v-if="hasVisibleAction(row)" #footer>
<CardActions :actions="props.buildRowActions(row)" :max-visible="2" />
</template>
</GlassCard>
</template>
</GlassCardList>
</template>
<style scoped>
.my-invoice-card__sub {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.my-invoice-card__amount-row {
display: flex;
align-items: center;
}
.my-invoice-card__amount {
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 22px;
font-weight: 700;
line-height: 1.3;
color: hsl(var(--primary));
}
.my-invoice-card__amount i {
margin-right: 1px;
font-size: 14px;
font-style: normal;
}
.my-invoice-card__kv {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
margin: 12px 0 0;
}
.my-invoice-card__kv-item dt {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.my-invoice-card__kv-item dd {
margin: 2px 0 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: hsl(var(--foreground));
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,18 @@
/**
* 我的开票常量:列表与卡片共用状态配色
*/
/** 本方 party 状态:0待受理 1待开票 2已开票 */
export function myInvoiceStatusTagColor(status: number): string {
if (status === 0) return 'processing';
if (status === 1) return 'warning';
if (status === 2) return 'success';
return 'default';
}
/** 本方状态 → GlassCard tone */
export function myInvoiceStatusTone(status: number): string {
if (status === 1) return 'warning';
if (status === 2) return 'success';
return 'primary';
}

View File

@@ -8,50 +8,63 @@ interface RowType {
status: number;
}
/** 我的开票表格 */
export const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: { highlight: true, labelField: '' },
columns: [
{ field: 'id', title: 'ID', width: 70 },
{ field: 'order_no', title: '订单号', minWidth: 160 },
{ field: 'title_name', title: '抬头', minWidth: 120 },
{ field: 'party_name', title: '开票方', minWidth: 120 },
{ field: 'amount', title: '本方金额', width: 110 },
{ field: 'status_text', title: '状态', width: 90, slots: { default: 'status' } },
{ field: 'created_at', title: '创建时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 200,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
columnConfig: { useKey: true },
rowConfig: { useKey: true },
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getMyInvoiceListApi({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
/**
* 我的开票表格
* @param getSearchValues 页面顶部统一搜索表单现值(列表/卡片双视图共用)
*/
export function createGridOptions(
getSearchValues: () => Promise<Record<string, any>>,
): VxeGridProps<RowType> {
return {
checkboxConfig: { highlight: true, labelField: '' },
columns: [
{ field: 'id', title: 'ID', width: 70 },
{ field: 'order_no', title: '订单号', minWidth: 160 },
{ field: 'title_name', title: '抬头', minWidth: 120 },
{ field: 'party_name', title: '开票方', minWidth: 120 },
{ field: 'amount', title: '本方金额', width: 110 },
{
field: 'status_text',
title: '状态',
width: 90,
slots: { default: 'status' },
},
{ field: 'created_at', title: '创建时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 200,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
columnConfig: { useKey: true },
rowConfig: { useKey: true },
proxyConfig: {
ajax: {
query: async ({ page }) => {
const formValues = await getSearchValues();
return await getMyInvoiceListApi({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
},
height: 'auto',
border: false,
toolbarConfig: {
// @ts-ignore
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: { buttons: 'toolbar-actions' },
},
showOverflow: false,
};
height: 'auto',
border: false,
toolbarConfig: {
// @ts-expect-error
search: false,
refresh: true,
print: false,
export: false,
zoom: true,
slots: { buttons: 'toolbar-actions' },
},
showOverflow: false,
};
}

View File

@@ -1,26 +1,44 @@
<script lang="ts" setup>
/**
* 门店/供应商/配送仓「我的开票」:仅本方受理与开票
* 顶部统一搜索 + 列表/卡片双视图
*/
import { ref } from 'vue';
import type { ActionItem } from '#/components/table-action';
import { ref, watch } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Modal, Space, Tag, message } from 'ant-design-vue';
import { Button, message, Modal, Space, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import FilePreviewModal from '#/components/file-preview/FilePreviewModal.vue';
import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch';
import { useDualViewSearch } from '#/composables/use-dual-view-search';
import { acceptMyInvoiceApi } from './api';
import CompleteModal from './components/CompleteModal.vue';
import MyInvoiceCardList from './components/MyInvoiceCardList.vue';
import { myInvoiceStatusTagColor } from './config/constants';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
import { createGridOptions } from './config/table';
defineOptions({ name: 'MyInvoiceTodo' });
const [Grid, gridApi] = useVbenVxeGrid({
const viewMode = useViewMode('my-invoice-view-mode');
const cardListRef = ref<InstanceType<typeof MyInvoiceCardList> | null>(null);
const { SearchForm, searchValues, readLiveSearchValues } = useDualViewSearch({
formOptions,
gridOptions,
viewMode,
onListSearch: () => {
gridApi.reload();
},
});
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: createGridOptions(() => readLiveSearchValues()),
});
const [CompleteModalComp, completeModalApi] = useVbenModal({
@@ -30,27 +48,48 @@ const [CompleteModalComp, completeModalApi] = useVbenModal({
const previewOpen = ref(false);
const previewFiles = ref<string[]>([]);
function partyStatusColor(status: number) {
if (status === 0) return 'processing';
if (status === 1) return 'warning';
if (status === 2) return 'success';
return 'default';
function refreshCurrentView() {
if (viewMode.value === 'list') {
gridApi.query();
} else {
cardListRef.value?.refreshKeepPage?.();
}
}
const gridApiProxy = {
reload: () => {
if (viewMode.value === 'list') {
gridApi.reload();
} else {
cardListRef.value?.reload?.();
}
},
query: refreshCurrentView,
};
watch(viewMode, (mode, prev) => {
if (mode === 'list' && prev === 'card') {
gridApi.query();
}
});
function handleAccept(row: any) {
Modal.confirm({
title: '确认受理本方开票?',
content: `开票方 ${row.party_name || ''},金额 ¥${row.amount || '0'}`,
async onOk() {
await acceptMyInvoiceApi({ party_id: row.id, invoice_id: row.invoice_id });
await acceptMyInvoiceApi({
party_id: row.id,
invoice_id: row.invoice_id,
});
message.success('本方已受理');
gridApi.reload();
refreshCurrentView();
},
});
}
function openComplete(row: any) {
completeModalApi.setData({ ...row, gridApi });
completeModalApi.setData({ ...row, gridApi: gridApiProxy });
completeModalApi.open();
}
@@ -62,47 +101,78 @@ function openPreview(files: string[]) {
previewFiles.value = files;
previewOpen.value = true;
}
function buildRowActions(row: Record<string, any>): ActionItem[] {
return [
{
label: '受理',
type: 'link',
ifShow: row.status === 0,
onClick: () => handleAccept(row),
},
{
label: '上传发票',
type: 'link',
ifShow: row.status === 1,
onClick: () => openComplete(row),
},
{
label: '预览',
type: 'link',
ifShow: row.status === 2 && !!row.invoice_files?.length,
onClick: () => openPreview(row.invoice_files),
},
];
}
</script>
<template>
<Page auto-content-height title="我的开票">
<CompleteModalComp />
<FilePreviewModal v-model:open="previewOpen" :files="previewFiles" title="发票预览" />
<Grid>
<template #toolbar-actions />
<template #status="{ row }">
<Tag :color="partyStatusColor(row.status)">
{{ row.status_text }}
</Tag>
</template>
<template #action="{ row }">
<Space>
<Button
v-if="row.status === 0"
type="link"
size="small"
@click="handleAccept(row)"
>
受理
</Button>
<Button
v-if="row.status === 1"
type="link"
size="small"
@click="openComplete(row)"
>
上传发票
</Button>
<Button
v-if="row.status === 2 && row.invoice_files?.length"
type="link"
size="small"
@click="openPreview(row.invoice_files)"
>
预览
</Button>
</Space>
</template>
</Grid>
<FilePreviewModal
v-model:open="previewOpen"
:files="previewFiles"
title="发票预览"
/>
<div class="flex h-full min-h-0 flex-col overflow-hidden">
<div
class="mb-3 shrink-0 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card,var(--background)))] p-3"
>
<SearchForm />
</div>
<div class="mb-3 shrink-0">
<ViewModeSwitch v-model="viewMode" />
</div>
<Grid v-if="viewMode === 'list'" class="h-0 min-h-0 flex-1">
<template #toolbar-actions></template>
<template #status="{ row }">
<Tag :color="myInvoiceStatusTagColor(Number(row.status))">
{{ row.status_text }}
</Tag>
</template>
<template #action="{ row }">
<Space>
<Button
v-for="action in buildRowActions(row).filter(
(a) => a.ifShow !== false,
)"
:key="action.label"
type="link"
size="small"
@click="action.onClick?.()"
>
{{ action.label }}
</Button>
</Space>
</template>
</Grid>
<MyInvoiceCardList
v-else
ref="cardListRef"
class="h-0 min-h-0 flex-1 overflow-auto"
:form-values="searchValues"
:build-row-actions="buildRowActions"
/>
</div>
</Page>
</template>

View File

@@ -1,87 +1,113 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import {
formatDailyBillDate,
formatDailyMergeId,
formatDailyRowId,
formatDailyRowType,
} from '#/views/finance/public-account-pay/config/constants';
import { getPublicAccountDailyBillList } from '../api';
interface RowType {
id: number;
bill_date: number;
status: number;
}
export const dailyGridOptions: VxeGridProps<RowType> = {
columns: [
{ field: 'id', title: 'ID', width: 90, formatter: ({ row }) =>
Number(row.is_period || 0) === 1
? `合#${row.merge_id || '—'}`
: String(row.id || '—'),
},
{
field: 'row_type_txt',
title: '类型',
width: 90,
formatter: ({ row }) =>
Number(row.is_period || 0) === 1 ? '周期账单' : '日账单',
},
{
field: 'merge_id',
title: '合并ID',
width: 110,
formatter: ({ row }) => {
const mid = Number(row.merge_id || 0);
if (mid < 1) {
return '—';
}
if (Number(row.is_period || 0) === 1) {
return String(mid);
}
return row.merge_parent_txt || `周期#${mid}`;
/**
* 到账确认 · 日汇总表格
* @param getSearchValues 页面顶部统一搜索表单的现值
*/
export function createDailyGridOptions(
getSearchValues: () => Promise<Record<string, any>>,
): VxeGridProps<RowType> {
return {
columns: [
{
field: 'id',
title: 'ID',
width: 90,
formatter: ({ row }) => formatDailyRowId(row),
},
{
field: 'row_type_txt',
title: '类型',
width: 90,
formatter: ({ row }) => formatDailyRowType(row),
},
{
field: 'merge_id',
title: '合并ID',
width: 110,
formatter: ({ row }) => formatDailyMergeId(row),
},
{ field: 'store_name', title: '诊所', minWidth: 160 },
{
field: 'bill_date_range_txt',
title: '账单日',
width: 180,
formatter: ({ row }) => formatDailyBillDate(row, false),
},
{ field: 'order_count', title: '订单数', width: 90 },
{ field: 'order_amount', title: '订单合计', width: 110 },
{ field: 'platform_amount', title: '应付款', width: 110 },
{
field: 'status',
title: '状态',
width: 160,
slots: { default: 'status' },
},
{
field: 'vouchers',
title: '付款凭证',
width: 150,
slots: { default: 'vouchers' },
},
{
field: 'arrival_vouchers',
title: '到账凭证',
width: 150,
slots: { default: 'arrival_vouchers' },
},
{ field: 'upload_admin_name', title: '上传人', width: 100 },
{ field: 'confirm_admin_name', title: '确认人', width: 100 },
{ field: 'created_at', title: '生成时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 220,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
columnConfig: { useKey: true },
rowConfig: { useKey: true, keyField: 'row_uid' },
proxyConfig: {
ajax: {
query: async ({ page }) => {
const formValues = await getSearchValues();
return await getPublicAccountDailyBillList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
{ field: 'store_name', title: '诊所', minWidth: 160 },
{
field: 'bill_date_range_txt',
title: '账单日',
width: 180,
formatter: ({ row }) =>
row.bill_date_range_txt || row.bill_date_txt || row.bill_date || '—',
height: 'auto',
border: false,
toolbarConfig: {
// @ts-expect-error
search: false,
refresh: true,
print: false,
export: false,
zoom: true,
slots: { buttons: 'toolbar-actions' },
},
{ field: 'order_count', title: '订单数', width: 90 },
{ field: 'order_amount', title: '订单合计', width: 110 },
{ field: 'platform_amount', title: '应付款', width: 110 },
{ field: 'status', title: '状态', width: 160, slots: { default: 'status' } },
{ field: 'vouchers', title: '付款凭证', width: 150, slots: { default: 'vouchers' } },
{ field: 'arrival_vouchers', title: '到账凭证', width: 150, slots: { default: 'arrival_vouchers' } },
{ field: 'upload_admin_name', title: '上传人', width: 100 },
{ field: 'confirm_admin_name', title: '确认人', width: 100 },
{ field: 'created_at', title: '生成时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 220,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
keyField: 'row_uid',
},
proxyConfig: {
ajax: {
query: async () => ({ items: [], total: 0 }),
},
},
height: 'auto',
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
},
showOverflow: false,
};
showOverflow: false,
};
}

View File

@@ -1,55 +1,80 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getPublicAccountPayList } from '../api';
interface RowType {
id: number;
order_no: string;
status: number;
}
export const gridOptions: VxeGridProps<RowType> = {
columns: [
{ field: 'id', title: 'ID', width: 80 },
{ field: 'store_name', title: '诊所', minWidth: 140 },
{ field: 'order_no', title: '订单号', minWidth: 180 },
{ field: 'order_amount', title: '订单金额', width: 110 },
{ field: 'platform_amount', title: '平台份额', width: 110 },
{ field: 'store_amount', title: '门店份额', width: 110 },
{ field: 'paid_amount', title: '入账金额', width: 110 },
{ field: 'split_mode_txt', title: '分账方式', width: 110 },
{ field: 'status', title: '状态', width: 100, slots: { default: 'status' } },
{ field: 'vouchers', title: '凭证', width: 150, slots: { default: 'vouchers' } },
{ field: 'apply_admin_name', title: '申请人', width: 100 },
{ field: 'confirm_admin_name', title: '确认人', width: 100 },
{ field: 'created_at', title: '申请时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 160,
fixed: 'right',
/**
* 到账确认 · 订单明细表格
* @param getSearchValues 页面顶部统一搜索表单的现值
*/
export function createGridOptions(
getSearchValues: () => Promise<Record<string, any>>,
): VxeGridProps<RowType> {
return {
columns: [
{ field: 'id', title: 'ID', width: 80 },
{ field: 'store_name', title: '诊所', minWidth: 140 },
{ field: 'order_no', title: '订单号', minWidth: 180 },
{ field: 'order_amount', title: '订单金额', width: 110 },
{ field: 'platform_amount', title: '平台份额', width: 110 },
{ field: 'store_amount', title: '门店份额', width: 110 },
{ field: 'paid_amount', title: '入账金额', width: 110 },
{ field: 'split_mode_txt', title: '分账方式', width: 110 },
{
field: 'status',
title: '状态',
width: 100,
slots: { default: 'status' },
},
{
field: 'vouchers',
title: '凭证',
width: 150,
slots: { default: 'vouchers' },
},
{ field: 'apply_admin_name', title: '申请人', width: 100 },
{ field: 'confirm_admin_name', title: '确认人', width: 100 },
{ field: 'created_at', title: '申请时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 160,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
columnConfig: { useKey: true },
rowConfig: { useKey: true },
proxyConfig: {
ajax: {
query: async ({ page }) => {
const formValues = await getSearchValues();
return await getPublicAccountPayList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
],
keepSource: true,
pagerConfig: {},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
},
proxyConfig: {
ajax: {
query: async () => ({ items: [], total: 0 }),
height: 'auto',
border: false,
toolbarConfig: {
// @ts-expect-error
search: false,
refresh: true,
print: false,
export: false,
zoom: true,
slots: { buttons: 'toolbar-actions' },
},
},
height: 'auto',
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
},
showOverflow: false,
};
showOverflow: false,
};
}

View File

@@ -2,20 +2,27 @@
/**
* 财务中心 · 公账到账确认(超管/系统管理员)
* 按天:确认已传凭证的日汇总;按订单:确认待到账单
* 顶部统一搜索 + 列表/卡片双视图;hint 用 GlassCard tone=warning
*/
import { nextTick, onMounted, ref, watch } from 'vue';
import type { ActionItem } from '#/components/table-action';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { Tag, message } from 'ant-design-vue';
import { message, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { GlassCard } from '#/components/glass-card';
import { TableAction } from '#/components/table-action';
import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch';
import { useDualViewSearch } from '#/composables/use-dual-view-search';
import ConfirmModal from '#/views/finance/public-account-pay/components/confirm-modal.vue';
import DailyConfirmModal from '#/views/finance/public-account-pay/components/daily-confirm-modal.vue';
import MergeOrdersModal from '#/views/finance/public-account-pay/components/merge-orders-modal.vue';
import PublicAccountDailyCardList from '#/views/finance/public-account-pay/components/PublicAccountDailyCardList.vue';
import PublicAccountOrderCardList from '#/views/finance/public-account-pay/components/PublicAccountOrderCardList.vue';
import VoucherThumbs from '#/views/finance/public-account-pay/components/voucher-thumbs.vue';
import {
publicAccountDailyStatusMeta,
@@ -24,30 +31,66 @@ import {
import {
getPublicAccountDailyBillInfo,
getPublicAccountDailyBillList,
getPublicAccountPayConfig,
getPublicAccountPayInfo,
getPublicAccountPayList,
} from './api';
import { dailyFormOptions } from './config/daily-search';
import { dailyGridOptions } from './config/daily-table';
import { createDailyGridOptions } from './config/daily-table';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
import { createGridOptions } from './config/table';
defineOptions({ name: 'FinancePublicAccountConfirm' });
const route = useRoute();
const billMode = ref<'order' | 'daily'>('order');
const unlockMode = ref<'voucher' | 'confirm'>('confirm');
const billMode = ref<'daily' | 'order'>('order');
const unlockMode = ref<'confirm' | 'voucher'>('confirm');
const canConfirm = ref(false);
const [Grid, gridApi] = useVbenVxeGrid({
/** 列表/卡片双视图偏好 */
const viewMode = useViewMode('public-account-confirm-view-mode');
const orderCardRef = ref<InstanceType<
typeof PublicAccountOrderCardList
> | null>(null);
const dailyCardRef = ref<InstanceType<
typeof PublicAccountDailyCardList
> | null>(null);
/** 凭证解锁时只展示提示,不拉列表 */
const showVoucherHint = computed(
() => billMode.value === 'daily' && unlockMode.value === 'voucher',
);
const {
SearchForm: OrderSearchForm,
searchValues: orderSearchValues,
readLiveSearchValues: readOrderSearchValues,
} = useDualViewSearch({
formOptions,
gridOptions,
viewMode,
onListSearch: () => {
gridApi.reload();
},
});
const {
SearchForm: DailySearchForm,
searchValues: dailySearchValues,
readLiveSearchValues: readDailySearchValues,
} = useDualViewSearch({
formOptions: dailyFormOptions,
viewMode,
onListSearch: () => {
dailyGridApi.reload();
},
});
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: createGridOptions(() => readOrderSearchValues()),
});
const [DailyGrid, dailyGridApi] = useVbenVxeGrid({
formOptions: dailyFormOptions,
gridOptions: dailyGridOptions,
gridOptions: createDailyGridOptions(() => readDailySearchValues()),
});
const [ConfirmPayModal, confirmPayModalApi] = useVbenModal({
@@ -60,13 +103,38 @@ const [MergeOrdersPayModal, mergeOrdersModalApi] = useVbenModal({
connectedComponent: MergeOrdersModal,
});
function makeGridProxy(kind: 'daily' | 'order') {
return {
reload: () => {
if (viewMode.value === 'list') {
if (kind === 'daily') dailyGridApi.reload();
else gridApi.reload();
} else if (kind === 'daily') {
dailyCardRef.value?.reload?.();
} else {
orderCardRef.value?.reload?.();
}
},
query: () => {
if (viewMode.value === 'list') {
if (kind === 'daily') dailyGridApi.query();
else gridApi.query();
} else if (kind === 'daily') {
dailyCardRef.value?.refreshKeepPage?.();
} else {
orderCardRef.value?.refreshKeepPage?.();
}
},
};
}
/** 按订单确认到账 */
function openConfirm(row: Record<string, any>) {
confirmPayModalApi.setData({
id: row.id,
order_id: row.order_id,
order_no: row.order_no,
gridApi,
gridApi: makeGridProxy('order'),
});
confirmPayModalApi.open();
}
@@ -84,7 +152,7 @@ function openDailyConfirm(row: Record<string, any>) {
platform_amount: row.platform_amount,
bill_date_range_txt: row.bill_date_range_txt || row.bill_date_txt,
store_name: row.store_name,
gridApi: dailyGridApi,
gridApi: makeGridProxy('daily'),
});
dailyConfirmModalApi.open();
}
@@ -125,37 +193,6 @@ function tryOpenFromQuery() {
.catch(() => {});
}
const initTableAjax = () => {
gridApi.setGridOptions({
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPublicAccountPayList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
});
dailyGridApi.setGridOptions({
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPublicAccountDailyBillList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
});
};
initTableAjax();
/** 表格按父级高度重算,避免 auto-content-height 下缩成一行 */
function resizeGrid() {
nextTick(() => {
@@ -166,11 +203,50 @@ function resizeGrid() {
});
}
function buildOrderRowActions(row: Record<string, any>): ActionItem[] {
return [
{
label: '确认到账',
type: 'link',
ifShow: canConfirm.value && row.status === 0,
onClick: () => openConfirm(row),
},
];
}
function buildDailyRowActions(row: Record<string, any>): ActionItem[] {
return [
{
label: '确认到账',
type: 'link',
ifShow: canConfirm.value && row.status === 3,
onClick: () => openDailyConfirm(row),
},
{
label: '查看合并',
type: 'link',
ifShow: Number(row.merge_id || 0) > 0,
onClick: () => openMergeDetail(Number(row.merge_id)),
},
];
}
watch(viewMode, (mode, prev) => {
if (mode === 'list' && prev === 'card' && !showVoucherHint.value) {
if (billMode.value === 'daily') {
dailyGridApi.query();
} else {
gridApi.query();
}
}
});
onMounted(async () => {
const cfg = await getPublicAccountPayConfig().catch(() => null);
billMode.value = cfg?.bill_mode === 'daily' ? 'daily' : 'order';
unlockMode.value = cfg?.unlock_mode === 'voucher' ? 'voucher' : 'confirm';
canConfirm.value = Number(cfg?.can_confirm) === 1 || cfg?.can_confirm === true;
canConfirm.value =
Number(cfg?.can_confirm) === 1 || cfg?.can_confirm === true;
resizeGrid();
tryOpenFromQuery();
});
@@ -187,69 +263,79 @@ watch(
<ConfirmPayModal />
<DailyConfirmPayModal />
<MergeOrdersPayModal />
<div
v-if="billMode === 'daily' && unlockMode === 'voucher'"
class="hint-card"
<!-- 凭证即解锁:无需确认,用 warning 微光提示 -->
<GlassCard
v-if="showVoucherHint"
tone="warning"
icon="lucide:info"
title="无需确认到账"
padding="md"
>
当前解锁条件为「上传凭证即解锁」,无需在本页确认到账。
</div>
<DailyGrid v-else-if="billMode === 'daily'">
<template #status="{ row }">
<Tag :color="publicAccountDailyStatusMeta(row.status).color">
{{
row.status_txt || publicAccountDailyStatusMeta(row.status).label
}}
</Tag>
</template>
<template #vouchers="{ row }">
<VoucherThumbs :items="row.vouchers" />
</template>
<template #arrival_vouchers="{ row }">
<VoucherThumbs :items="row.arrival_vouchers" />
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '确认到账',
type: 'link',
ifShow: canConfirm && row.status === 3,
onClick: openDailyConfirm.bind(null, row),
},
{
label: '查看合并',
type: 'link',
ifShow: Number(row.merge_id || 0) > 0,
onClick: openMergeDetail.bind(null, Number(row.merge_id)),
},
]"
</GlassCard>
<template v-else>
<div
class="mb-3 shrink-0 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card,var(--background)))] p-3"
>
<DailySearchForm v-if="billMode === 'daily'" />
<OrderSearchForm v-else />
</div>
<div class="mb-3 shrink-0">
<ViewModeSwitch v-model="viewMode" />
</div>
<template v-if="billMode === 'daily'">
<DailyGrid v-if="viewMode === 'list'" class="h-0 min-h-0 flex-1">
<template #status="{ row }">
<Tag :color="publicAccountDailyStatusMeta(row.status).color">
{{
row.status_txt ||
publicAccountDailyStatusMeta(row.status).label
}}
</Tag>
</template>
<template #vouchers="{ row }">
<VoucherThumbs :items="row.vouchers" />
</template>
<template #arrival_vouchers="{ row }">
<VoucherThumbs :items="row.arrival_vouchers" />
</template>
<template #action="{ row }">
<TableAction :actions="buildDailyRowActions(row)" />
</template>
</DailyGrid>
<PublicAccountDailyCardList
v-else
ref="dailyCardRef"
class="h-0 min-h-0 flex-1 overflow-auto"
:form-values="dailySearchValues"
:build-row-actions="buildDailyRowActions"
/>
</template>
</DailyGrid>
<Grid v-else>
<template #status="{ row }">
<Tag :color="publicAccountPayStatusMeta(row.status).color">
{{
row.status_txt || publicAccountPayStatusMeta(row.status).label
}}
</Tag>
</template>
<template #vouchers="{ row }">
<VoucherThumbs :items="row.vouchers" />
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '确认到账',
type: 'link',
ifShow: canConfirm && row.status === 0,
onClick: openConfirm.bind(null, row),
},
]"
<template v-else>
<Grid v-if="viewMode === 'list'" class="h-0 min-h-0 flex-1">
<template #status="{ row }">
<Tag :color="publicAccountPayStatusMeta(row.status).color">
{{
row.status_txt || publicAccountPayStatusMeta(row.status).label
}}
</Tag>
</template>
<template #vouchers="{ row }">
<VoucherThumbs :items="row.vouchers" />
</template>
<template #action="{ row }">
<TableAction :actions="buildOrderRowActions(row)" />
</template>
</Grid>
<PublicAccountOrderCardList
v-else
ref="orderCardRef"
class="h-0 min-h-0 flex-1 overflow-auto"
:form-values="orderSearchValues"
:build-row-actions="buildOrderRowActions"
/>
</template>
</Grid>
</template>
</div>
</Page>
</template>
@@ -261,19 +347,13 @@ watch(
height: 100%;
min-height: 0;
}
.pac-page > :deep(.h-full) {
flex: 1;
min-height: 0;
}
.pac-page :deep(.vxe-grid) {
height: 100% !important;
}
.hint-card {
padding: 16px 18px;
color: hsl(var(--muted-foreground));
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--warning) / 30%);
border-radius: 10px;
box-shadow: 0 0 6px hsl(var(--warning) / 18%);
}
</style>

View File

@@ -14,7 +14,9 @@ export async function getPublicAccountPayInfo(id: number) {
/** 预览平台/门店分账金额 */
export async function previewPublicAccountPay(orderId: number) {
return requestClient.get<any>(`${prefix}preview`, { params: { order_id: orderId } });
return requestClient.get<any>(`${prefix}preview`, {
params: { order_id: orderId },
});
}
/** 医生申请公账支付(付款凭证可选) */
@@ -28,7 +30,9 @@ export async function confirmPublicAccountPay(data: Record<string, any>) {
}
/** 待支付订单上直接确认公账到账 */
export async function confirmPublicAccountPayByOrder(data: Record<string, any>) {
export async function confirmPublicAccountPayByOrder(
data: Record<string, any>,
) {
return requestClient.post<any>(`${prefix}confirm-by-order`, data);
}
@@ -43,7 +47,9 @@ export async function getPublicAccountPayConfig() {
}
/** 手动生成日汇总(管理员生成会站内信通知诊所) */
export async function generatePublicAccountDailyBill(data: Record<string, any>) {
export async function generatePublicAccountDailyBill(
data: Record<string, any>,
) {
return requestClient.post<any>(`${prefix}daily-generate`, data);
}
@@ -66,8 +72,8 @@ export async function getPublicAccountDailyMergeDetail(mergeId: number) {
/** 锁店页只读:日账单 + 商品订单 */
export async function getPublicAccountDailyLockOrders(data: {
merge_id?: number;
daily_id?: number;
merge_id?: number;
overdue_daily_ids?: number[] | string;
}) {
return requestClient.get<any>(`${prefix}daily-lock-orders`, {
@@ -100,7 +106,14 @@ export async function cancelPublicAccountDailyBill(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}daily-cancel`, data);
}
/** 超管/系统管理员手动延期(必填理由) */
export async function extendPublicAccountDailyBill(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}daily-extend`, data);
}
/** 日账单变动记录 */
export async function getPublicAccountDailyChangeLog(data: Record<string, any>) {
export async function getPublicAccountDailyChangeLog(
data: Record<string, any>,
) {
return requestClient.get<any>(`${prefix}daily-change-log`, { params: data });
}

View File

@@ -0,0 +1,172 @@
<script lang="ts" setup>
/**
* 公账日汇总卡片视图(账单页 / 到账确认页共用)
* 展示口径与列表 formatter 共用 constants,保证双视图一致
*/
import type { ActionItem } from '#/components/table-action';
import { ref } from 'vue';
import { Tag } from 'ant-design-vue';
import { GlassCard, GlassCardList } from '#/components/glass-card';
import { CardActions } from '#/components/table-action';
import { getPublicAccountDailyBillList } from '../api';
import {
formatDailyBillDate,
formatDailyMergeId,
formatDailyRowId,
formatDailyRowType,
publicAccountDailyStatusMeta,
publicAccountDailyStatusTone,
} from '../config/constants';
import VoucherThumbs from './voucher-thumbs.vue';
defineOptions({ name: 'PublicAccountDailyCardList' });
const props = defineProps<{
buildRowActions: (row: Record<string, any>) => ActionItem[];
fetcher?: (params: Record<string, any>) => Promise<any>;
formValues?: Record<string, any>;
/** 账单页需要展示截止时间,确认页可不带 */
withPeriodEnd?: boolean;
}>();
const listRef = ref<null | {
refreshKeepPage: () => Promise<void>;
reload: () => Promise<void>;
}>(null);
function hasVisibleAction(row: Record<string, any>) {
return props.buildRowActions(row).some((action) => action.ifShow !== false);
}
async function reload() {
await listRef.value?.reload();
}
async function refreshKeepPage() {
await listRef.value?.refreshKeepPage();
}
defineExpose({ reload, refreshKeepPage });
</script>
<template>
<GlassCardList
ref="listRef"
:fetcher="props.fetcher || getPublicAccountDailyBillList"
:form-values="props.formValues"
:min-card-width="360"
row-key="row_uid"
empty-text="暂无日汇总账单"
>
<template #card="{ row }">
<GlassCard
hoverable
icon="lucide:calendar-days"
:tone="publicAccountDailyStatusTone(Number(row.status))"
>
<template #title>
<span :title="row.store_name">{{ row.store_name || '-' }}</span>
</template>
<template #subtitle>
{{ formatDailyRowId(row) }} · {{ formatDailyRowType(row) }}
</template>
<template #extra>
<Tag
:color="publicAccountDailyStatusMeta(row.status).color"
class="!mr-0"
>
{{
row.status_txt || publicAccountDailyStatusMeta(row.status).label
}}
</Tag>
</template>
<div class="pap-daily-card__amount">
<i>¥</i>{{ row.platform_amount ?? '0' }}
</div>
<div class="pap-daily-card__date">
{{ formatDailyBillDate(row, props.withPeriodEnd !== false) }}
</div>
<dl class="pap-daily-card__kv">
<div class="pap-daily-card__kv-item">
<dt>订单数</dt>
<dd>{{ row.order_count ?? 0 }}</dd>
</div>
<div class="pap-daily-card__kv-item">
<dt>订单合计</dt>
<dd>¥{{ row.order_amount ?? '0' }}</dd>
</div>
<div class="pap-daily-card__kv-item">
<dt>合并ID</dt>
<dd>{{ formatDailyMergeId(row) }}</dd>
</div>
<div class="pap-daily-card__kv-item">
<dt>生成时间</dt>
<dd>{{ row.created_at || '-' }}</dd>
</div>
<div class="pap-daily-card__kv-item pap-daily-card__kv-item--full">
<dt>付款凭证</dt>
<dd><VoucherThumbs :items="row.vouchers" /></dd>
</div>
<div class="pap-daily-card__kv-item pap-daily-card__kv-item--full">
<dt>到账凭证</dt>
<dd><VoucherThumbs :items="row.arrival_vouchers" /></dd>
</div>
</dl>
<template v-if="hasVisibleAction(row)" #footer>
<CardActions :actions="props.buildRowActions(row)" :max-visible="3" />
</template>
</GlassCard>
</template>
</GlassCardList>
</template>
<style scoped>
.pap-daily-card__amount {
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 22px;
font-weight: 700;
line-height: 1.3;
color: hsl(var(--primary));
}
.pap-daily-card__amount i {
margin-right: 1px;
font-size: 14px;
font-style: normal;
}
.pap-daily-card__date {
margin-top: 4px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.pap-daily-card__kv {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
margin: 12px 0 0;
}
.pap-daily-card__kv-item--full {
grid-column: 1 / -1;
}
.pap-daily-card__kv-item dt {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.pap-daily-card__kv-item dd {
margin: 2px 0 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: hsl(var(--foreground));
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,151 @@
<script lang="ts" setup>
/**
* 公账订单明细卡片视图(账单页 / 到账确认页共用)
* 字段与列表列对齐;行操作由父级 buildRowActions 注入
*/
import type { ActionItem } from '#/components/table-action';
import { ref } from 'vue';
import { Tag } from 'ant-design-vue';
import { GlassCard, GlassCardList } from '#/components/glass-card';
import { CardActions } from '#/components/table-action';
import { getPublicAccountPayList } from '../api';
import {
publicAccountPayStatusMeta,
publicAccountPayStatusTone,
} from '../config/constants';
import VoucherThumbs from './voucher-thumbs.vue';
defineOptions({ name: 'PublicAccountOrderCardList' });
const props = defineProps<{
buildRowActions: (row: Record<string, any>) => ActionItem[];
/** 列表接口(确认页可换成本端 API) */
fetcher?: (params: Record<string, any>) => Promise<any>;
formValues?: Record<string, any>;
}>();
const listRef = ref<null | {
refreshKeepPage: () => Promise<void>;
reload: () => Promise<void>;
}>(null);
function hasVisibleAction(row: Record<string, any>) {
return props.buildRowActions(row).some((action) => action.ifShow !== false);
}
async function reload() {
await listRef.value?.reload();
}
async function refreshKeepPage() {
await listRef.value?.refreshKeepPage();
}
defineExpose({ reload, refreshKeepPage });
</script>
<template>
<GlassCardList
ref="listRef"
:fetcher="props.fetcher || getPublicAccountPayList"
:form-values="props.formValues"
:min-card-width="340"
empty-text="暂无公账订单账单"
>
<template #card="{ row }">
<GlassCard
hoverable
icon="lucide:landmark"
:tone="publicAccountPayStatusTone(Number(row.status))"
>
<template #title>
<span :title="row.store_name">{{ row.store_name || '-' }}</span>
</template>
<template #subtitle>
#{{ row.id }} · {{ row.order_no || '-' }}
</template>
<template #extra>
<Tag
:color="publicAccountPayStatusMeta(row.status).color"
class="!mr-0"
>
{{ row.status_txt || publicAccountPayStatusMeta(row.status).label }}
</Tag>
</template>
<div class="pap-order-card__amount">
<i>¥</i>{{ row.paid_amount ?? row.platform_amount ?? '0' }}
</div>
<dl class="pap-order-card__kv">
<div class="pap-order-card__kv-item">
<dt>订单金额</dt>
<dd>¥{{ row.order_amount ?? '0' }}</dd>
</div>
<div class="pap-order-card__kv-item">
<dt>平台份额</dt>
<dd>¥{{ row.platform_amount ?? '0' }}</dd>
</div>
<div class="pap-order-card__kv-item">
<dt>分账方式</dt>
<dd>{{ row.split_mode_txt || '-' }}</dd>
</div>
<div class="pap-order-card__kv-item">
<dt>申请时间</dt>
<dd>{{ row.created_at || '-' }}</dd>
</div>
<div class="pap-order-card__kv-item pap-order-card__kv-item--full">
<dt>凭证</dt>
<dd><VoucherThumbs :items="row.vouchers" /></dd>
</div>
</dl>
<template v-if="hasVisibleAction(row)" #footer>
<CardActions :actions="props.buildRowActions(row)" :max-visible="2" />
</template>
</GlassCard>
</template>
</GlassCardList>
</template>
<style scoped>
.pap-order-card__amount {
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 22px;
font-weight: 700;
line-height: 1.3;
color: hsl(var(--primary));
}
.pap-order-card__amount i {
margin-right: 1px;
font-size: 14px;
font-style: normal;
}
.pap-order-card__kv {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
margin: 12px 0 0;
}
.pap-order-card__kv-item--full {
grid-column: 1 / -1;
}
.pap-order-card__kv-item dt {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.pap-order-card__kv-item dd {
margin: 2px 0 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: hsl(var(--foreground));
white-space: nowrap;
}
</style>

View File

@@ -9,6 +9,7 @@ import { useVbenModal } from '@vben/common-ui';
import { Input, message } from 'ant-design-vue';
import UploadDraggerPaste from '#/components/form/components/upload-dragger-paste.vue';
import { GlassCard } from '#/components/glass-card';
import { applyPublicAccountPay } from '../api';
@@ -17,7 +18,7 @@ const TextArea = Input.TextArea;
const orderId = ref(0);
const orderNo = ref('');
const amount = ref('');
const billMode = ref<'order' | 'daily'>('order');
const billMode = ref<'daily' | 'order'>('order');
const voucherUrls = ref<string[]>([]);
const applyRemark = ref('');
@@ -72,7 +73,13 @@ const [Modal, modalApi] = useVbenModal({
<template>
<Modal title="申请公账支付" class="w-[560px]">
<div class="space-y-4 py-2">
<div class="apply-card">
<!-- 订单摘要:GlassCard 边框微光,不用左侧色条 -->
<GlassCard
title="订单信息"
icon="lucide:receipt"
padding="sm"
:specular="false"
>
<div class="apply-row">
<span>订单号</span>
<b>{{ orderNo || '—' }}</b>
@@ -81,7 +88,7 @@ const [Modal, modalApi] = useVbenModal({
<span>订单金额</span>
<b>¥{{ amount || '0.00' }}</b>
</div>
</div>
</GlassCard>
<p class="text-sm text-muted-foreground">
提交后等待管理员确认到账;确认前患者无法再微信/易票联支付。付款凭证可选。
</p>
@@ -109,13 +116,6 @@ const [Modal, modalApi] = useVbenModal({
</template>
<style scoped>
.apply-card {
padding: 12px 14px;
background: hsl(var(--muted) / 0.25);
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 10px;
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.apply-row {
display: flex;
justify-content: space-between;
@@ -123,9 +123,11 @@ const [Modal, modalApi] = useVbenModal({
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.apply-row:last-child {
margin-bottom: 0;
}
.apply-row b {
color: hsl(var(--foreground));
}

View File

@@ -7,9 +7,10 @@ import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Image, Radio, RadioGroup, message } from 'ant-design-vue';
import { Image, message, Radio, RadioGroup } from 'ant-design-vue';
import UploadDraggerPaste from '#/components/form/components/upload-dragger-paste.vue';
import { GlassCard } from '#/components/glass-card';
import {
confirmPublicAccountPay,
@@ -50,7 +51,7 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
async onConfirm() {
if (!voucherUrls.value.length) {
if (voucherUrls.value.length === 0) {
message.warning('请上传到账凭证');
return;
}
@@ -127,8 +128,16 @@ const [Modal, modalApi] = useVbenModal({
<template>
<Modal title="确认公账到账" class="w-[640px]">
<div class="space-y-4 py-2">
<div class="amount-card">
<div v-if="loadingPreview" class="mb-2 text-xs text-muted-foreground">正在预览分账金额…</div>
<!-- 分账金额摘要:GlassCard 统一质感 -->
<GlassCard
title="分账金额"
icon="lucide:banknote"
padding="sm"
:specular="false"
>
<div v-if="loadingPreview" class="mb-2 text-xs text-muted-foreground">
正在预览分账金额…
</div>
<div class="amount-row">
<span>订单号</span>
<b>{{ orderNo || '—' }}</b>
@@ -149,7 +158,7 @@ const [Modal, modalApi] = useVbenModal({
<span>本次入账</span>
<b>¥{{ paidAmount }}</b>
</div>
</div>
</GlassCard>
<div>
<div class="mb-2 font-medium text-foreground">分账方式</div>
<RadioGroup v-model:value="splitMode">
@@ -192,20 +201,13 @@ const [Modal, modalApi] = useVbenModal({
rows="2"
maxlength="200"
placeholder="可选"
/>
></textarea>
</div>
</div>
</Modal>
</template>
<style scoped>
.amount-card {
padding: 12px 14px;
background: hsl(var(--muted) / 0.25);
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 10px;
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.amount-row {
display: flex;
justify-content: space-between;
@@ -213,18 +215,22 @@ const [Modal, modalApi] = useVbenModal({
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.amount-row b {
color: hsl(var(--foreground));
}
.amount-row--total {
margin-top: 8px;
margin-bottom: 0;
font-size: 14px;
color: hsl(var(--foreground));
}
.amount-row--total b {
color: hsl(var(--primary));
}
.confirm-remark {
width: 100%;
padding: 8px 10px;

View File

@@ -11,9 +11,9 @@ import { getPublicAccountDailyChangeLog } from '../api';
defineOptions({ name: 'DailyChangeLogDrawer' });
const props = defineProps<{
open: boolean;
dailyBillId?: number;
mergeId?: number;
open: boolean;
}>();
const emit = defineEmits<{
@@ -40,8 +40,8 @@ async function loadLogs() {
page_size: 50,
});
items.value = Array.isArray(res?.items) ? res.items : [];
} catch (e) {
console.error(e);
} catch (error) {
console.error(error);
items.value = [];
} finally {
loading.value = false;
@@ -52,13 +52,37 @@ function close() {
emit('update:open', false);
}
/** 从快照拼金额摘要 */
/** 格式化快照里的到期时间戳 */
function formatPeriodEnd(ts: unknown): string {
const n = Number(ts || 0);
if (n < 1) {
return '—';
}
const d = new Date(n * 1000);
if (Number.isNaN(d.getTime())) {
return String(ts);
}
const pad = (x: number) => String(x).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** 从快照拼金额/延期摘要;优先展示金额变化,其次到期变化,最后 remark */
function amountLine(row: Record<string, any>) {
const before = row.before_snapshot?.platform_amount;
const after = row.after_snapshot?.platform_amount;
if (before !== undefined && after !== undefined && before !== after) {
return `应付款 ${before} → ${after}`;
}
const beforeEnd = row.before_snapshot?.period_end_at;
const afterEnd = row.after_snapshot?.period_end_at;
if (
beforeEnd !== undefined &&
afterEnd !== undefined &&
Number(beforeEnd) !== Number(afterEnd)
) {
const tip = `到期 ${formatPeriodEnd(beforeEnd)} → ${formatPeriodEnd(afterEnd)}`;
return row.remark ? `${tip};${row.remark}` : tip;
}
return row.remark || '';
}

View File

@@ -0,0 +1,279 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
/**
* 公账日账单手动延期弹窗
* 超管/系统管理员二选一:按天数延后,或直接选新到期日;必填理由写入变动日志
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { useVbenForm } from '#/adapter/form';
import { extendPublicAccountDailyBill } from '../api';
import {
resolveDailyBillDateTxt,
resolveDailyMergeId,
resolveDailyStoreName,
resolveDailyUploadId,
} from '../utils/daily-upload';
const gridApi = ref<any>();
const billMeta = ref({
id: 0,
merge_id: 0,
store_name: '',
bill_date_txt: '',
period_end_at: 0,
period_end_at_txt: '',
lock_hm: '09:00',
});
/** days=按天数;date=选到期日 */
const extendMode = ref<'date' | 'days'>('days');
const previewDays = ref(1);
const previewDate = ref('');
/** 预览新到期文案(两种模式共用) */
const previewEndTxt = computed(() => {
const base = Number(billMeta.value.period_end_at || 0);
const hm = billMeta.value.lock_hm || '09:00';
if (extendMode.value === 'date') {
const d = String(previewDate.value || '').trim();
if (!d) {
return '请选择新的到期日';
}
return `${d} ${hm}`;
}
const days = Math.floor(Number(previewDays.value || 0));
if (base < 1) {
return '原到期时间未知,提交后以服务端计算为准';
}
if (days < 1) {
return billMeta.value.period_end_at_txt || '—';
}
return dayjs.unix(base).add(days, 'day').format('YYYY-MM-DD HH:mm');
});
/** 选日期时禁用「不晚于当前到期日」的日期 */
function disablePastOrSame(current: Dayjs) {
const base = Number(billMeta.value.period_end_at || 0);
if (!current) {
return false;
}
if (base > 0) {
return (
current.endOf('day').unix() <= dayjs.unix(base).startOf('day').unix()
);
}
return current.endOf('day').isBefore(dayjs().startOf('day'));
}
const [Form, formApi] = useVbenForm({
layout: 'vertical',
commonConfig: {
labelWidth: 90,
},
handleValuesChange(values: Record<string, any>) {
const mode = (values?.extend_mode || 'days') as 'date' | 'days';
extendMode.value = mode;
previewDays.value = Math.floor(Number(values?.extend_days || 0));
previewDate.value = String(values?.period_end_date || '');
},
schema: [
{
component: 'RadioGroup',
fieldName: 'extend_mode',
label: '延期方式',
rules: 'selectRequired',
defaultValue: 'days',
componentProps: {
options: [
{ label: '按天数延期', value: 'days' },
{ label: '选择到期日', value: 'date' },
],
},
},
{
component: 'InputNumber',
fieldName: 'extend_days',
label: '延期天数',
defaultValue: 1,
componentProps: {
class: 'w-full',
min: 1,
max: 90,
precision: 0,
placeholder: '1~90',
},
help: '相对当前到期时刻延后,最多 90 天',
dependencies: {
show: (values: Record<string, any>) => values.extend_mode === 'days',
triggerFields: ['extend_mode'],
rules: (values: Record<string, any>) =>
values.extend_mode === 'days' ? 'required' : null,
},
},
{
component: 'DatePicker',
fieldName: 'period_end_date',
label: '新到期日',
componentProps: {
class: 'w-full',
allowClear: false,
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
disabledDate: disablePastOrSame,
},
help: '到期时刻沿用系统锁店时点(如 09:00)',
dependencies: {
show: (values: Record<string, any>) => values.extend_mode === 'date',
triggerFields: ['extend_mode'],
rules: (values: Record<string, any>) =>
values.extend_mode === 'date' ? 'required' : null,
},
},
{
component: 'Textarea',
fieldName: 'reason',
label: '延期理由',
rules: 'required',
componentProps: {
rows: 3,
maxlength: 500,
showCount: true,
placeholder: '必填,将写入变动记录便于审计',
},
},
],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
async onConfirm() {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
const mode = (values.extend_mode || 'days') as 'date' | 'days';
const reason = String(values.reason || '').trim();
if (!reason) {
message.warning('请填写延期理由');
return;
}
const payload: Record<string, any> = {
extend_mode: mode,
reason,
};
if (mode === 'days') {
const days = Math.floor(Number(values.extend_days || 0));
if (days < 1 || days > 90) {
message.warning('延期天数须为 1~90');
return;
}
payload.extend_days = days;
} else {
const date = String(values.period_end_date || '').trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
message.warning('请选择新的到期日');
return;
}
payload.period_end_date = date;
}
if (billMeta.value.merge_id > 0) {
payload.merge_id = billMeta.value.merge_id;
}
if (billMeta.value.id > 0) {
payload.id = billMeta.value.id;
}
modalApi.lock();
modalApi.setState({ confirmLoading: true });
try {
const res = await extendPublicAccountDailyBill(payload);
message.success(
res?.period_end_at_txt
? `已延期,新到期:${res.period_end_at_txt}`
: '已延期',
);
if (gridApi.value?.reload) {
gridApi.value.reload();
} else {
gridApi.value?.query?.();
}
modalApi.close();
} catch (error) {
console.error(error);
} finally {
modalApi.unlock();
modalApi.setState({ confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<Record<string, any>>() || {};
gridApi.value = data.gridApi;
const id = resolveDailyUploadId(data);
const mergeId = resolveDailyMergeId(data);
const periodEndAt = Number(data.period_end_at || 0);
// 优先用系统 lock_time;否则从到期文案里抠 HH:mm
let lockHm = String(data.lock_time || '').trim();
const endTxt = String(data.period_end_at_txt || '');
if (!/^\d{2}:\d{2}$/.test(lockHm)) {
const hmMatch = endTxt.match(/(\d{2}:\d{2})\s*$/);
lockHm = hmMatch ? hmMatch[1] : '09:00';
}
billMeta.value = {
id,
merge_id: mergeId,
store_name: resolveDailyStoreName(data),
bill_date_txt: resolveDailyBillDateTxt(data),
period_end_at: periodEndAt,
period_end_at_txt: String(
endTxt ||
(periodEndAt > 0
? dayjs.unix(periodEndAt).format('YYYY-MM-DD HH:mm')
: ''),
),
lock_hm: lockHm,
};
extendMode.value = 'days';
previewDays.value = 1;
previewDate.value = '';
// 选日期默认:当前到期日的次日
const defaultDate =
periodEndAt > 0
? dayjs.unix(periodEndAt).add(1, 'day').format('YYYY-MM-DD')
: dayjs().add(1, 'day').format('YYYY-MM-DD');
await formApi.resetForm?.();
await formApi.setValues({
extend_mode: 'days',
extend_days: 1,
period_end_date: defaultDate,
reason: '',
});
},
});
</script>
<template>
<Modal title="手动延期" class="w-[520px]">
<div class="extend-meta mb-3 text-sm text-[hsl(var(--muted-foreground))]">
<div v-if="billMeta.store_name">诊所:{{ billMeta.store_name }}</div>
<div v-if="billMeta.bill_date_txt">
账单区间:{{ billMeta.bill_date_txt }}
</div>
<div>当前到期:{{ billMeta.period_end_at_txt || '—' }}</div>
<div class="mt-1 text-[hsl(var(--foreground))]">
延期后到期:{{ previewEndTxt }}
</div>
</div>
<Form />
</Modal>
</template>

View File

@@ -18,6 +18,13 @@ export function publicAccountPayStatusMeta(status: number) {
return { label: '待到账', color: 'orange' };
}
/** 订单账单状态 → GlassCard tone(与 Tag 同色系) */
export function publicAccountPayStatusTone(status: number) {
if (status === 1) return 'success';
if (status === 2) return 'destructive';
return 'warning';
}
export const PUBLIC_ACCOUNT_DAILY_STATUS_OPTIONS = [
{ label: '未付', value: 0 },
{ label: '已确认到账', value: 1 },
@@ -33,3 +40,60 @@ export function publicAccountDailyStatusMeta(status: number) {
if (status === 4) return { label: '已并入账单', color: 'purple' };
return { label: '未付', color: 'orange' };
}
/** 日汇总状态 → GlassCard tone */
export function publicAccountDailyStatusTone(status: number) {
if (status === 1) return 'success';
if (status === 2) return 'destructive';
if (status === 3) return 'primary';
if (status === 4) return 'primary';
return 'warning';
}
/*
* ---- 日汇总行展示格式化(列表列 formatter 与卡片视图共用,保证双视图口径一致) ----
* 周期账单行的 id 常为 0,业务主键在 merge_id,所以 ID/合并ID 需要按 is_period 区分展示
*/
/** 是否周期账单行(多日合并) */
export function isDailyPeriodRow(row: Record<string, any>) {
return Number(row?.is_period || 0) === 1;
}
/** ID 列:周期行显示「合#合并ID」,日账单显示自身 id */
export function formatDailyRowId(row: Record<string, any>) {
return isDailyPeriodRow(row)
? `合#${row.merge_id || '—'}`
: String(row.id || '—');
}
/** 类型列:周期账单 / 日账单 */
export function formatDailyRowType(row: Record<string, any>) {
return isDailyPeriodRow(row) ? '周期账单' : '日账单';
}
/** 合并ID 列:未合并「—」;周期行显示自身合并ID;被并入的日账单显示所属周期 */
export function formatDailyMergeId(row: Record<string, any>) {
const mid = Number(row?.merge_id || 0);
if (mid < 1) {
return '—';
}
if (isDailyPeriodRow(row)) {
return String(mid);
}
return row.merge_parent_txt || `周期#${mid}`;
}
/**
* 账单日列
* @param withPeriodEnd 是否追加「(截止 xxx)」:账单页需要提示到期时间,确认页只看日期范围
*/
export function formatDailyBillDate(
row: Record<string, any>,
withPeriodEnd = false,
) {
const range =
row?.bill_date_range_txt || row?.bill_date_txt || row?.bill_date || '—';
const endTxt = withPeriodEnd ? row?.period_end_at_txt : '';
return endTxt ? `${range}(截止 ${endTxt})` : range;
}

View File

@@ -1,95 +1,113 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getPublicAccountDailyBillList } from '../api';
import {
formatDailyBillDate,
formatDailyMergeId,
formatDailyRowId,
formatDailyRowType,
} from './constants';
interface RowType {
id: number;
bill_date: number;
status: number;
}
export const dailyGridOptions: VxeGridProps<RowType> = {
columns: [
{ type: 'expand', width: 50, slots: { content: 'expand' } },
{ field: 'id', title: 'ID', width: 90, formatter: ({ row }) =>
Number(row.is_period || 0) === 1
? `合#${row.merge_id || '—'}`
: String(row.id || '—'),
},
{
field: 'row_type_txt',
title: '类型',
width: 90,
formatter: ({ row }) =>
Number(row.is_period || 0) === 1 ? '周期账单' : '日账单',
},
{
field: 'merge_id',
title: '合并ID',
width: 100,
formatter: ({ row }) => {
const mid = Number(row.merge_id || 0);
if (mid < 1) {
return '—';
}
if (Number(row.is_period || 0) === 1) {
return String(mid);
}
return row.merge_parent_txt || `周期#${mid}`;
/**
* 公账日汇总表格
* @param getSearchValues 页面顶部统一搜索表单的现值
*/
export function createDailyGridOptions(
getSearchValues: () => Promise<Record<string, any>>,
): VxeGridProps<RowType> {
return {
columns: [
{ type: 'expand', width: 50, slots: { content: 'expand' } },
{
field: 'id',
title: 'ID',
width: 90,
formatter: ({ row }) => formatDailyRowId(row),
},
{
field: 'row_type_txt',
title: '类型',
width: 90,
formatter: ({ row }) => formatDailyRowType(row),
},
{
field: 'merge_id',
title: '合并ID',
width: 100,
formatter: ({ row }) => formatDailyMergeId(row),
},
{ field: 'store_name', title: '诊所', minWidth: 140 },
{
field: 'bill_date_range_txt',
title: '账单日',
width: 220,
formatter: ({ row }) => formatDailyBillDate(row, true),
},
{ field: 'order_count', title: '订单数', width: 90 },
{ field: 'order_amount', title: '订单合计', width: 110 },
{ field: 'platform_amount', title: '应付款', width: 110 },
{
field: 'status',
title: '状态',
width: 160,
slots: { default: 'status' },
},
{
field: 'vouchers',
title: '付款凭证',
width: 150,
slots: { default: 'vouchers' },
},
{
field: 'arrival_vouchers',
title: '到账凭证',
width: 150,
slots: { default: 'arrival_vouchers' },
},
{ field: 'upload_admin_name', title: '上传人', width: 100 },
{ field: 'confirm_admin_name', title: '确认人', width: 100 },
{ field: 'created_at', title: '生成时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 360,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
columnConfig: { useKey: true },
rowConfig: { useKey: true, keyField: 'row_uid' },
proxyConfig: {
ajax: {
query: async ({ page }) => {
const formValues = await getSearchValues();
return await getPublicAccountDailyBillList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
{ field: 'store_name', title: '诊所', minWidth: 140 },
{
field: 'bill_date_range_txt',
title: '账单日',
width: 220,
formatter: ({ row }) => {
const range =
row.bill_date_range_txt || row.bill_date_txt || row.bill_date || '—';
const endTxt = row.period_end_at_txt;
return endTxt ? `${range}(截止 ${endTxt})` : range;
},
height: 'auto',
border: false,
toolbarConfig: {
// @ts-expect-error
search: false,
refresh: true,
print: false,
export: false,
zoom: true,
slots: { buttons: 'toolbar-actions' },
},
{ field: 'order_count', title: '订单数', width: 90 },
{ field: 'order_amount', title: '订单合计', width: 110 },
{ field: 'platform_amount', title: '应付款', width: 110 },
{ field: 'status', title: '状态', width: 160, slots: { default: 'status' } },
{ field: 'vouchers', title: '付款凭证', width: 150, slots: { default: 'vouchers' } },
{ field: 'arrival_vouchers', title: '到账凭证', width: 150, slots: { default: 'arrival_vouchers' } },
{ field: 'upload_admin_name', title: '上传人', width: 100 },
{ field: 'confirm_admin_name', title: '确认人', width: 100 },
{ field: 'created_at', title: '生成时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 280,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
keyField: 'row_uid',
},
proxyConfig: {
ajax: {
query: async () => ({ items: [], total: 0 }),
},
},
height: 'auto',
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: {
buttons: 'toolbar-actions',
},
},
showOverflow: false,
};
showOverflow: false,
};
}

View File

@@ -1,58 +1,80 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getPublicAccountPayList } from '../api';
interface RowType {
id: number;
order_no: string;
status: number;
}
export const gridOptions: VxeGridProps<RowType> = {
columns: [
{ field: 'id', title: 'ID', width: 80 },
{ field: 'store_name', title: '诊所', minWidth: 140 },
{ field: 'order_no', title: '订单号', minWidth: 180 },
{ field: 'order_amount', title: '订单金额', width: 110 },
{ field: 'platform_amount', title: '平台份额', width: 110 },
{ field: 'store_amount', title: '门店份额', width: 110 },
{ field: 'paid_amount', title: '入账金额', width: 110 },
{ field: 'split_mode_txt', title: '分账方式', width: 110 },
{ field: 'status', title: '状态', width: 100, slots: { default: 'status' } },
{ field: 'vouchers', title: '凭证', width: 150, slots: { default: 'vouchers' } },
{ field: 'apply_admin_name', title: '申请人', width: 100 },
{ field: 'confirm_admin_name', title: '确认人', width: 100 },
{ field: 'created_at', title: '申请时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 200,
fixed: 'right',
/**
* 公账订单明细表格
* @param getSearchValues 页面顶部统一搜索表单的现值
*/
export function createGridOptions(
getSearchValues: () => Promise<Record<string, any>>,
): VxeGridProps<RowType> {
return {
columns: [
{ field: 'id', title: 'ID', width: 80 },
{ field: 'store_name', title: '诊所', minWidth: 140 },
{ field: 'order_no', title: '订单号', minWidth: 180 },
{ field: 'order_amount', title: '订单金额', width: 110 },
{ field: 'platform_amount', title: '平台份额', width: 110 },
{ field: 'store_amount', title: '门店份额', width: 110 },
{ field: 'paid_amount', title: '入账金额', width: 110 },
{ field: 'split_mode_txt', title: '分账方式', width: 110 },
{
field: 'status',
title: '状态',
width: 100,
slots: { default: 'status' },
},
{
field: 'vouchers',
title: '凭证',
width: 150,
slots: { default: 'vouchers' },
},
{ field: 'apply_admin_name', title: '申请人', width: 100 },
{ field: 'confirm_admin_name', title: '确认人', width: 100 },
{ field: 'created_at', title: '申请时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 200,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
columnConfig: { useKey: true },
rowConfig: { useKey: true },
proxyConfig: {
ajax: {
query: async ({ page }) => {
const formValues = await getSearchValues();
return await getPublicAccountPayList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
],
keepSource: true,
pagerConfig: {},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
},
proxyConfig: {
ajax: {
query: async () => ({ items: [], total: 0 }),
height: 'auto',
border: false,
toolbarConfig: {
// @ts-expect-error
search: false,
refresh: true,
print: false,
export: false,
zoom: true,
slots: { buttons: 'toolbar-actions' },
},
},
height: 'auto',
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: {
buttons: 'toolbar-actions',
},
},
showOverflow: false,
};
showOverflow: false,
};
}

View File

@@ -2,7 +2,10 @@
/**
* 财务中心 · 公账支付账单
* 按订单:查看/取消;按天:上传凭证、生成日账单。到账确认在「公账到账确认」页
* 支持列表/卡片双视图:日汇总与订单明细各自一套搜索表单抽到顶部共用
*/
import type { ActionItem } from '#/components/table-action';
import { nextTick, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
@@ -11,30 +14,34 @@ import { Page, useVbenModal } from '@vben/common-ui';
import { Image, message, Tabs, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { GlassCard } from '#/components/glass-card';
import { TableAction } from '#/components/table-action';
import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch';
import { useDualViewSearch } from '#/composables/use-dual-view-search';
import {
cancelPublicAccountDailyBill,
cancelPublicAccountPay,
getPublicAccountDailyBillInfo,
getPublicAccountDailyBillList,
getPublicAccountPayConfig,
getPublicAccountPayInfo,
getPublicAccountPayList,
} from './api';
import DailyChangeLogDrawer from './components/daily-change-log-drawer.vue';
import DailyExtendModal from './components/daily-extend-modal.vue';
import DailyUploadModal from './components/daily-upload-modal.vue';
import GenerateDailyModal from './components/generate-daily-modal.vue';
import MergeOrdersModal from './components/merge-orders-modal.vue';
import PublicAccountDailyCardList from './components/PublicAccountDailyCardList.vue';
import PublicAccountOrderCardList from './components/PublicAccountOrderCardList.vue';
import VoucherThumbs from './components/voucher-thumbs.vue';
import {
publicAccountDailyStatusMeta,
publicAccountPayStatusMeta,
} from './config/constants';
import { dailyFormOptions } from './config/daily-search';
import { dailyGridOptions } from './config/daily-table';
import { createDailyGridOptions } from './config/daily-table';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
import { createGridOptions } from './config/table';
import {
buildDailyUploadFormData,
resolveDailyUploadId,
@@ -47,6 +54,7 @@ const TAB_STORAGE_KEY = 'finance_public_account_pay_tab';
const activeTab = ref(localStorage.getItem(TAB_STORAGE_KEY) || 'daily');
const billMode = ref<'daily' | 'order'>('order');
const lockDays = ref(1);
const lockTime = ref('09:00');
const canConfirm = ref(false);
const canUpload = ref(false);
const canGenerate = ref(false);
@@ -56,13 +64,39 @@ const changeLogOpen = ref(false);
const changeLogDailyId = ref(0);
const changeLogMergeId = ref(0);
const [Grid, gridApi] = useVbenVxeGrid({
const viewMode = useViewMode('public-account-pay-view-mode');
const orderCardRef = ref<InstanceType<
typeof PublicAccountOrderCardList
> | null>(null);
const dailyCardRef = ref<InstanceType<
typeof PublicAccountDailyCardList
> | null>(null);
const {
SearchForm: OrderSearchForm,
searchValues: orderSearchValues,
readLiveSearchValues: readOrderSearch,
} = useDualViewSearch({
formOptions,
gridOptions,
viewMode,
onListSearch: () => gridApi.reload(),
});
const {
SearchForm: DailySearchForm,
searchValues: dailySearchValues,
readLiveSearchValues: readDailySearch,
} = useDualViewSearch({
formOptions: dailyFormOptions,
viewMode,
onListSearch: () => dailyGridApi.reload(),
});
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: createGridOptions(() => readOrderSearch()),
});
const [DailyGrid, dailyGridApi] = useVbenVxeGrid({
formOptions: dailyFormOptions,
gridOptions: dailyGridOptions,
gridOptions: createDailyGridOptions(() => readDailySearch()),
});
const [DailyUploadPayModal, dailyUploadModalApi] = useVbenModal({
@@ -74,8 +108,32 @@ const [GenerateDailyPayModal, generateDailyModalApi] = useVbenModal({
const [MergeOrdersPayModal, mergeOrdersModalApi] = useVbenModal({
connectedComponent: MergeOrdersModal,
});
const [DailyExtendPayModal, dailyExtendModalApi] = useVbenModal({
connectedComponent: DailyExtendModal,
});
/** 记住当前 Tab,并让刚显示的表格按父级高度重算,避免切 Tab 后仍缩成一行 */
/** 当前 Tab 下列表/卡片刷新桥(弹窗成功回调用) */
function refreshOrderView(fromPageOne = false) {
if (viewMode.value === 'list') {
fromPageOne ? gridApi.reload() : gridApi.query();
} else {
fromPageOne
? orderCardRef.value?.reload?.()
: orderCardRef.value?.refreshKeepPage?.();
}
}
function refreshDailyView(fromPageOne = false) {
if (viewMode.value === 'list') {
fromPageOne ? dailyGridApi.reload() : dailyGridApi.query();
} else {
fromPageOne
? dailyCardRef.value?.reload?.()
: dailyCardRef.value?.refreshKeepPage?.();
}
}
/** 记住当前 Tab,并让刚显示的表格按父级高度重算 */
function persistTab(key: string) {
activeTab.value = key;
localStorage.setItem(TAB_STORAGE_KEY, key);
@@ -87,7 +145,6 @@ function persistTab(key: string) {
});
}
/** 门店生成本店;管理员选店后生成并站内信通知诊所 */
function openGenerateDaily() {
generateDailyModalApi.setData({
isPlatformAdmin: canConfirm.value,
@@ -95,8 +152,8 @@ function openGenerateDaily() {
gridApi: {
reload: () => {
persistTab('daily');
dailyGridApi.reload();
gridApi.reload();
refreshDailyView(true);
refreshOrderView(true);
},
},
});
@@ -110,11 +167,36 @@ function openDailyUpload(row: Record<string, any>) {
return;
}
dailyUploadModalApi.setData(
buildDailyUploadFormData(row, { gridApi: dailyGridApi }),
buildDailyUploadFormData(row, {
gridApi: {
reload: () => refreshDailyView(true),
query: () => refreshDailyView(false),
},
}),
);
dailyUploadModalApi.open();
}
function openDailyExtend(row: Record<string, any>) {
const uploadId = resolveDailyUploadId(row);
const mergeId = Number(row.merge_id || 0);
if (uploadId < 1 && mergeId < 1) {
message.warning('无法识别日汇总,请刷新后重试');
return;
}
dailyExtendModalApi.setData({
...row,
id: uploadId,
merge_id: mergeId,
lock_time: lockTime.value,
gridApi: {
reload: () => refreshDailyView(true),
query: () => refreshDailyView(false),
},
});
dailyExtendModalApi.open();
}
async function openDetail(id: number) {
detail.value = await getPublicAccountPayInfo(id);
}
@@ -123,16 +205,12 @@ async function openDailyDetail(id: number) {
dailyDetail.value = await getPublicAccountDailyBillInfo(id);
}
/** 查看合并:弹窗展示日账单 + 商品订单 */
function openMergeDetail(mergeId: number) {
if (mergeId < 1) {
return;
}
if (mergeId < 1) return;
mergeOrdersModalApi.setData({ merge_id: mergeId });
mergeOrdersModalApi.open();
}
/** 打开日账单变动记录 */
function openChangeLog(row: Record<string, any>) {
const isPeriod = Number(row.is_period || 0) === 1;
changeLogDailyId.value = isPeriod ? 0 : Number(row.id || 0);
@@ -146,13 +224,13 @@ function openChangeLog(row: Record<string, any>) {
async function handleCancel(row: Record<string, any>) {
await cancelPublicAccountPay({ id: row.id });
message.success('已取消');
gridApi.reload();
refreshOrderView(true);
}
async function handleDailyCancel(row: Record<string, any>) {
await cancelPublicAccountDailyBill({ id: row.id });
message.success('已取消');
dailyGridApi.reload();
refreshDailyView(true);
}
function tryOpenFromQuery() {
@@ -183,41 +261,101 @@ function tryOpenFromQuery() {
.catch(() => {});
}
const initTableAjax = () => {
gridApi.setGridOptions({
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPublicAccountPayList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
/** 日汇总行操作(列表与卡片共用) */
function buildDailyRowActions(row: Record<string, any>): ActionItem[] {
return [
{
label: '上传凭证',
type: 'link',
ifShow:
canUpload.value &&
row.status !== 1 &&
row.status !== 2 &&
(Number(row.is_period || 0) === 1 ||
(Number(row.is_period || 0) !== 1 && Number(row.status) !== 4)),
onClick: () => openDailyUpload(row),
},
{
label: '手动延期',
type: 'link',
ifShow:
!!canConfirm.value &&
Number(row.status) !== 1 &&
Number(row.status) !== 2,
onClick: () => openDailyExtend(row),
},
{
label: '变动记录',
type: 'link',
ifShow: Number(row.id || 0) > 0 || Number(row.merge_id || 0) > 0,
onClick: () => openChangeLog(row),
},
{
label: '查看合并',
type: 'link',
ifShow: Number(row.merge_id || 0) > 0,
onClick: () => openMergeDetail(Number(row.merge_id)),
},
{
label: '详情',
type: 'link',
ifShow: Number(row.is_period || 0) !== 1,
onClick: () => openDailyDetail(row.id),
},
{
label: '取消',
type: 'link',
danger: true,
ifShow:
canConfirm.value &&
Number(row.is_period || 0) !== 1 &&
row.status !== 1 &&
row.status !== 2 &&
row.status !== 4,
popConfirm: {
title: '确认取消该日汇总?子单将退回待出账',
confirm: () => handleDailyCancel(row),
},
},
});
dailyGridApi.setGridOptions({
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPublicAccountDailyBillList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
});
};
];
}
initTableAjax();
/** 订单明细行操作:日模式下仅详情;纯订单模式可取消待到账 */
function buildOrderRowActions(row: Record<string, any>): ActionItem[] {
return [
{
label: '详情',
type: 'link',
onClick: () => openDetail(row.id),
},
{
label: '取消',
type: 'link',
danger: true,
ifShow: billMode.value === 'order' && row.status === 0,
popConfirm: {
title: '确认取消该待到账单?',
confirm: () => handleCancel(row),
},
},
];
}
watch(viewMode, (mode, prev) => {
if (mode === 'list' && prev === 'card') {
if (billMode.value === 'daily' && activeTab.value === 'daily') {
dailyGridApi.query();
} else {
gridApi.query();
}
}
});
onMounted(async () => {
const cfg = await getPublicAccountPayConfig().catch(() => null);
billMode.value = cfg?.bill_mode === 'daily' ? 'daily' : 'order';
lockDays.value = Math.max(1, Number(cfg?.lock_days ?? 1));
lockTime.value = String(cfg?.lock_time || '09:00').trim() || '09:00';
canConfirm.value =
Number(cfg?.can_confirm) === 1 || cfg?.can_confirm === true;
canUpload.value = Number(cfg?.can_upload) === 1 || cfg?.can_upload === true;
@@ -241,221 +379,187 @@ watch(
<DailyUploadPayModal />
<GenerateDailyPayModal />
<MergeOrdersPayModal />
<DailyExtendPayModal />
<DailyChangeLogDrawer
v-model:open="changeLogOpen"
:daily-bill-id="changeLogDailyId"
:merge-id="changeLogMergeId"
/>
<Tabs
v-if="billMode === 'daily'"
class="pap-tabs"
:active-key="activeTab"
@change="persistTab"
<template v-if="billMode === 'daily'">
<Tabs class="pap-tabs" :active-key="activeTab" @change="persistTab">
<Tabs.TabPane key="daily" tab="日汇总">
<DailySearchForm class="mb-3" />
<div class="mb-3 flex items-center justify-between gap-2">
<TableAction
:actions="[
{
label: lockDays > 1 ? '生成周期账单' : '生成日账单',
type: 'primary',
ifShow: canGenerate,
onClick: openGenerateDaily,
},
]"
/>
<ViewModeSwitch v-model="viewMode" />
</div>
<DailyGrid v-if="viewMode === 'list'">
<template #toolbar-actions></template>
<template #status="{ row }">
<Tag :color="publicAccountDailyStatusMeta(row.status).color">
{{
row.status_txt ||
publicAccountDailyStatusMeta(row.status).label
}}
</Tag>
</template>
<template #vouchers="{ row }">
<VoucherThumbs :items="row.vouchers" />
</template>
<template #arrival_vouchers="{ row }">
<VoucherThumbs :items="row.arrival_vouchers" />
</template>
<template #expand="{ row }">
<div class="expand-box">
<template v-if="Number(row.is_period || 0) === 1">
<div
v-for="item in row.items || []"
:key="`m-${item.id}`"
class="expand-row"
>
日账单 #{{ item.id }} ·
{{ item.bill_date_txt || item.bill_date }} · ¥{{
item.platform_amount
}}
· {{ item.status_txt }}
</div>
</template>
<template v-else>
<div
v-for="item in row.items || []"
:key="item.id"
class="expand-row"
:class="{
'text-muted-foreground line-through opacity-70':
item.status === 2,
}"
>
{{ item.order_no }} · ¥{{ item.platform_amount }} ·
{{ item.status_txt }}
</div>
<a
v-if="!(row.items || []).length"
class="text-sm text-primary"
@click="openDailyDetail(row.id)"
>
查看子单
</a>
</template>
</div>
</template>
<template #action="{ row }">
<TableAction :actions="buildDailyRowActions(row)" />
</template>
</DailyGrid>
<PublicAccountDailyCardList
v-else
ref="dailyCardRef"
:form-values="dailySearchValues"
:build-row-actions="buildDailyRowActions"
:with-period-end="true"
/>
</Tabs.TabPane>
<Tabs.TabPane key="order" tab="订单明细">
<OrderSearchForm class="mb-3" />
<div class="mb-3 flex items-center justify-between gap-2">
<TableAction
:actions="[
{
label: lockDays > 1 ? '生成周期账单' : '生成日账单',
type: 'primary',
ifShow: canGenerate,
onClick: openGenerateDaily,
},
]"
/>
<ViewModeSwitch v-model="viewMode" />
</div>
<Grid v-if="viewMode === 'list'">
<template #toolbar-actions></template>
<template #status="{ row }">
<Tag :color="publicAccountPayStatusMeta(row.status).color">
{{
row.status_txt ||
publicAccountPayStatusMeta(row.status).label
}}
</Tag>
</template>
<template #vouchers="{ row }">
<VoucherThumbs :items="row.vouchers" />
</template>
<template #action="{ row }">
<TableAction :actions="buildOrderRowActions(row)" />
</template>
</Grid>
<PublicAccountOrderCardList
v-else
ref="orderCardRef"
:form-values="orderSearchValues"
:build-row-actions="buildOrderRowActions"
/>
</Tabs.TabPane>
</Tabs>
</template>
<template v-else>
<OrderSearchForm class="mb-3" />
<div class="mb-3 flex justify-end">
<ViewModeSwitch v-model="viewMode" />
</div>
<Grid v-if="viewMode === 'list'">
<template #toolbar-actions></template>
<template #status="{ row }">
<Tag :color="publicAccountPayStatusMeta(row.status).color">
{{
row.status_txt || publicAccountPayStatusMeta(row.status).label
}}
</Tag>
</template>
<template #vouchers="{ row }">
<VoucherThumbs :items="row.vouchers" />
</template>
<template #action="{ row }">
<TableAction :actions="buildOrderRowActions(row)" />
</template>
</Grid>
<PublicAccountOrderCardList
v-else
ref="orderCardRef"
:form-values="orderSearchValues"
:build-row-actions="buildOrderRowActions"
/>
</template>
<GlassCard
v-if="dailyDetail"
class="mt-4"
icon="lucide:calendar-days"
padding="md"
:specular="false"
>
<Tabs.TabPane key="daily" tab="日汇总">
<DailyGrid>
<template #toolbar-actions>
<TableAction
:actions="[
{
label: lockDays > 1 ? '生成周期账单' : '生成日账单',
type: 'primary',
ifShow: canGenerate,
onClick: openGenerateDaily,
},
]"
/>
</template>
<template #status="{ row }">
<Tag :color="publicAccountDailyStatusMeta(row.status).color">
{{
row.status_txt ||
publicAccountDailyStatusMeta(row.status).label
}}
</Tag>
</template>
<template #vouchers="{ row }">
<VoucherThumbs :items="row.vouchers" />
</template>
<template #arrival_vouchers="{ row }">
<VoucherThumbs :items="row.arrival_vouchers" />
</template>
<template #expand="{ row }">
<div class="expand-box">
<template v-if="Number(row.is_period || 0) === 1">
<div
v-for="item in row.items || []"
:key="`m-${ item.id}`"
class="expand-row"
>
日账单 #{{ item.id }} ·
{{ item.bill_date_txt || item.bill_date }} · ¥{{
item.platform_amount
}}
· {{ item.status_txt }}
</div>
</template>
<template v-else>
<div
v-for="item in row.items || []"
:key="item.id"
class="expand-row"
:class="{
'text-muted-foreground line-through opacity-70':
item.status === 2,
}"
>
{{ item.order_no }} · ¥{{ item.platform_amount }} ·
{{ item.status_txt }}
</div>
<a
v-if="!(row.items || []).length"
class="text-sm text-primary"
@click="openDailyDetail(row.id)"
>
查看子单
</a>
</template>
</div>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '上传凭证',
type: 'link',
ifShow:
canUpload &&
row.status !== 1 &&
row.status !== 2 &&
(Number(row.is_period || 0) === 1 ||
(Number(row.is_period || 0) !== 1 &&
Number(row.status) !== 4)),
onClick: openDailyUpload.bind(null, row),
},
{
label: '变动记录',
type: 'link',
ifShow:
Number(row.id || 0) > 0 || Number(row.merge_id || 0) > 0,
onClick: openChangeLog.bind(null, row),
},
{
label: '查看合并',
type: 'link',
ifShow: Number(row.merge_id || 0) > 0,
onClick: openMergeDetail.bind(null, Number(row.merge_id)),
},
{
label: '详情',
type: 'link',
ifShow: Number(row.is_period || 0) !== 1,
onClick: openDailyDetail.bind(null, row.id),
},
{
label: '取消',
type: 'link',
danger: true,
ifShow:
canConfirm &&
Number(row.is_period || 0) !== 1 &&
row.status !== 1 &&
row.status !== 2 &&
row.status !== 4,
popConfirm: {
title: '确认取消该日汇总?子单将退回待出账',
confirm: handleDailyCancel.bind(null, row),
},
},
]"
/>
</template>
</DailyGrid>
</Tabs.TabPane>
<Tabs.TabPane key="order" tab="订单明细">
<Grid>
<template #toolbar-actions>
<TableAction
:actions="[
{
label: lockDays > 1 ? '生成周期账单' : '生成日账单',
type: 'primary',
ifShow: canGenerate,
onClick: openGenerateDaily,
},
]"
/>
</template>
<template #status="{ row }">
<Tag :color="publicAccountPayStatusMeta(row.status).color">
{{
row.status_txt || publicAccountPayStatusMeta(row.status).label
}}
</Tag>
</template>
<template #vouchers="{ row }">
<VoucherThumbs :items="row.vouchers" />
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '详情',
type: 'link',
onClick: openDetail.bind(null, row.id),
},
]"
/>
</template>
</Grid>
</Tabs.TabPane>
</Tabs>
<Grid v-else>
<template #status="{ row }">
<Tag :color="publicAccountPayStatusMeta(row.status).color">
{{ row.status_txt || publicAccountPayStatusMeta(row.status).label }}
</Tag>
<template #title>
日汇总 #{{ dailyDetail.id }} ·
{{ dailyDetail.bill_date_range_txt || dailyDetail.bill_date_txt }}
</template>
<template #vouchers="{ row }">
<VoucherThumbs :items="row.vouchers" />
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '详情',
type: 'link',
onClick: openDetail.bind(null, row.id),
},
{
label: '取消',
type: 'link',
danger: true,
ifShow: row.status === 0,
popConfirm: {
title: '确认取消该待到账单?',
confirm: handleCancel.bind(null, row),
},
},
]"
/>
</template>
</Grid>
<div v-if="dailyDetail" class="detail-card mt-4">
<div class="mb-2 flex items-center justify-between">
<b>
日汇总 #{{ dailyDetail.id }} ·
{{ dailyDetail.bill_date_range_txt || dailyDetail.bill_date_txt }}
</b>
<a class="text-sm text-primary" @click="dailyDetail = null">关闭</a>
<a
class="ml-3 text-sm text-primary"
@click="openChangeLog(dailyDetail)"
>
<template #extra>
<a class="text-sm text-primary" @click="openChangeLog(dailyDetail)">
变动记录
</a>
</div>
<div class="text-sm text-[hsl(var(--muted-foreground))]">
<a class="ml-3 text-sm text-primary" @click="dailyDetail = null">
关闭
</a>
</template>
<div class="text-sm text-muted-foreground">
{{ dailyDetail.store_name }} · {{ dailyDetail.status_txt }} · 应付款
¥{{ dailyDetail.platform_amount || '0.00' }} ·
{{ dailyDetail.order_count }} 单
@@ -507,13 +611,21 @@ watch(
/>
</div>
</div>
</div>
<div v-if="detail" class="detail-card mt-4">
<div class="mb-2 flex items-center justify-between">
<b>账单 #{{ detail.id }} · {{ detail.order_no }}</b>
</GlassCard>
<GlassCard
v-if="detail"
class="mt-4"
icon="lucide:landmark"
padding="md"
:specular="false"
>
<template #title>
账单 #{{ detail.id }} · {{ detail.order_no }}
</template>
<template #extra>
<a class="text-sm text-primary" @click="detail = null">关闭</a>
</div>
<div class="text-sm text-[hsl(var(--muted-foreground))]">
</template>
<div class="text-sm text-muted-foreground">
状态 {{ detail.status_txt }} · 分账
{{ detail.split_mode_txt || '未选' }} · 入账 ¥{{
detail.paid_amount || '0.00'
@@ -543,7 +655,7 @@ watch(
/>
</div>
</div>
</div>
</GlassCard>
</div>
</Page>
</template>
@@ -576,7 +688,7 @@ watch(
.pap-tabs :deep(.ant-tabs-content-holder) {
flex: 1;
min-height: 0;
overflow: hidden;
overflow: auto;
}
.pap-tabs :deep(.ant-tabs-content) {
@@ -597,14 +709,6 @@ watch(
height: 100% !important;
}
.detail-card {
padding: 14px 16px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 10px;
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.expand-box {
padding: 8px 12px;
}

View File

@@ -2,8 +2,9 @@ import { requestClient } from '#/api/request';
const prefix = 'visualization-chart/';
/**
* 诊所对账单
* @param data
* 财务可视化看板数据(xk-api GET visualization-chart/curve-chart)
* 返回 line 趋势 / drug 分类占比 / address 地区 / store_rank 门店排行 / kpi 指标 / compare 环比
* @param data { search_time: [开始, 结束] }
*/
export async function getReconciliationChartApi(data: any) {
return requestClient.get<any>(`${prefix}curve-chart`, { params: data });

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
/**
* 财务可视化 · 液态玻璃指标卡
* - 数字从旧值平滑滚动到新值(切换时间范围时更容易感知变化)
* - 底部展示环比:涨用 success、跌用 destructive,上期为 0 时显示「—」
* 为什么不复用 stat-islands:它固定两位小数且没有环比位,订单笔数会显示成 128.00
*/
import { computed, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { TransitionPresets, useTransition } from '@vueuse/core';
import { formatMoney } from '../utils/chart-theme';
const props = withDefaults(
defineProps<{
icon: string;
/** money 金额千分位两位小数;count 整数 */
kind?: 'count' | 'money';
label: string;
/** 上期值,用于悬停提示 */
prevValue?: number;
/** 环比百分比;null 表示上期无数据 */
rate?: null | number;
/** 主题色变量名(不带 --),决定图标与微光颜色 */
tone?: string;
unit?: string;
value: number;
}>(),
{ kind: 'money', rate: null, prevValue: 0, tone: 'primary', unit: '' },
);
const source = ref(0);
const animated = useTransition(source, {
duration: 700,
transition: TransitionPresets.easeOutCubic,
});
watch(
() => props.value,
(val) => {
source.value = Number(val) || 0;
},
{ immediate: true },
);
/** 动画中的数字按类型格式化,金额保留两位、笔数取整 */
const display = computed(() =>
props.kind === 'money'
? formatMoney(animated.value)
: Math.round(animated.value).toLocaleString('zh-CN'),
);
const prevDisplay = computed(() =>
props.kind === 'money'
? `¥${formatMoney(props.prevValue)}`
: `${Number(props.prevValue || 0).toLocaleString('zh-CN')}${props.unit}`,
);
/** 环比方向:up / down / flat / none(上期为 0) */
const trend = computed(() => {
if (props.rate === null || props.rate === undefined) return 'none';
if (props.rate > 0) return 'up';
if (props.rate < 0) return 'down';
return 'flat';
});
const rateText = computed(() =>
trend.value === 'none'
? '—'
: `${Math.abs(props.rate as number).toFixed(1)}%`,
);
</script>
<template>
<article class="kpi-card" :style="{ '--kpi-tone': `var(--${tone})` }">
<div class="kpi-specular"></div>
<div class="kpi-head">
<span class="kpi-label">{{ label }}</span>
<span class="kpi-icon">
<IconifyIcon :icon="icon" />
</span>
</div>
<div class="kpi-value">
<span v-if="kind === 'money'" class="kpi-prefix">¥</span>
<span class="kpi-number">{{ display }}</span>
<span v-if="unit" class="kpi-unit">{{ unit }}</span>
</div>
<div class="kpi-foot" :title="`上期:${prevDisplay}`">
<span class="kpi-rate" :class="`kpi-rate--${trend}`">
<IconifyIcon
v-if="trend === 'up'"
icon="lucide:trending-up"
class="kpi-rate-icon"
/>
<IconifyIcon
v-else-if="trend === 'down'"
icon="lucide:trending-down"
class="kpi-rate-icon"
/>
{{ rateText }}
</span>
<span class="kpi-compare">较上期</span>
</div>
</article>
</template>
<style scoped>
.kpi-card {
position: relative;
padding: 18px 18px 14px;
overflow: hidden;
background: linear-gradient(
145deg,
hsl(var(--card) / 72%),
hsl(var(--card) / 42%)
);
border: 1px solid hsl(var(--kpi-tone) / 22%);
border-radius: 20px;
box-shadow:
inset 0 1px 0 hsl(var(--glass-specular) / 55%),
0 0 6px hsl(var(--kpi-tone) / 12%),
0 10px 30px hsl(var(--kpi-tone) / 8%);
backdrop-filter: blur(24px) saturate(180%);
transition:
transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow 0.35s ease,
border-color 0.35s ease;
}
.kpi-card:hover {
border-color: hsl(var(--kpi-tone) / 40%);
box-shadow:
inset 0 1px 0 hsl(var(--glass-specular) / 70%),
0 0 10px hsl(var(--kpi-tone) / 18%),
0 18px 40px hsl(var(--kpi-tone) / 14%);
transform: translateY(-3px);
}
/* 液态玻璃的高光:左上角斜向反射 + 右上角色彩折射 */
.kpi-specular {
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(
120% 60% at 0% 0%,
hsl(var(--glass-specular) / 28%) 0%,
transparent 55%
),
radial-gradient(
60% 80% at 100% 0%,
hsl(var(--kpi-tone) / 16%) 0%,
transparent 70%
);
}
.kpi-head {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.kpi-label {
font-size: 13px;
color: hsl(var(--muted-foreground));
letter-spacing: 0.02em;
}
.kpi-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
font-size: 18px;
color: hsl(var(--kpi-tone));
background: hsl(var(--kpi-tone) / 12%);
border: 1px solid hsl(var(--kpi-tone) / 20%);
border-radius: 12px;
box-shadow: inset 0 1px 0 hsl(var(--glass-specular) / 40%);
}
.kpi-value {
position: relative;
display: flex;
gap: 3px;
align-items: baseline;
color: hsl(var(--foreground));
}
.kpi-prefix,
.kpi-unit {
font-size: 14px;
font-weight: 500;
color: hsl(var(--muted-foreground));
}
.kpi-number {
font-family:
-apple-system, 'SF Pro Display', 'DIN Alternate', ui-monospace, sans-serif;
font-size: 26px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.15;
letter-spacing: -0.01em;
}
.kpi-foot {
position: relative;
display: flex;
gap: 6px;
align-items: center;
margin-top: 10px;
font-size: 12px;
}
.kpi-rate {
display: inline-flex;
gap: 2px;
align-items: center;
padding: 1px 8px;
font-weight: 600;
border-radius: 999px;
}
.kpi-rate-icon {
font-size: 13px;
}
.kpi-rate--up {
color: hsl(var(--success));
background: hsl(var(--success) / 12%);
}
.kpi-rate--down {
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 12%);
}
.kpi-rate--flat,
.kpi-rate--none {
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 50%);
}
.kpi-compare {
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,218 @@
<script setup lang="ts">
/**
* 财务可视化 · 门店销售排行 Top10
* - 进度条按「占第一名的比例」绘制,直观看出头部门店与长尾差距
* - 同时给出占全部销售额的比例,方便判断头部集中度
* 不用 vxe-grid:这里是固定 10 行的可视化榜单,没有分页/排序/导出需求
*/
import { computed } from 'vue';
import { formatMoney } from '../utils/chart-theme';
export interface StoreRankItem {
store_id: number;
store_name: string;
province_name: string;
money: number;
order_count: number;
avg_order_money: number;
}
const props = defineProps<{
items: StoreRankItem[];
/** 区间总销售额,用于计算单店占比 */
totalMoney: number;
}>();
/** 预先算好进度和占比,模板只做展示 */
const rows = computed(() => {
const top = Number(props.items[0]?.money || 0);
const total = Number(props.totalMoney || 0);
return props.items.map((item, index) => ({
...item,
rank: index + 1,
progress: top > 0 ? Math.max(4, (Number(item.money) / top) * 100) : 0,
share: total > 0 ? ((Number(item.money) / total) * 100).toFixed(1) : '0.0',
}));
});
</script>
<template>
<div class="rank-list">
<div v-if="rows.length === 0" class="rank-empty">暂无门店销售数据</div>
<div v-for="row in rows" :key="row.store_id" class="rank-row">
<span
class="rank-badge"
:class="`rank-badge--${row.rank <= 3 ? row.rank : 'n'}`"
>
{{ row.rank }}
</span>
<div class="rank-main">
<div class="rank-title">
<span class="rank-name" :title="row.store_name">{{
row.store_name
}}</span>
<span v-if="row.province_name" class="rank-province">{{
row.province_name
}}</span>
</div>
<div class="rank-bar">
<div class="rank-bar-fill" :style="{ width: `${row.progress}%` }"></div>
</div>
<div class="rank-meta">
<span>{{ row.order_count }} 笔</span>
<span>客单价 ¥{{ formatMoney(row.avg_order_money) }}</span>
<span>占比 {{ row.share }}%</span>
</div>
</div>
<div class="rank-money">¥{{ formatMoney(row.money) }}</div>
</div>
</div>
</template>
<style scoped>
.rank-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 10px 20px;
}
.rank-empty {
grid-column: 1 / -1;
padding: 40px 0;
font-size: 13px;
color: hsl(var(--muted-foreground));
text-align: center;
}
.rank-row {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 12px;
background: hsl(var(--card) / 35%);
border: 1px solid hsl(var(--border) / 60%);
border-radius: 14px;
transition:
background 0.25s ease,
border-color 0.25s ease;
}
.rank-row:hover {
background: hsl(var(--primary) / 6%);
border-color: hsl(var(--primary) / 30%);
}
.rank-badge {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
font-size: 13px;
font-weight: 700;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 60%);
border-radius: 10px;
}
/* 前三名:金银铜的柔和玻璃徽章 */
.rank-badge--1 {
color: rgb(230 140 0);
background: linear-gradient(
145deg,
rgb(255 214 10 / 35%),
rgb(255 159 10 / 18%)
);
box-shadow: 0 0 6px rgb(255 159 10 / 25%);
}
.rank-badge--2 {
color: hsl(var(--foreground) / 70%);
background: linear-gradient(
145deg,
rgb(203 213 225 / 45%),
rgb(148 163 184 / 18%)
);
}
.rank-badge--3 {
color: rgb(234 110 40);
background: linear-gradient(
145deg,
rgb(251 146 60 / 30%),
rgb(234 88 12 / 15%)
);
}
.rank-main {
flex: 1;
min-width: 0;
}
.rank-title {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 6px;
}
.rank-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
white-space: nowrap;
}
.rank-province {
flex-shrink: 0;
padding: 0 6px;
font-size: 11px;
color: hsl(var(--primary));
background: hsl(var(--primary) / 10%);
border-radius: 6px;
}
.rank-bar {
height: 6px;
overflow: hidden;
background: hsl(var(--muted) / 60%);
border-radius: 999px;
}
.rank-bar-fill {
height: 100%;
background: linear-gradient(
90deg,
hsl(var(--primary) / 55%),
hsl(var(--primary))
);
border-radius: inherit;
transition: width 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rank-meta {
display: flex;
gap: 12px;
margin-top: 5px;
font-size: 11px;
color: hsl(var(--muted-foreground));
}
.rank-money {
flex-shrink: 0;
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: hsl(var(--foreground));
}
@media (max-width: 768px) {
.rank-list {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,21 +1,41 @@
<script setup lang="ts">
import { onActivated, ref } from 'vue';
/**
* 财务数据可视化(液态玻璃风格看板)
* - 顶部:时间范围 + 刷新/重置
* - 指标卡:总销售额 / 订单笔数 / 客单价 / 动销门店,均带环比(对比紧邻的上一个等长区间)
* - 趋势:销售金额(面积)+ 订单笔数(折线)双 Y 轴;≤62 天按天、否则按月(后端决定粒度)
* - 药品分类占比(环形图)、地区销售分布(横向条形)、门店 Top10 榜单
* 数据全部来自 visualization-chart/curve-chart 一个接口,后端已用 SQL 聚合
*/
import type { Dayjs } from 'dayjs';
import type { EchartsUIType } from '@vben/plugins/echarts';
import { computed, nextTick, onActivated, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import {
EchartsUI,
type EchartsUIType,
useEcharts,
} from '@vben/plugins/echarts';
import { IconifyIcon } from '@vben/icons';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { usePreferences } from '@vben/preferences';
import { Button, Card } from 'ant-design-vue';
import type { Dayjs } from 'dayjs';
import { Button, Spin } from 'ant-design-vue';
import SearchTimeRangePicker from '#/components/form/components/search-time-range-picker.vue';
import { monthToTodayRangeDayjs } from '#/utils/search-time-range';
import { getReconciliationChartApi } from '#/views/finance/visualization-chart/api';
// 图表相关
import KpiGlassCard from './components/KpiGlassCard.vue';
import StoreRankPanel from './components/StoreRankPanel.vue';
import {
formatAxisMoney,
formatMoney,
getChartPalette,
GLASS_SERIES_COLORS,
glassTooltip,
} from './utils/chart-theme';
defineOptions({ name: 'VisualizationChart' });
const lineChartRef = ref<EchartsUIType>();
const pieChartRef = ref<EchartsUIType>();
const mapChartRef = ref<EchartsUIType>();
@@ -24,32 +44,159 @@ const { renderEcharts: renderLine } = useEcharts(lineChartRef);
const { renderEcharts: renderPie } = useEcharts(pieChartRef);
const { renderEcharts: renderMap } = useEcharts(mapChartRef);
// 模拟数据(替换为真实数据接口)
const chartData = ref();
const { isDark } = usePreferences();
// 初始化图表配置
const initCharts = () => {
// 折线图
renderLine({
tooltip: {
trigger: 'axis',
formatter: '{b}<br/>{a}: ¥{c}',
const chartData = ref<any>(null);
const loading = ref(false);
/** 指标卡配置:值和环比都从接口 kpi / compare 取,tone 对应主题色变量 */
const kpiCards = computed(() => {
const kpi = chartData.value?.kpi || {};
const compare = chartData.value?.compare || {};
const prev = compare.prev || {};
return [
{
key: 'total_money',
label: '总销售额',
value: Number(kpi.total_money || 0),
prevValue: Number(prev.total_money || 0),
rate: compare.total_money_rate ?? null,
icon: 'lucide:wallet',
kind: 'money' as const,
tone: 'primary',
},
{
key: 'order_count',
label: '订单笔数',
value: Number(kpi.order_count || 0),
prevValue: Number(prev.order_count || 0),
rate: compare.order_count_rate ?? null,
icon: 'lucide:receipt-text',
kind: 'count' as const,
tone: 'success',
unit: '笔',
},
{
key: 'avg_order_money',
label: '客单价',
value: Number(kpi.avg_order_money || 0),
prevValue: Number(prev.avg_order_money || 0),
rate: compare.avg_order_money_rate ?? null,
icon: 'lucide:badge-dollar-sign',
kind: 'money' as const,
tone: 'warning',
},
{
key: 'store_count',
label: '动销门店',
value: Number(kpi.store_count || 0),
prevValue: Number(prev.store_count || 0),
rate: compare.store_count_rate ?? null,
icon: 'lucide:store',
kind: 'count' as const,
tone: 'destructive',
unit: '家',
},
];
});
/** 副标题:当前区间与对比区间,让用户明白环比的口径 */
const rangeText = computed(() => {
const [s, e] = searchTime.value || [];
const prevRange = chartData.value?.compare?.prev_range || [];
const cur =
s && e ? `${s.format('YYYY-MM-DD')} 至 ${e.format('YYYY-MM-DD')}` : '';
const prev =
prevRange.length === 2 ? `,对比 ${prevRange[0]} 至 ${prevRange[1]}` : '';
return `${cur}${prev}`;
});
/** 趋势粒度标签:后端返回 day / month */
const granularityText = computed(() =>
chartData.value?.line?.granularity === 'month' ? '按月' : '按天',
);
/** 区间内日均销售额(按趋势点数平均),趋势卡右上角展示 */
const avgPerBucket = computed(() => {
const list: number[] = chartData.value?.line?.sale_money || [];
if (list.length === 0) return 0;
return Number(chartData.value?.kpi?.total_money || 0) / list.length;
});
/**
* 销售趋势:金额走左轴面积线,笔数走右轴折线
* 双轴是因为两者量级差几个数量级,同轴会把笔数压成一条直线
*/
function renderTrendChart() {
const p = getChartPalette();
const line = chartData.value?.line || {};
const labels: string[] = line.month || [];
renderLine({
color: [p.primary, GLASS_SERIES_COLORS[1] as string],
tooltip: {
...glassTooltip(p),
trigger: 'axis',
axisPointer: {
type: 'line',
lineStyle: { color: p.border, type: 'dashed' },
},
formatter: (params: any) => {
const list = Array.isArray(params) ? params : [params];
const head = `<div style="margin-bottom:6px;font-weight:600">${list[0]?.axisValue ?? ''}</div>`;
const body = list
.map((it: any) => {
const val =
it.seriesIndex === 0
? `¥${formatMoney(it.value)}`
: `${it.value} 笔`;
return `<div style="display:flex;justify-content:space-between;gap:24px">${it.marker}${it.seriesName}<b>${val}</b></div>`;
})
.join('');
return head + body;
},
},
legend: {
top: 0,
right: 0,
icon: 'roundRect',
itemWidth: 12,
itemHeight: 6,
textStyle: { color: p.subText },
},
grid: { left: 8, right: 8, top: 40, bottom: 4, containLabel: true },
xAxis: {
type: 'category',
data: chartData.value.line.month,
axisLabel: { rotate: 45 },
},
yAxis: {
type: 'value',
axisLabel: { formatter: '¥{value}' },
boundaryGap: false,
data: labels,
axisLine: { lineStyle: { color: p.border } },
axisTick: { show: false },
axisLabel: { color: p.subText, hideOverlap: true },
},
yAxis: [
{
type: 'value',
splitLine: { lineStyle: { color: p.splitLine, type: 'dashed' } },
axisLabel: {
color: p.subText,
formatter: (v: number) => `¥${formatAxisMoney(v)}`,
},
},
{
type: 'value',
minInterval: 1,
splitLine: { show: false },
axisLabel: { color: p.subText, formatter: '{value} 笔' },
},
],
series: [
{
name: chartData.value.line.legend[0],
name: '销售金额',
type: 'line',
smooth: true,
data: chartData.value.line.sale_money.map(Number),
smooth: 0.4,
showSymbol: false,
symbolSize: 8,
data: (line.sale_money || []).map(Number),
lineStyle: { width: 3, shadowBlur: 12, shadowColor: p.primarySoft },
areaStyle: {
color: {
type: 'linear',
@@ -58,94 +205,180 @@ const initCharts = () => {
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(58,77,233,0.8)' },
{ offset: 1, color: 'rgba(58,77,233,0.1)' },
{ offset: 0, color: p.primarySoft },
{ offset: 1, color: p.primaryFade },
],
},
},
lineStyle: { width: 3, color: '#3a4de9' },
itemStyle: { color: '#3a4de9' },
emphasis: { focus: 'series' },
},
{
name: '订单笔数',
type: 'line',
yAxisIndex: 1,
smooth: 0.4,
showSymbol: false,
symbolSize: 7,
data: (line.order_count || []).map(Number),
lineStyle: { width: 2, type: [6, 4] },
emphasis: { focus: 'series' },
},
],
});
}
// 饼图
/**
* 药品分类占比:环形图 + 中心总额(用 title 组件实现,插件没有注册 graphic)
*/
function renderDrugChart() {
const p = getChartPalette();
const list: any[] = chartData.value?.drug?.data || [];
const total = list.reduce((sum, it) => sum + Number(it.value || 0), 0);
renderPie({
color: GLASS_SERIES_COLORS,
tooltip: {
...glassTooltip(p),
trigger: 'item',
formatter: '{b}: ¥{c} ({d}%)',
formatter: (it: any) =>
`${it.marker}${it.name}<br/><b>¥${formatMoney(it.value)}</b> ${it.percent}%<br/>${it.data?.orders ?? 0} 笔订单`,
},
title: {
text:
list.length > 0 ? `¥${formatAxisMoney(Math.round(total))}` : '暂无数据',
subtext: list.length > 0 ? '分类合计' : '',
left: '34%',
top: '43%',
textAlign: 'center',
textStyle: { color: p.text, fontSize: 20, fontWeight: 700 },
subtextStyle: { color: p.subText, fontSize: 12 },
itemGap: 4,
},
legend: {
orient: 'vertical',
right: 8,
top: 'middle',
icon: 'circle',
itemWidth: 8,
itemGap: 14,
textStyle: { color: p.subText },
formatter: (name: string) => {
const hit = list.find((it) => it.name === name);
const pct =
total > 0 && hit
? ((Number(hit.value) / total) * 100).toFixed(1)
: '0.0';
return `${name} ${pct}%`;
},
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
data: chartData.value.drug.data.map((item) => ({
name: item.name,
value: Number.parseFloat(item.value),
radius: ['56%', '78%'],
center: ['35%', '50%'],
padAngle: 2,
avoidLabelOverlap: true,
itemStyle: { borderRadius: 8 },
label: { show: false },
emphasis: {
scale: true,
scaleSize: 6,
itemStyle: { shadowBlur: 16, shadowColor: p.primarySoft },
},
data: list.map((it) => ({
name: it.name,
value: Number(it.value),
orders: it.orders,
})),
itemStyle: {
borderRadius: 5,
borderColor: '#fff',
borderWidth: 2,
},
label: {
formatter: '{b|{b}}\n{hr|}\n¥{d|{c}}',
rich: {
b: { fontSize: 14, lineHeight: 20 },
hr: {
borderColor: '#a8a8a8',
width: '100%',
borderWidth: 1,
height: 0,
},
d: { color: '#4c4c4c', fontSize: 12, padding: [5, 0] },
},
},
color: ['#36c6d9', '#659be0'],
},
],
});
}
// 地图(这里使用柱状图替代)
/**
* 地区销售分布:横向条形,后端已按金额倒序;倒置数组让第一名显示在最上方
*/
function renderRegionChart() {
const p = getChartPalette();
const names: string[] = [
...(chartData.value?.address?.address || []),
].reverse();
const money: number[] = [...(chartData.value?.address?.money || [])]
.map(Number)
.reverse();
renderMap({
tooltip: { trigger: 'item', formatter: '¥{c}' },
tooltip: {
...glassTooltip(p),
trigger: 'axis',
axisPointer: { type: 'shadow', shadowStyle: { color: p.primaryFade } },
formatter: (params: any) => {
const it = Array.isArray(params) ? params[0] : params;
return `${it.name}<br/><b>¥${formatMoney(it.value)}</b>`;
},
},
grid: { left: 8, right: 56, top: 8, bottom: 4, containLabel: true },
xAxis: {
type: 'category',
data: chartData.value.address.address,
type: 'value',
splitLine: { lineStyle: { color: p.splitLine, type: 'dashed' } },
axisLabel: {
color: p.subText,
formatter: (v: number) => formatAxisMoney(v),
},
},
yAxis: {
type: 'value',
axisLabel: { formatter: '¥{value}' },
type: 'category',
data: names,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: p.text },
},
series: [
{
type: 'bar',
data: chartData.value.address.money.map(Number),
data: money,
barMaxWidth: 16,
showBackground: true,
backgroundStyle: { color: p.primaryFade, borderRadius: 8 },
itemStyle: {
borderRadius: 8,
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
x2: 1,
y2: 0,
colorStops: [
{ offset: 0, color: '#36c6d9' },
{ offset: 1, color: '#659be0' },
{ offset: 0, color: GLASS_SERIES_COLORS[2] as string },
{ offset: 1, color: p.primary },
],
},
borderRadius: [5, 5, 0, 0],
},
barWidth: '60%',
label: {
show: true,
position: 'right',
color: p.subText,
formatter: (it: any) => formatAxisMoney(Number(it.value)),
},
},
],
});
};
}
// onMounted(() => {
// // initCharts();
// });
/** 三张图一起重绘(数据变化 / 主题切换都走这里) */
function initCharts() {
if (!chartData.value) return;
renderTrendChart();
renderDrugChart();
renderRegionChart();
}
/**
* 主题切换后 CSS 变量已变,要重新读取配色再渲染;
* useEcharts 内部只会用旧 option 重绘,所以这里等它重建实例后再覆盖一次
*/
watch(isDark, () => {
nextTick(() => setTimeout(initCharts, 60));
});
// 时间选择器相关
const searchTime = ref<[Dayjs, Dayjs]>(monthToTodayRangeDayjs());
const searchTimeTouched = ref(false);
@@ -160,13 +393,23 @@ const handleTimeChange = (range?: [Dayjs, Dayjs] | null) => {
getData();
};
const getData = () => {
getReconciliationChartApi({
search_time: searchTime.value,
}).then((res) => {
/** 拉取看板数据;用请求序号丢弃过期响应,避免快速切换时间时旧数据覆盖新数据 */
let requestSeq = 0;
const getData = async () => {
const seq = ++requestSeq;
loading.value = true;
try {
const res = await getReconciliationChartApi({
search_time: searchTime.value,
});
if (seq !== requestSeq) return;
chartData.value = res;
initCharts();
});
} catch (error) {
console.error('获取财务可视化数据失败:', error);
} finally {
if (seq === requestSeq) loading.value = false;
}
};
const reload = () => {
@@ -187,112 +430,351 @@ getData();
<template>
<Page>
<!-- 搜索区域 -->
<div class="search-container">
<SearchTimeRangePicker
v-model:value="searchTime"
format="YYYY-MM-DD"
style="width: 100%; max-width: 420px"
@change="handleTimeChange"
/>
<Button type="link" @click="getData">刷新</Button>
<Button type="link" @click="reload">重置</Button>
</div>
<div class="viz-page">
<!-- 背景氛围光:液态玻璃需要有色彩的底才能折射出层次 -->
<div class="viz-ambient" aria-hidden="true">
<span class="blob blob-a"></span>
<span class="blob blob-b"></span>
<span class="blob blob-c"></span>
</div>
<!-- 图表容器 -->
<div class="charts-grid">
<div class="chart-card">
<!-- 总金额展示 -->
<!-- <div class="total-money">-->
<!-- <span>总销售金额:</span>-->
<!-- </div>-->
<Card title="每日销售趋势">
<template #extra>
<div class="amount">
总销售金额:¥{{ chartData?.total_money || 0 }}
<header class="glass glass-header">
<div class="header-text">
<h2 class="header-title">
<IconifyIcon icon="lucide:chart-spline" class="header-icon" />
销售数据看板
</h2>
<p class="header-sub">{{ rangeText }}</p>
</div>
<div class="header-actions">
<SearchTimeRangePicker
v-model:value="searchTime"
format="YYYY-MM-DD"
class="header-picker"
@change="handleTimeChange"
/>
<Button class="glass-btn" :loading="loading" @click="getData">
<template #icon><IconifyIcon icon="lucide:refresh-cw" /></template>
刷新
</Button>
<Button class="glass-btn" @click="reload">
<template #icon><IconifyIcon icon="lucide:rotate-ccw" /></template>
重置
</Button>
</div>
</header>
<Spin :spinning="loading" :delay="200">
<section class="kpi-grid">
<KpiGlassCard
v-for="card in kpiCards"
:key="card.key"
:label="card.label"
:value="card.value"
:prev-value="card.prevValue"
:rate="card.rate"
:icon="card.icon"
:kind="card.kind"
:tone="card.tone"
:unit="card.unit"
/>
</section>
<section class="glass panel">
<div class="panel-head">
<div>
<div class="panel-title">销售趋势</div>
<div class="panel-sub">
{{ granularityText }}统计 · 金额与订单笔数
</div>
</div>
</template>
<EchartsUI ref="lineChartRef" class="chart" />
</Card>
</div>
</div>
<!-- 图表容器 -->
<div class="charts-grid">
<div class="chart-card">
<Card title="药品销售占比">
<EchartsUI ref="pieChartRef" class="chart" />
</Card>
</div>
<div class="panel-extra">
<span class="extra-label">平均每{{ granularityText === '按月' ? '月' : '日' }}</span>
<span class="extra-value">¥{{ formatMoney(avgPerBucket) }}</span>
</div>
</div>
<EchartsUI ref="lineChartRef" class="chart chart-lg" />
</section>
<div class="chart-card">
<Card title="地区销售分布">
<EchartsUI ref="mapChartRef" class="chart" />
</Card>
</div>
<section class="panel-row">
<div class="glass panel">
<div class="panel-head">
<div>
<div class="panel-title">药品分类占比</div>
<div class="panel-sub">按销售金额</div>
</div>
</div>
<EchartsUI ref="pieChartRef" class="chart" />
</div>
<div class="glass panel">
<div class="panel-head">
<div>
<div class="panel-title">地区销售分布</div>
<div class="panel-sub">按门店所在省份汇总</div>
</div>
</div>
<EchartsUI ref="mapChartRef" class="chart" />
</div>
</section>
<section class="glass panel">
<div class="panel-head">
<div>
<div class="panel-title">门店销售排行</div>
<div class="panel-sub">Top 10 · 进度条为相对第一名的比例</div>
</div>
</div>
<StoreRankPanel
:items="chartData?.store_rank || []"
:total-money="Number(chartData?.kpi?.total_money || 0)"
/>
</section>
</Spin>
</div>
</Page>
</template>
<style scoped>
.search-container {
padding: 16px;
margin-bottom: 16px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
/*
* 液态玻璃变量:--glass-specular 是高光色(HSL 三元组),暗色下降低亮度让高光更含蓄
* 只在本页根节点定义,不污染全局主题变量
*/
.viz-page {
--glass-specular: 0 0% 100%;
.total-money {
padding: 24px;
margin-bottom: 16px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
text-align: center;
}
.total-money h3 {
color: #666;
margin-bottom: 8px;
font-size: 16px;
}
.amount {
color: #3a4de9;
font-weight: 600;
}
.charts-grid {
display: grid;
position: relative;
display: flex;
flex-direction: column;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
isolation: isolate;
}
.chart-card {
padding: 16px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.dark .viz-page {
--glass-specular: 0 0% 62%;
}
.chart-card h4 {
margin: 0 0 12px 0;
color: #333;
.viz-ambient {
position: absolute;
inset: -24px;
z-index: -1;
overflow: hidden;
pointer-events: none;
}
.blob {
position: absolute;
border-radius: 50%;
opacity: 0.55;
filter: blur(80px);
animation: blob-float 18s ease-in-out infinite alternate;
}
.blob-a {
top: -60px;
left: -40px;
width: 420px;
height: 420px;
background: hsl(var(--primary) / 35%);
}
.blob-b {
top: 260px;
right: -80px;
width: 380px;
height: 380px;
background: rgb(94 92 230 / 28%);
animation-delay: -6s;
}
.blob-c {
bottom: -80px;
left: 30%;
width: 460px;
height: 360px;
background: rgb(100 210 255 / 26%);
animation-delay: -12s;
}
@keyframes blob-float {
0% {
transform: translate3d(0, 0, 0) scale(1);
}
100% {
transform: translate3d(40px, 30px, 0) scale(1.12);
}
}
@media (prefers-reduced-motion: reduce) {
.blob {
animation: none;
}
}
/* 通用玻璃面板:半透明底 + 模糊饱和 + 顶部内高光 + 1px 主色微光边框 */
.glass {
position: relative;
background: linear-gradient(
150deg,
hsl(var(--card) / 68%),
hsl(var(--card) / 40%)
);
border: 1px solid hsl(var(--primary) / 16%);
border-radius: 22px;
box-shadow:
inset 0 1px 0 hsl(var(--glass-specular) / 55%),
inset 0 -1px 0 hsl(var(--glass-specular) / 10%),
0 0 6px hsl(var(--primary) / 8%),
0 12px 36px hsl(var(--foreground) / 6%);
backdrop-filter: blur(28px) saturate(180%);
}
.glass-header {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
}
.header-title {
display: flex;
gap: 8px;
align-items: center;
margin: 0;
font-size: 18px;
font-weight: 700;
color: hsl(var(--foreground));
letter-spacing: -0.01em;
}
.header-icon {
font-size: 20px;
color: hsl(var(--primary));
}
.header-sub {
margin: 4px 0 0;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
}
.header-picker {
width: 380px;
max-width: 100%;
}
/* 胶囊玻璃按钮:覆盖 antd 默认实底,保持与面板同一质感 */
.glass-btn {
display: inline-flex;
gap: 4px;
align-items: center;
color: hsl(var(--foreground));
background: hsl(var(--card) / 55%);
border: 1px solid hsl(var(--border));
border-radius: 999px;
box-shadow: inset 0 1px 0 hsl(var(--glass-specular) / 45%);
backdrop-filter: blur(12px);
}
.glass-btn:hover {
color: hsl(var(--primary)) !important;
border-color: hsl(var(--primary) / 40%) !important;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.panel {
padding: 18px 20px 14px;
margin-bottom: 16px;
}
.panel-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 8px;
}
.panel-title {
font-size: 15px;
font-weight: 600;
color: hsl(var(--foreground));
}
.panel-sub {
margin-top: 2px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.panel-extra {
display: flex;
flex-direction: column;
align-items: flex-end;
padding: 6px 12px;
background: hsl(var(--primary) / 8%);
border: 1px solid hsl(var(--primary) / 18%);
border-radius: 12px;
}
.extra-label {
font-size: 11px;
color: hsl(var(--muted-foreground));
}
.extra-value {
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: hsl(var(--primary));
}
.chart {
height: 400px;
height: 340px;
}
.chart-lg {
height: 380px;
}
@media (max-width: 1200px) {
.kpi-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.panel-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.charts-grid {
.kpi-grid {
grid-template-columns: 1fr;
}
.chart {
.chart,
.chart-lg {
height: 300px;
}
.amount {
font-size: 24px;
}
}
</style>

View File

@@ -0,0 +1,83 @@
/**
* 财务可视化 · 图表主题工具
* ECharts 不认识 Tailwind/Vben 的 `--xxx: 212 100% 45%` 变量写法,也不支持空格分隔的 hsl(),
* 所以在渲染时读取 CSS 变量并转换成 `hsla(h, s, l, a)`,保证亮/暗主题切换后坐标轴文字、分割线可读
*/
/** 系列配色:取自 Apple 系统色,亮暗两套底色上都有足够对比度 */
export const GLASS_SERIES_COLORS = [
'#0A84FF',
'#5E5CE6',
'#64D2FF',
'#30D158',
'#FF9F0A',
'#FF375F',
'#BF5AF2',
'#FFD60A',
];
/**
* 读取主题 CSS 变量并转成 ECharts 可用的 hsla 颜色
* @param name 变量名(不带 --),如 primary / muted-foreground
* @param alpha 透明度 0~1
* @param fallback 变量不存在时的兜底色(SSR / 变量未注入)
*/
export function themeColor(name: string, alpha = 1, fallback = '#888') {
if (typeof window === 'undefined') return fallback;
const raw = getComputedStyle(document.documentElement)
.getPropertyValue(`--${name}`)
.trim();
if (!raw) return fallback;
const [h, s, l] = raw.split(/\s+/);
if (!h || !s || !l) return fallback;
return `hsla(${h}, ${s}, ${l}, ${alpha})`;
}
/**
* 一次性取出图表通用的轴/提示框配色,避免每个图表重复读 CSS 变量
*/
export function getChartPalette() {
return {
primary: themeColor('primary', 1, '#0A84FF'),
primarySoft: themeColor('primary', 0.28, 'rgba(10,132,255,0.28)'),
primaryFade: themeColor('primary', 0.02, 'rgba(10,132,255,0.02)'),
text: themeColor('foreground', 0.85, '#333'),
subText: themeColor('muted-foreground', 1, '#888'),
border: themeColor('border', 1, '#e5e5e5'),
splitLine: themeColor('border', 0.6, 'rgba(0,0,0,0.06)'),
tooltipBg: themeColor('card', 0.82, 'rgba(255,255,255,0.82)'),
};
}
/**
* 玻璃风 tooltip:半透明底 + 背景模糊 + 圆角,与页面卡片质感一致
*/
export function glassTooltip(palette: ReturnType<typeof getChartPalette>) {
return {
backgroundColor: palette.tooltipBg,
borderColor: palette.border,
borderWidth: 1,
padding: [10, 14],
textStyle: { color: palette.text, fontSize: 12 },
extraCssText:
'backdrop-filter: blur(16px) saturate(180%); -webkit-backdrop-filter: blur(16px) saturate(180%); border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.12);',
};
}
/** 金额千分位,保留 2 位小数 */
export function formatMoney(val: null | number | string | undefined) {
const n = Number(val ?? 0);
if (!Number.isFinite(n)) return '0.00';
return n.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
/** 坐标轴金额缩写:1.2万 / 3.4亿,避免长数字挤压 Y 轴 */
export function formatAxisMoney(val: number) {
const abs = Math.abs(val);
if (abs >= 1e8) return `${(val / 1e8).toFixed(1)}亿`;
if (abs >= 1e4) return `${(val / 1e4).toFixed(1)}万`;
return String(val);
}

View File

@@ -6,11 +6,12 @@ import { useVbenDrawer } from '@vben/common-ui';
import { Descriptions, Spin, Table } from 'ant-design-vue';
import { getSettlementLedgerDetail } from '#/views/finance/withdrawal/api/settlement';
import { getStoreSettlementLedgerDetailApi } from '#/views/system/store/api';
const loading = ref(false);
const detail = ref<{
items: Record<string, any>[];
ledger_log: Record<string, any> | null;
ledger_log: null | Record<string, any>;
order_id: number;
order_no: string;
order_type: number;
@@ -54,6 +55,8 @@ async function loadDetail() {
merged?: boolean;
order_id?: number;
order_type?: number;
/** 诊所详情传入时走门店主体接口,避免超管登录态查不到该店分账 */
storeId?: number;
}>();
if (!data) {
return;
@@ -61,11 +64,18 @@ async function loadDetail() {
loading.value = true;
try {
const params =
const baseParams =
data.merged && data.order_id && data.order_type
? { order_id: data.order_id, order_type: data.order_type }
: { ledger_log_id: data.ledger_log_id };
const res = await getSettlementLedgerDetail(params);
const storeId = Number(data.storeId || 0);
const res =
storeId > 0
? await getStoreSettlementLedgerDetailApi({
id: storeId,
...baseParams,
})
: await getSettlementLedgerDetail(baseParams);
detail.value = res || detail.value;
} finally {
loading.value = false;
@@ -73,12 +83,17 @@ async function loadDetail() {
}
defineExpose({
open(row: Record<string, any>) {
/**
* @param row 结算记录行
* @param options.storeId 诊所详情场景传入门店 ID
*/
open(row: Record<string, any>, options?: { storeId?: number }) {
drawerApi.setData({
ledger_log_id: row.id,
merged: row.merged,
order_id: row.order_id,
order_type: row.order_type,
storeId: options?.storeId,
});
drawerApi.open();
},

View File

@@ -5,20 +5,24 @@ import { useUserStore } from '@vben/stores';
/**
* 资金变动筛选:门店现金账户无 frozen(未确认收货),筛选项需按角色裁剪
* 须在组件 setup 内调用,保证 Pinia 已就绪
* @param options.forceStore 诊所详情等「固定看门店主体」场景强制按门店裁剪,避免超管态误出 frozen
*/
export function getAccountChangeSearchOptions(): VbenFormProps {
export function getAccountChangeSearchOptions(options?: {
forceStore?: boolean;
}): VbenFormProps {
const userStore = useUserStore();
const isStoreUser =
options?.forceStore === true ||
Number(
(userStore.userInfo as Record<string, any> | null)?.roles?.user_type,
(userStore.userInfo as null | Record<string, any>)?.roles?.user_type,
) === 1;
const fieldNameOptions = [
{ label: '可提现金额', value: 'able_cash' },
{ label: '可提现余额', value: 'balance' },
// 仅供应商/配送仓余额账户有「确认收货前冻结」
...(!isStoreUser
? [{ label: '冻结金额(用户未确认收货)', value: 'frozen' }]
: []),
...(isStoreUser
? []
: [{ label: '冻结金额(用户未确认收货)', value: 'frozen' }]),
{ label: '审核中金额', value: 'frozen_cash' },
{ label: '审核中金额(余额账户)', value: 'withdrawn_frozen' },
];

View File

@@ -0,0 +1,20 @@
import { requestClient } from '#/api/request';
const prefix = 'log/';
/**
* Api 访问历史日志接口(xk-api,读 xk_api_op_log_history)
* 取值:code / result / message
*/
/** 分页查询历史日志 xk-api GET /log/api-history-list */
export async function getApiOpLogHistoryList(data: any) {
return requestClient.get<any>(`${prefix}api-history-list`, { params: data });
}
/** 单条历史详情(含 param/result)xk-api GET /log/api-history-detail */
export async function getApiOpLogHistoryDetail(id: number) {
return requestClient.get<any>(`${prefix}api-history-detail`, {
params: { id },
});
}

View File

@@ -0,0 +1,154 @@
<script lang="ts" setup>
/**
* Api 访问历史日志详情弹窗
* 列表不带大 JSON,打开时按历史表 id 再拉详情;操作人只用 xk_admin / 患者,不兼容 oldadmin
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Descriptions, Spin } from 'ant-design-vue';
import { Icon } from '#/components/icon';
import { getIcon } from '#/util/tool';
import {
getApiOpLogOperatorLabel,
getApiOpLogPlatformLabel,
} from '../../api-log/config/platform';
import { getApiOpLogHistoryDetail } from '../api';
const gridApi = ref();
const data = ref<Record<string, any>>({});
const loading = ref(false);
/** 列表行没有 param/result,打开时按 id 拉详情;解析失败则原样展示 */
function formatJson(raw: unknown) {
if (raw == null || raw === '') return '';
if (typeof raw === 'object') return raw;
try {
return JSON.parse(String(raw));
} catch {
return raw;
}
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
modalApi.close();
},
async onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (!isOpen) return;
const { values } = modalApi.getData<Record<string, any>>();
data.value = values || {};
const id = Number(values?.id || 0);
if (!id) return;
loading.value = true;
try {
data.value = (await getApiOpLogHistoryDetail(id)) || values || {};
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
},
});
</script>
<template>
<Modal class="w-[80%] h-[80%]" title="Api访问历史日志详情">
<Spin :spinning="loading">
<div class="flex flex-col gap-4">
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 2, xs: 2 }"
bordered
>
<Descriptions.Item label="ID">{{ data.id }}</Descriptions.Item>
<Descriptions.Item label="接口名称">
{{ data.url_title || '-' }}
</Descriptions.Item>
<Descriptions.Item label="接口说明">
{{ data.url_description || '-' }}
</Descriptions.Item>
<Descriptions.Item label="是否鉴权">
<span v-if="data.url_need_auth === 1">需要鉴权</span>
<span v-else-if="data.url_need_auth === 0">无需鉴权</span>
<span v-else>-</span>
</Descriptions.Item>
<Descriptions.Item label="操作人员">
<span>{{ getApiOpLogOperatorLabel(data) }}</span>
</Descriptions.Item>
<Descriptions.Item label="URL">{{ data.url }}</Descriptions.Item>
<Descriptions.Item label="请求方式">
<span v-if="data.method === 1">GET</span>
<span v-else-if="data.method === 2">POST</span>
<span v-else>未知</span>
</Descriptions.Item>
<Descriptions.Item label="控制器">
{{ data.controller }}
</Descriptions.Item>
<Descriptions.Item label="IP">
IP地址:{{ data.ip }}
<p>归属地:{{ data.ip_address }}</p>
</Descriptions.Item>
<Descriptions.Item label="类型">
{{ data.type === 0 ? '访问成功' : '访问失败' }}
</Descriptions.Item>
<Descriptions.Item label="返回结果代码">
{{ data.result_code }}
</Descriptions.Item>
<Descriptions.Item label="接口来源">
<span>{{ getApiOpLogPlatformLabel(data.platform_type) }}</span>
</Descriptions.Item>
<Descriptions.Item label="设备">
<Icon :icon="getIcon(data.equipment)" :size="20" />
{{ data.equipment }}
</Descriptions.Item>
<Descriptions.Item label="浏览器">
<Icon :icon="getIcon(data.browser)" :size="20" />
{{ data.browser }}
</Descriptions.Item>
<Descriptions.Item label="所属平台">
{{ data.platform?.name || '未绑定' }}
</Descriptions.Item>
<Descriptions.Item label="访问时间">
{{ data.created_at }}
</Descriptions.Item>
<Descriptions.Item label="归档时间">
{{ data.archived_at }}
</Descriptions.Item>
</Descriptions>
<Descriptions
:column="{ xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 }"
bordered
>
<Descriptions.Item label="参数">
<pre>{{ formatJson(data.param) }}</pre>
</Descriptions.Item>
<Descriptions.Item label="结果">
<pre>{{ formatJson(data.result) }}</pre>
</Descriptions.Item>
</Descriptions>
</div>
</Spin>
</Modal>
</template>
<style scoped>
.ant-descriptions-item-label {
font-weight: bold;
}
pre {
padding: 8px;
overflow-x: auto;
white-space: pre-wrap;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,72 @@
import type { VbenFormProps } from '#/adapter/form';
/**
* 历史日志搜索:端由页面 Tab 控制。
* 抽出 Grid 后必须显式多列栅格,否则默认一行一个字段占满屏。
*/
export const formOptions: VbenFormProps = {
collapsed: false,
commonConfig: {
labelWidth: 80,
componentProps: {
class: 'w-full',
},
},
wrapperClass:
'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
schema: [
{
component: 'VbenInput',
fieldName: 'platform_type',
label: '端',
dependencies: {
show: false,
triggerFields: ['platform_type'],
},
},
{
component: 'ApiLogUserPicker',
fieldName: 'user_filter',
label: '操作用户',
dependencies: {
show: true,
triggerFields: ['user_filter'],
},
},
{
// 接诊式:标题/首拼/全拼搜索,选中回填 url;platformType 由页面 Tab 注入
component: 'ApiLogRoutePicker',
fieldName: 'router',
label: 'api接口',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: 'GET', value: 1 },
{ label: 'POST', value: 2 },
],
placeholder: '全部',
},
fieldName: 'method',
label: '请求方式',
},
{
component: 'RangePicker',
componentProps: {
format: 'YYYY-MM-DD HH:mm:ss',
showTime: true,
valueFormat: 'YYYY-MM-DD HH:mm:ss',
},
fieldName: 'search_time',
label: '时间范围',
},
],
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
submitOnChange: false,
submitOnEnter: true,
};

View File

@@ -0,0 +1,100 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getApiOpLogHistoryList } from '../api';
interface RowType {
id: string;
admin: string;
url: string;
url_title: string;
ip: string;
type: string;
created_at: string;
archived_at: string;
}
export const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: true,
labelField: '',
},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
},
columns: [
{ field: 'id', align: 'left', title: 'ID', width: 100 },
{
field: 'url_title',
title: '接口名称',
minWidth: 120,
slots: { default: 'url_title' },
},
{
field: 'admin',
align: 'left',
title: '操作管理员',
slots: { default: 'admin' },
},
{ field: 'url', title: '访问路由', minWidth: 180 },
{
field: 'method',
title: '请求方式',
slots: { default: 'method' },
width: 90,
},
{ field: 'ip', title: '用户IP地址' },
{ field: 'ip_address', title: 'IP归属地' },
{ field: 'controller', title: '访问控制器' },
{
field: 'equipment',
title: '操作系统',
slots: { default: 'equipment' },
width: 80,
},
{
field: 'browser',
title: '浏览器',
slots: { default: 'browser' },
width: 80,
},
{ field: 'type', title: '操作简述', slots: { default: 'type' } },
{ field: 'created_at', title: '访问时间' },
{ field: 'archived_at', title: '归档时间', width: 170 },
{ type: 'html', title: '操作', slots: { default: 'action' } },
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const { user_filter, ...rest } = formValues || {};
return await getApiOpLogHistoryList({
page: page.currentPage,
pageSize: page.pageSize,
...rest,
user_id: user_filter?.id,
});
},
},
},
height: 'auto',
border: false,
toolbarConfig: {
// @ts-expect-error
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: {
buttons: 'toolbar-actions',
},
custom: {
icon: 'vxe-icon-menu',
},
},
showOverflow: false,
};

Some files were not shown because too many files have changed in this diff Show More