refactor: organize service dto contracts

This commit is contained in:
Yvan
2026-08-21 14:00:19 +08:00
parent e81a641953
commit d91f0554fd
65 changed files with 865 additions and 696 deletions
+8
View File
@@ -25,6 +25,14 @@ export const refundPaymentOrder = (data) => service({
})
export const testPaymentProvider = (provider) => service({
// The payment test route is registered at /payment/... (without the
// frontend /api proxy prefix). When the built UI is opened directly from
// the backend port, bypass axios' default /api baseURL for this request
// only. Vite/Nginx deployments continue to use the normal proxy path.
...(typeof window !== 'undefined' &&
String(window.location.port) === String(import.meta.env.VITE_SERVER_PORT || '8000')
? { baseURL: `${window.location.origin}/` }
: {}),
url: `/payment/providers/${encodeURIComponent(provider)}/test`,
method: 'post'
})
+1
View File
@@ -0,0 +1 @@
.vue-cropper[data-v-a742df44]{position:relative;width:100%;height:100%;box-sizing:border-box;user-select:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;direction:ltr;touch-action:none;text-align:left;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC)}.cropper-box[data-v-a742df44],.cropper-box-canvas[data-v-a742df44],.cropper-drag-box[data-v-a742df44],.cropper-crop-box[data-v-a742df44],.cropper-face[data-v-a742df44]{position:absolute;top:0;right:0;bottom:0;left:0;-webkit-user-select:none;user-select:none}.cropper-box-canvas img[data-v-a742df44]{position:relative;text-align:left;-webkit-user-select:none;user-select:none;transform:none;max-width:none;max-height:none}.cropper-box[data-v-a742df44]{overflow:hidden}.cropper-move[data-v-a742df44]{cursor:move}.cropper-crop[data-v-a742df44]{cursor:crosshair}.cropper-modal[data-v-a742df44]{background:rgba(0,0,0,.5)}.cropper-view-box[data-v-a742df44]{display:block;overflow:hidden;width:100%;height:100%;outline:1px solid #39f;outline-color:#3399ffbf;-webkit-user-select:none;user-select:none}.cropper-view-box img[data-v-a742df44]{-webkit-user-select:none;user-select:none;text-align:left;max-width:none;max-height:none}.cropper-face[data-v-a742df44]{top:0;left:0;background-color:#fff;opacity:.1}.crop-info[data-v-a742df44]{position:absolute;left:0;min-width:65px;text-align:center;color:#fff;line-height:20px;background-color:#000c;font-size:12px}.crop-line[data-v-a742df44]{position:absolute;display:block;width:100%;height:100%;opacity:.1}.line-w[data-v-a742df44]{top:-3px;left:0;height:5px;cursor:n-resize}.line-a[data-v-a742df44]{top:0;left:-3px;width:5px;cursor:w-resize}.line-s[data-v-a742df44]{bottom:-3px;left:0;height:5px;cursor:s-resize}.line-d[data-v-a742df44]{top:0;right:-3px;width:5px;cursor:e-resize}.crop-point[data-v-a742df44]{position:absolute;width:8px;height:8px;opacity:.75;background-color:#39f;border-radius:100%}.point1[data-v-a742df44]{top:-4px;left:-4px;cursor:nw-resize}.point2[data-v-a742df44]{top:-5px;left:50%;margin-left:-3px;cursor:n-resize}.point3[data-v-a742df44]{top:-4px;right:-4px;cursor:ne-resize}.point4[data-v-a742df44]{top:50%;left:-4px;margin-top:-3px;cursor:w-resize}.point5[data-v-a742df44]{top:50%;right:-4px;margin-top:-3px;cursor:e-resize}.point6[data-v-a742df44]{bottom:-5px;left:-4px;cursor:sw-resize}.point7[data-v-a742df44]{bottom:-5px;left:50%;margin-left:-3px;cursor:s-resize}.point8[data-v-a742df44]{bottom:-5px;right:-4px;cursor:se-resize}@media screen and (max-width: 500px){.crop-point[data-v-a742df44]{position:absolute;width:20px;height:20px;opacity:.45;background-color:#39f;border-radius:100%}.point1[data-v-a742df44]{top:-10px;left:-10px}.point2[data-v-a742df44],.point4[data-v-a742df44],.point5[data-v-a742df44],.point7[data-v-a742df44]{display:none}.point3[data-v-a742df44]{top:-10px;right:-10px}.point4[data-v-a742df44]{top:0;left:0}.point6[data-v-a742df44]{bottom:-10px;left:-10px}.point8[data-v-a742df44]{bottom:-10px;right:-10px}}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -18,7 +18,7 @@
</template>
<script setup>
import '@wangeditor/editor/dist/css/style.css' // 引入 css
import '@/assets/styles/wangeditor.css'
const basePath = import.meta.env.VITE_BASE_API
+1 -1
View File
@@ -11,7 +11,7 @@
</div>
</template>
<script setup>
import '@wangeditor/editor/dist/css/style.css' // 引入 css
import '@/assets/styles/wangeditor.css'
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { Editor } from '@wangeditor/editor-for-vue'
+1 -1
View File
@@ -85,7 +85,7 @@
import { ref, getCurrentInstance } from 'vue'
import { ElMessage } from 'element-plus'
import { RefreshLeft, RefreshRight, Plus, Minus } from '@element-plus/icons-vue'
import 'vue-cropper/dist/index.css'
import '@/assets/styles/vue-cropper.css'
import { VueCropper } from 'vue-cropper'
import { getBaseUrl } from '@/utils/format'
import { getUploadErrorMessage } from '@/utils/uploadResponse'
+1 -1
View File
@@ -80,7 +80,7 @@
import { ref, getCurrentInstance, onMounted } from 'vue'
import { ElLoading, ElMessage } from 'element-plus'
import { RefreshLeft, RefreshRight, Plus, Minus } from '@element-plus/icons-vue'
import 'vue-cropper/dist/index.css'
import '@/assets/styles/vue-cropper.css'
import { VueCropper } from 'vue-cropper'
import { getBaseUrl } from '@/utils/format'
import { getUploadErrorMessage } from '@/utils/uploadResponse'
+311 -2
View File
@@ -137,6 +137,10 @@
<el-switch v-model="wrapLines" aria-label="自动换行" />
自动换行
</label>
<div class="log-view-mode" role="group" aria-label="日志显示模式">
<button type="button" :class="{ active: viewMode === 'structured' }" @click="viewMode = 'structured'">结构化</button>
<button type="button" :class="{ active: viewMode === 'raw' }" @click="viewMode = 'raw'">原始</button>
</div>
</div>
<div class="flex items-center gap-1">
<span class="mr-2 hidden rounded-full bg-muted px-2.5 py-1 text-[11px] text-muted-foreground sm:inline">{{ contentMeta }}</span>
@@ -187,7 +191,41 @@
class="h-full overflow-auto font-mono text-xs leading-6"
:class="hasMore ? 'pt-12' : 'pt-2'"
>
<div v-if="displayLines.length" :class="wrapLines ? 'min-w-0' : 'min-w-max'">
<div v-if="displayLines.length && viewMode === 'structured'" class="log-structured-list">
<article
v-for="line in displayLines"
:key="line.sourceIndex"
:data-log-line-index="line.sourceIndex"
class="log-entry"
:class="logEntryTone(line.json)"
>
<div v-if="line.json" class="log-entry-main">
<div class="log-entry-heading">
<span class="log-line-number">#{{ line.number }}</span>
<span class="log-level" :class="`level-${String(line.json.level || 'info').toLowerCase()}`">{{ String(line.json.level || 'info').toUpperCase() }}</span>
<time v-if="line.json.ts" class="log-time">{{ formatLogTimestamp(line.json.ts) }}</time>
<span v-if="line.json.mod" class="log-module">{{ line.json.mod }}</span>
<span v-if="line.json.caller" class="log-caller">{{ line.json.caller }}</span>
</div>
<div class="log-message">{{ line.json.msg || '未命名日志' }}</div>
<div v-if="isSqlLog(line.json)" class="log-sql">{{ line.json.sql }}</div>
<div v-if="isHttpLog(line.json)" class="log-metrics">
<span v-if="line.json.http_method || line.json.http_path"><b>{{ line.json.http_method || '-' }}</b> {{ line.json.http_path || '-' }}</span>
<span v-if="line.json.http_status != null" :class="httpStatusTone(line.json.http_status)">HTTP {{ line.json.http_status }}</span>
<span v-if="line.json.latency_ms != null">耗时 {{ line.json.latency_ms }} ms</span>
<span v-if="line.json.bytes_out != null">响应 {{ formatFileSize(Number(line.json.bytes_out)) }}</span>
</div>
<div v-if="structuredFields(line).length" class="log-fields">
<span v-for="field in structuredFields(line)" :key="field.key" class="log-field">
<b>{{ field.label }}</b><span :title="field.value">{{ field.value }}</span>
</span>
</div>
</div>
<pre v-else class="log-fallback" :class="wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'">{{ line.text }}</pre>
</article>
</div>
<div v-else-if="displayLines.length" :class="wrapLines ? 'min-w-0' : 'min-w-max'">
<div
v-for="line in displayLines"
:key="line.sourceIndex"
@@ -248,6 +286,7 @@ const limitedByBytes = ref(false)
const windowLimited = ref(false)
const searchText = ref('')
const wrapLines = ref(false)
const viewMode = ref('structured')
const loadingDates = ref(false)
const loadingFiles = ref(false)
const loadingContent = ref(false)
@@ -292,7 +331,12 @@ const allLines = computed(() => {
if (!content.value) return []
const lines = content.value.split('\n')
if (lines[lines.length - 1] === '') lines.pop()
return lines.map((text, index) => ({ number: index + 1, sourceIndex: index, text }))
return lines.map((text, index) => ({
number: index + 1,
sourceIndex: index,
text,
json: parseLogLine(text)
}))
})
const displayLines = computed(() => {
const keyword = searchText.value.trim().toLowerCase()
@@ -340,6 +384,96 @@ function lineTone(text) {
return 'text-container'
}
function parseLogLine(text) {
const value = text.trim()
if (!value.startsWith('{') || !value.endsWith('}')) return null
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}
function logEntryTone(log) {
const level = log?.level
if (/^(error|dpanic|panic|fatal)$/i.test(level || '')) return 'entry-error'
if (/^warn$/i.test(level || '')) return 'entry-warn'
if (/^debug$/i.test(level || '')) return 'entry-debug'
return 'entry-info'
}
function formatLogTimestamp(value) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
hour12: false
}).format(date)
}
function isSqlLog(log) {
return Boolean(log?.sql)
}
function isHttpLog(log) {
return Boolean(log && (log.mod === 'http' || log.http_method || log.http_status != null))
}
function httpStatusTone(status) {
const value = Number(status)
if (value >= 500) return 'text-error'
if (value >= 400) return 'text-warning'
if (value >= 300) return 'text-info'
return 'text-success'
}
const fieldLabels = {
request_id: '请求 ID',
trace_id: 'Trace ID',
span_id: 'Span ID',
client_ip: '客户端 IP',
ip: 'IP',
user_id: '用户',
authority_id: '角色',
rows: '影响行数',
elapsed_ms: 'SQL 耗时',
provider: '渠道',
trade_no: '订单号',
error: '错误',
device_id: '设备'
}
const hiddenStructuredFields = new Set([
'level', 'ts', 'caller', 'msg', 'mod', 'sql', 'gorm_logger',
'http_method', 'http_path', 'http_route', 'http_status', 'latency_ms',
'bytes_in', 'bytes_out', 'ua', 'req_headers', 'req_body', 'resp_data',
'service.id', 'service.name', 'service.version', 'node', 'app_id', 'env'
])
function displayFieldValue(value) {
if (typeof value === 'boolean') return value ? '是' : '否'
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
function structuredFields(line) {
if (!line.json) return []
return Object.entries(line.json)
.filter(([key, value]) => !hiddenStructuredFields.has(key) && value !== '' && value != null)
.slice(0, 10)
.map(([key, value]) => ({
key,
label: fieldLabels[key] || key,
value: displayFieldValue(value)
}))
}
function highlightedSegments(text) {
const keyword = searchText.value.trim()
if (!keyword) return [{ text, match: false }]
@@ -656,4 +790,179 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateRootHeight))
color: rgb(var(--primary-color));
font-weight: 500;
}
.log-view-mode {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border: 1px solid rgb(var(--border-color));
border-radius: 7px;
background: rgb(var(--muted-color) / 0.35);
}
.log-view-mode button {
height: 26px;
padding: 0 9px;
border: 0;
border-radius: 5px;
background: transparent;
color: rgb(var(--muted-foreground-color));
font-size: 12px;
cursor: pointer;
}
.log-view-mode button:hover,
.log-view-mode button.active {
background: rgb(var(--container-bg-color));
color: rgb(var(--primary-color));
}
.log-structured-list {
min-width: 760px;
padding: 8px 12px 28px;
}
.log-entry {
position: relative;
margin: 0 0 8px;
overflow: hidden;
border: 1px solid rgb(var(--container-bg-color) / 0.18);
border-left: 3px solid rgb(var(--muted-foreground-color) / 0.45);
border-radius: 7px;
background: rgb(var(--container-bg-color) / 0.06);
}
.log-entry:hover {
background: rgb(var(--container-bg-color) / 0.11);
}
.log-entry.entry-error { border-left-color: rgb(var(--error-color)); }
.log-entry.entry-warn { border-left-color: rgb(var(--warning-color)); }
.log-entry.entry-debug { border-left-color: rgb(var(--info-color)); }
.log-entry.entry-info { border-left-color: rgb(var(--success-color) / 0.7); }
.log-entry-main {
padding: 10px 12px 11px;
}
.log-entry-heading,
.log-metrics,
.log-fields {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
}
.log-entry-heading {
min-height: 20px;
color: rgb(var(--muted-foreground-color));
font-size: 11px;
}
.log-line-number {
color: rgb(var(--muted-foreground-color) / 0.7);
font-variant-numeric: tabular-nums;
}
.log-level {
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: .04em;
}
.level-error, .level-dpanic, .level-panic, .level-fatal { background: rgb(var(--error-color) / .14); color: rgb(var(--error-color)); }
.level-warn { background: rgb(var(--warning-color) / .16); color: rgb(var(--warning-color)); }
.level-debug { background: rgb(var(--info-color) / .14); color: rgb(var(--info-color)); }
.level-info { background: rgb(var(--success-color) / .14); color: rgb(var(--success-color)); }
.log-time,
.log-caller,
.log-module {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.log-module {
color: rgb(var(--primary-color));
font-weight: 600;
}
.log-message {
margin-top: 5px;
color: rgb(var(--base-text-color));
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 13px;
font-weight: 600;
line-height: 1.55;
}
.log-sql {
margin-top: 8px;
padding: 8px 10px;
overflow-x: auto;
border: 1px solid rgb(var(--primary-color) / .12);
border-radius: 5px;
background: rgb(var(--primary-color) / .06);
color: rgb(var(--base-text-color));
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.log-metrics {
margin-top: 8px;
color: rgb(var(--muted-foreground-color));
font-size: 11px;
}
.log-metrics span {
padding: 3px 7px;
border-radius: 4px;
background: rgb(var(--muted-color) / .52);
}
.log-metrics b {
color: rgb(var(--base-text-color));
font-weight: 700;
}
.log-fields {
margin-top: 8px;
align-items: stretch;
}
.log-field {
display: inline-flex;
max-width: 320px;
gap: 5px;
padding: 3px 7px;
border-radius: 4px;
background: rgb(var(--muted-color) / .38);
color: rgb(var(--muted-foreground-color));
font-size: 11px;
}
.log-field b {
color: rgb(var(--base-text-color) / .78);
font-weight: 600;
}
.log-field span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-fallback {
margin: 0;
padding: 10px 12px;
color: rgb(var(--base-text-color));
font-size: 12px;
line-height: 1.7;
}
</style>