feat: improve ChatView, add LoginView and demo reference page
- ChatView: enhanced AI chat interface with improved message rendering - LoginView: new login page component - demo/main_demo.html: reference implementation for Chart.js dashboards Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -1,81 +1,157 @@
|
||||
<template>
|
||||
<section class="view chat-view">
|
||||
<article class="panel chat-shell">
|
||||
<header class="chat-hero">
|
||||
<div>
|
||||
<div class="eyebrow">Compliance conversation</div>
|
||||
<h2>上传单据,询问 AI 是否合规</h2>
|
||||
<p>把发票、行程单、合同附件或审批说明放到同一个上下文里,AI 会按制度、预算和审计留痕给出建议。</p>
|
||||
</div>
|
||||
<label class="upload-card">
|
||||
<span class="upload-icon" v-html="fileIcon"></span>
|
||||
<strong>上传单据</strong>
|
||||
<small>PDF、图片、Excel 或压缩包</small>
|
||||
<input type="file" multiple @change="emit('upload', $event)" />
|
||||
</label>
|
||||
</header>
|
||||
<section class="view full">
|
||||
<div class="queue-panel">
|
||||
<DataTable
|
||||
:value="documents"
|
||||
:paginator="true"
|
||||
:rows="pageSize"
|
||||
:rowsPerPageOptions="[5, 10, 20, 50]"
|
||||
:totalRecords="documents.length"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown JumpToPageInput"
|
||||
currentPageReportTemplate="共 {totalRecords} 条"
|
||||
stripedRows
|
||||
size="small"
|
||||
:rowHover="false"
|
||||
tableStyle="min-width: 960px"
|
||||
>
|
||||
<Column field="id" header="单据编号">
|
||||
<template #body="{ data }">
|
||||
<strong>{{ data.id }}</strong>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<div class="upload-list" aria-live="polite">
|
||||
<span v-for="file in uploadedFiles" :key="file.name" class="file-pill">{{ file.name }}</span>
|
||||
<span v-if="!uploadedFiles.length" class="file-pill muted">尚未上传文件,可直接选择现有报销单追问。</span>
|
||||
</div>
|
||||
<Column field="type" header="申请类型">
|
||||
<template #body="{ data }">
|
||||
<span class="type-tag" :class="data.typeTag">{{ data.type }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<div class="dialog-body chat-body">
|
||||
<section
|
||||
v-motion
|
||||
class="review-summary"
|
||||
aria-label="审核摘要"
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:enter="{ opacity: 1, y: 0, transition: { delay: 0.08, duration: 0.3 } }"
|
||||
>
|
||||
<div class="risk-ring"><strong>82</strong><span>可信分</span></div>
|
||||
<Column field="applicant" header="申请人">
|
||||
<template #body="{ data }">
|
||||
<strong>{{ data.applicant }}</strong>
|
||||
<p class="sub-text">{{ data.dept }}</p>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="destination" header="目的地 / 行程" />
|
||||
|
||||
<Column field="date" header="申请日期" sortable />
|
||||
|
||||
<Column field="amount" header="金额" sortable>
|
||||
<template #body="{ data }">
|
||||
<strong>{{ data.amount }}</strong>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="status" header="状态">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="statusSeverity(data.statusClass)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="conclusion" header="AI 结论">
|
||||
<template #body="{ data }">
|
||||
<span class="conclusion" :class="data.statusClass">{{ data.conclusion }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="操作" style="width: 120px">
|
||||
<template #body="{ data }">
|
||||
<div class="row-actions">
|
||||
<Button label="对话" icon="pi pi-comment" size="small" severity="secondary" rounded @click="openCaseDialog(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- AI Travel Assistant Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="dialogOpen"
|
||||
:modal="true"
|
||||
:draggable="false"
|
||||
:closable="true"
|
||||
:header="dialogCase ? dialogCase.type + ' · ' + dialogCase.id : '智能差旅助手'"
|
||||
:style="{ width: '720px' }"
|
||||
:breakpoints="{ '760px': '95vw' }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="dialog-header-custom">
|
||||
<div>
|
||||
<h3>建议有条件通过</h3>
|
||||
<p>发票验真与预算归属通过,当前风险集中在 {{ activeCase?.risk }}。</p>
|
||||
<div class="summary-pills">
|
||||
<span>制度命中 3</span>
|
||||
<span>附件完整度 86%</span>
|
||||
<span>SLA {{ activeCase?.sla }}</span>
|
||||
</div>
|
||||
<span class="eyebrow">AI Travel Assistant</span>
|
||||
<h3>{{ dialogCase ? dialogCase.type + ' · ' + dialogCase.id : '智能差旅助手' }}</h3>
|
||||
<p v-if="dialogCase">{{ dialogCase.applicant }} · {{ dialogCase.destination }} · {{ dialogCase.days }}天</p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="messages" ref="messageList" aria-live="polite">
|
||||
<TransitionGroup name="message-list">
|
||||
<div v-for="message in messages" :key="message.id" class="message" :class="message.role">
|
||||
<span>{{ message.role === 'user' ? 'Reviewer' : 'Finance AI' }}</span>
|
||||
<p>{{ message.text }}</p>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
<aside class="case-panel">
|
||||
<h3>单据上下文</h3>
|
||||
<dl>
|
||||
<div><dt>单据编号</dt><dd>{{ activeCase?.id }}</dd></div>
|
||||
<div><dt>申请人</dt><dd>{{ activeCase?.person }}</dd></div>
|
||||
<div><dt>金额</dt><dd>{{ activeCase?.amount }}</dd></div>
|
||||
<div><dt>风险点</dt><dd>{{ activeCase?.risk }}</dd></div>
|
||||
</dl>
|
||||
<h3>快捷追问</h3>
|
||||
<div class="quick-prompts">
|
||||
<button v-for="prompt in quickPrompts" :key="prompt" class="chip" type="button" @click="emit('draft', prompt)">{{ prompt }}</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<!-- Quick Service Cards (for new request) -->
|
||||
<div v-if="!dialogCase" class="service-cards">
|
||||
<button v-for="svc in services" :key="svc.label" class="service-card" @click="applyService(svc)">
|
||||
<i :class="svc.piIcon" class="svc-icon"></i>
|
||||
<strong>{{ svc.label }}</strong>
|
||||
<small>{{ svc.desc }}</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer class="dialog-foot chat-foot">
|
||||
<textarea v-model="localDraft" placeholder="询问合规问题,例如:这张住宿发票是否超标?需要补哪些材料?" @keydown.ctrl.enter.prevent="emit('send')"></textarea>
|
||||
<button class="btn success" type="button" @click="emit('approveCase')">通过</button>
|
||||
<button class="btn danger" type="button" @click="emit('rejectCase')">转人工</button>
|
||||
<button class="btn primary" type="button" @click="emit('send')">发送</button>
|
||||
</footer>
|
||||
</article>
|
||||
<!-- Case Summary -->
|
||||
<div v-if="dialogCase" class="review-summary">
|
||||
<div class="risk-ring">
|
||||
<strong>{{ dialogCase.status === '已通过' || dialogCase.status === '已完成' ? '96' : '78' }}</strong>
|
||||
<span>合规分</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4>{{ dialogCase.conclusion }}</h4>
|
||||
<p>{{ dialogCase.type }} · {{ dialogCase.destination }} · {{ dialogCase.amount }} · {{ dialogCase.days }}天行程</p>
|
||||
<div class="summary-pills">
|
||||
<span>{{ dialogCase.date }}</span>
|
||||
<span>{{ dialogCase.status }}</span>
|
||||
<span>{{ dialogCase.applicant }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="messages" ref="messageList" aria-live="polite">
|
||||
<TransitionGroup name="msg-list">
|
||||
<div v-for="message in messages" :key="message.id" class="message" :class="message.role">
|
||||
<span>{{ message.role === 'user' ? '你' : '差旅助手' }}</span>
|
||||
<p>{{ message.text }}</p>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
<div v-if="!messages.length" class="messages-empty">
|
||||
<p>告诉我您的差旅需求,我来帮您安排 ✈️</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Prompts -->
|
||||
<div class="quick-bar">
|
||||
<Button v-for="p in quickPrompts" :key="p" :label="p" size="small" severity="secondary" rounded outlined @click="applyPrompt(p)" />
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="dialog-input">
|
||||
<Textarea v-model="localDraft" rows="2" autoResize placeholder="描述您的差旅需求…" @keydown.ctrl.enter.prevent="emit('send')" />
|
||||
<div class="input-actions">
|
||||
<Button label="发送" icon="pi pi-send" size="small" @click="emit('send')" />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { icons } from '../data/icons.js'
|
||||
import { ref, computed } from 'vue'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import Button from 'primevue/button'
|
||||
import Tag from 'primevue/tag'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Textarea from 'primevue/textarea'
|
||||
|
||||
const props = defineProps({
|
||||
documents: { type: Array, required: true },
|
||||
docSearch: { type: String, default: '' },
|
||||
messages: { type: Array, required: true },
|
||||
uploadedFiles: { type: Array, required: true },
|
||||
activeCase: { type: Object, default: null },
|
||||
@@ -84,84 +160,156 @@ const props = defineProps({
|
||||
messageList: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['send', 'upload', 'draft', 'approveCase', 'rejectCase', 'update:draft'])
|
||||
const emit = defineEmits(['send', 'upload', 'draft', 'approveCase', 'rejectCase', 'update:draft', 'selectCase'])
|
||||
|
||||
const fileIcon = icons.file
|
||||
const dialogOpen = ref(false)
|
||||
const dialogCase = ref(null)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const services = [
|
||||
{ label: '出差申请', desc: '提交出差申请单', piIcon: 'pi pi-file', prompt: '我需要提交一份出差申请' },
|
||||
{ label: '预订机票', desc: '查询并预订机票', piIcon: 'pi pi-send', prompt: '帮我预订机票' },
|
||||
{ label: '预订酒店', desc: '查询并预订酒店', piIcon: 'pi pi-building', prompt: '帮我预订酒店' },
|
||||
{ label: '预订火车票', desc: '查询并预订火车票', piIcon: 'pi pi-map-marker', prompt: '帮我预订火车票' }
|
||||
]
|
||||
|
||||
import { computed } from 'vue'
|
||||
const localDraft = computed({
|
||||
get: () => props.draft,
|
||||
set: (val) => emit('update:draft', val)
|
||||
})
|
||||
|
||||
function statusSeverity(cls) {
|
||||
if (cls === 'success') return 'success'
|
||||
if (cls === 'danger') return 'danger'
|
||||
return 'warn'
|
||||
}
|
||||
|
||||
function openCaseDialog(doc) {
|
||||
dialogCase.value = doc
|
||||
emit('selectCase', doc)
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function applyService(svc) {
|
||||
emit('draft', svc.prompt)
|
||||
}
|
||||
|
||||
function applyPrompt(p) {
|
||||
emit('draft', p)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-view { max-width: 1280px; }
|
||||
/* ── List View ──────────────────────────────────── */
|
||||
.view { display: grid; gap: 22px; animation: fadeUp 220ms var(--ease) both; }
|
||||
.chat-shell { min-height: calc(100dvh - 210px); display: grid; grid-template-rows: auto auto minmax(420px, 1fr) auto; overflow: hidden; padding: 0; }
|
||||
.chat-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0,1fr) 260px;
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
padding: 24px;
|
||||
.view.full { width: 100%; }
|
||||
.queue-panel {
|
||||
border: 1px solid var(--line); border-radius: var(--radius);
|
||||
background: var(--surface); overflow: hidden;
|
||||
}
|
||||
|
||||
.sub-text { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
||||
|
||||
/* Type tags */
|
||||
.type-tag {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 3px 10px; border-radius: 999px;
|
||||
font-size: 12px; font-weight: 750; white-space: nowrap;
|
||||
}
|
||||
.type-tag.travel { background: var(--primary-soft); color: var(--primary); }
|
||||
.type-tag.flight { background: #eef3ff; color: #335cff; }
|
||||
.type-tag.hotel { background: var(--warning-soft); color: var(--warning); }
|
||||
.type-tag.train { background: var(--success-soft); color: var(--success); }
|
||||
|
||||
.conclusion { color: var(--muted); font-size: 12px; }
|
||||
.conclusion.success { color: var(--success); }
|
||||
.conclusion.warning { color: var(--warning); }
|
||||
.conclusion.danger { color: var(--danger); }
|
||||
|
||||
.row-actions { display: flex; gap: 6px; }
|
||||
|
||||
/* ── Dialog Custom Header ───────────────────────── */
|
||||
.dialog-header-custom h3 { margin: 4px 0 0; color: var(--ink); font-size: 20px; }
|
||||
.dialog-header-custom p { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
|
||||
/* ── Service Cards ──────────────────────────────── */
|
||||
.service-cards {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.service-card {
|
||||
display: grid; gap: 6px; place-items: center; text-align: center;
|
||||
padding: 16px 8px; border: 1px solid var(--line); border-radius: var(--radius);
|
||||
background: #fff; cursor: pointer;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
.service-card:hover { border-color: rgba(51,92,255,.28); box-shadow: 0 8px 24px rgba(51,92,255,.08); }
|
||||
.svc-icon { font-size: 22px; color: var(--primary); }
|
||||
.service-card strong { color: var(--ink); font-size: 13px; }
|
||||
.service-card small { color: var(--muted); font-size: 11px; }
|
||||
|
||||
/* ── Summary ────────────────────────────────────── */
|
||||
.review-summary {
|
||||
display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 16px;
|
||||
padding: 14px 0; margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background:
|
||||
radial-gradient(circle at 12% 10%, rgba(51,92,255,.14), transparent 28%),
|
||||
linear-gradient(135deg, #fff, #f7fbff);
|
||||
}
|
||||
.chat-hero h2 { margin-top: 4px; color: var(--ink); font-size: 28px; }
|
||||
.chat-hero p { max-width: 780px; margin-top: 8px; color: var(--muted); line-height: 1.6; }
|
||||
.upload-card {
|
||||
position: relative;
|
||||
min-height: 148px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border: 1px dashed rgba(51,92,255,.36);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255,255,255,.72);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: transform 180ms var(--ease), border-color 180ms var(--ease), box-shadow 180ms var(--ease);
|
||||
.risk-ring {
|
||||
width: 68px; aspect-ratio: 1; display: grid; place-items: center;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle,#fff 0 55%,transparent 56%), conic-gradient(var(--success) 0 82%, #e4e7ec 82% 100%);
|
||||
}
|
||||
.upload-card:hover { transform: translateY(-2px); border-color: var(--primary); box-shadow: 0 18px 42px rgba(51,92,255,.12); }
|
||||
.upload-card input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.upload-icon { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 12px; background: var(--primary-soft); color: var(--primary); }
|
||||
.upload-icon svg { width: 20px; height: 20px; stroke: currentColor; stroke-width: 2; fill: none; }
|
||||
.upload-card strong { color: var(--ink); }
|
||||
.upload-card small { color: var(--muted); }
|
||||
.upload-list { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 24px; border-bottom: 1px solid var(--line); background: #fff; }
|
||||
.file-pill { min-height: 30px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; background: var(--success-soft); color: var(--success); font-size: 12px; font-weight: 750; }
|
||||
.file-pill.muted { background: var(--surface-soft); color: var(--muted); }
|
||||
.dialog-body { min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 300px; grid-template-rows: auto minmax(0,1fr); }
|
||||
.chat-body { border-bottom: 1px solid var(--line); }
|
||||
.review-summary { grid-column: 1 / -1; display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 16px; padding: 18px 24px; border-bottom: 1px solid var(--line); background: #fff; }
|
||||
.risk-ring { width: 82px; aspect-ratio: 1; display: grid; place-items: center; border-radius: 50%; background: radial-gradient(circle,#fff 0 55%,transparent 56%), conic-gradient(var(--success) 0 82%, #e4e7ec 82% 100%); }
|
||||
.risk-ring strong { color: var(--ink); font-size: 24px; line-height: 1; }
|
||||
.risk-ring span { color: var(--muted); font-size: 11px; }
|
||||
.review-summary h3 { color: var(--ink); }
|
||||
.review-summary p { margin-top: 5px; color: var(--muted); }
|
||||
.summary-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.summary-pills span { min-height: 28px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; background: var(--primary-soft); color: var(--primary); font-size: 12px; font-weight: 750; }
|
||||
.messages { min-height: 0; overflow: auto; display: grid; align-content: start; gap: 12px; padding: 22px 24px; background: linear-gradient(180deg,#fbfcff,#f6f8fb); }
|
||||
.message { max-width: 82%; display: grid; gap: 6px; will-change: transform, opacity; }
|
||||
.risk-ring strong { color: var(--ink); font-size: 20px; line-height: 1; }
|
||||
.risk-ring span { color: var(--muted); font-size: 10px; }
|
||||
.review-summary h4 { color: var(--ink); font-size: 14px; }
|
||||
.review-summary p { margin-top: 3px; color: var(--muted); font-size: 13px; }
|
||||
.summary-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.summary-pills span {
|
||||
min-height: 24px; display: inline-flex; align-items: center;
|
||||
padding: 0 8px; border-radius: 999px;
|
||||
background: var(--primary-soft); color: var(--primary); font-size: 11px; font-weight: 750;
|
||||
}
|
||||
|
||||
/* ── Messages ───────────────────────────────────── */
|
||||
.messages {
|
||||
min-height: 160px; max-height: 360px; overflow-y: auto;
|
||||
display: grid; align-content: start; gap: 12px;
|
||||
padding: 14px 0; background: linear-gradient(180deg, #fbfcff, #f6f8fb);
|
||||
border-radius: var(--radius); margin-bottom: 12px;
|
||||
}
|
||||
.message { max-width: 82%; display: grid; gap: 4px; }
|
||||
.message.user { justify-self: end; }
|
||||
.message span { color: var(--muted); font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.message p { padding: 13px 15px; border: 1px solid var(--line); border-radius: 16px 16px 16px 6px; background: #fff; box-shadow: 0 8px 24px rgba(16,24,40,.05); }
|
||||
.message.user p { border-color: transparent; border-radius: 16px 16px 6px 16px; background: linear-gradient(135deg,var(--primary),#2446d8); color: #fff; box-shadow: 0 14px 30px rgba(51,92,255,.20); }
|
||||
.case-panel { overflow: auto; padding: 22px; border-left: 1px solid var(--line); background: rgba(255,255,255,.72); }
|
||||
.case-panel h3 { margin: 0 0 12px; color: var(--ink); }
|
||||
dl { margin: 0 0 18px; display: grid; gap: 10px; }
|
||||
dl div { display: flex; justify-content: space-between; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
|
||||
dt { color: var(--muted); font-size: 12px; }
|
||||
dd { margin: 0; color: var(--ink); font-weight: 750; text-align: right; }
|
||||
.quick-prompts { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.chip { min-height: 34px; padding: 0 10px; border: 1px solid var(--line); border-radius: 999px; background: #fff; color: var(--text); font-size: 12px; font-weight: 700; transition: transform 160ms var(--ease), border-color 160ms var(--ease), background 160ms var(--ease); }
|
||||
.chip:hover { border-color: rgba(51,92,255,.28); background: var(--primary-soft); color: var(--primary); }
|
||||
.dialog-foot { display: grid; grid-template-columns: minmax(0,1fr) auto auto auto; gap: 10px; padding: 16px 20px; border-top: 1px solid var(--line); background: #fff; }
|
||||
.chat-foot { border-top: 0; }
|
||||
textarea { min-height: 48px; max-height: 116px; resize: vertical; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.message span { color: var(--muted); font-size: 10px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.message p {
|
||||
padding: 12px 14px; border: 1px solid var(--line);
|
||||
border-radius: 14px 14px 14px 4px; background: #fff;
|
||||
font-size: 13px; line-height: 1.6; white-space: pre-line;
|
||||
}
|
||||
.message.user p {
|
||||
border-color: transparent; border-radius: 14px 14px 4px 14px;
|
||||
background: linear-gradient(135deg, var(--primary), #2446d8);
|
||||
color: #fff;
|
||||
}
|
||||
.messages-empty { display: grid; place-items: center; padding: 32px; }
|
||||
.messages-empty p { color: var(--muted); font-size: 13px; }
|
||||
.msg-list-enter-active { transition: opacity 200ms var(--ease), transform 200ms var(--ease); }
|
||||
.msg-list-leave-active { transition: opacity 120ms ease; }
|
||||
.msg-list-enter-from { opacity: 0; transform: translateY(6px); }
|
||||
.msg-list-leave-to { opacity: 0; }
|
||||
|
||||
/* ── Quick Prompts ──────────────────────────────── */
|
||||
.quick-bar { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||
|
||||
/* ── Input ──────────────────────────────────────── */
|
||||
.dialog-input {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px; align-items: start;
|
||||
}
|
||||
.input-actions { display: grid; gap: 6px; padding-top: 2px; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.dialog-input { grid-template-columns: 1fr; }
|
||||
.input-actions { display: flex; }
|
||||
.service-cards { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
</style>
|
||||
|
||||
497
src/views/LoginView.vue
Normal file
497
src/views/LoginView.vue
Normal file
@@ -0,0 +1,497 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<!-- Left: Enterprise Brand Hero -->
|
||||
<aside class="hero">
|
||||
<div class="hero-grid"></div>
|
||||
<div class="hero-content">
|
||||
<div class="brand">
|
||||
<div class="logo-box">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="currentColor"/>
|
||||
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="brand-name">X-Financial Ops</span>
|
||||
</div>
|
||||
|
||||
<header class="hero-header">
|
||||
<h1>智能财务报销<br/><span class="text-gradient">全流程运营中台</span></h1>
|
||||
<p>基于 AI Agent 的合规预审与自动化处理引擎,助力企业财务实现数字化治理与运营效率的跨越式提升。</p>
|
||||
</header>
|
||||
|
||||
<div class="value-prop">
|
||||
<div v-for="item in features" :key="item.title" class="prop-item">
|
||||
<div class="prop-icon" :style="{ color: item.color }">
|
||||
<component :is="item.icon" />
|
||||
</div>
|
||||
<div class="prop-text">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<p>{{ item.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-footer">
|
||||
<div class="trust-badge">
|
||||
<div class="avatars">
|
||||
<div v-for="i in 3" :key="i" class="avatar-circle"></div>
|
||||
</div>
|
||||
<span>已为 500+ 大中型企业提供智能化财务转型支持</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview UI Element: Simplified Dashboard Part -->
|
||||
<div class="hero-preview">
|
||||
<div class="preview-card">
|
||||
<div class="preview-header">
|
||||
<div class="dots"><span></span><span></span><span></span></div>
|
||||
<div class="preview-title">系统合规性看板</div>
|
||||
</div>
|
||||
<div class="preview-body">
|
||||
<div class="preview-stat">
|
||||
<div class="stat-circle">
|
||||
<svg viewBox="0 0 36 36">
|
||||
<path class="circle-bg" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
<path class="circle" stroke-dasharray="82, 100" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
</svg>
|
||||
<div class="stat-val">82%</div>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<strong>综合自动通过率</strong>
|
||||
<p>较上月提升 12.4%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-list">
|
||||
<div v-for="i in 3" :key="i" class="list-item">
|
||||
<div class="item-bar" :style="{ width: [85, 62, 45][i-1] + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right: Login Form (Refined) -->
|
||||
<main class="login-main">
|
||||
<div class="login-form-wrap">
|
||||
<header class="login-header">
|
||||
<h2>系统登录</h2>
|
||||
<p>请输入您的凭据以访问控制台</p>
|
||||
</header>
|
||||
|
||||
<form class="login-form" @submit.prevent="emit('login', { username, password })">
|
||||
<div class="input-group">
|
||||
<label>用户名</label>
|
||||
<div class="input-control">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
<input v-model="username" type="text" placeholder="账户名称 / 手机号" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>密码</label>
|
||||
<div class="input-control">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
<input v-model="password" type="password" placeholder="请输入登录密码" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<label class="remember-me">
|
||||
<input type="checkbox" v-model="remember" />
|
||||
<span>记住我</span>
|
||||
</label>
|
||||
<a href="#" class="forgot-link">忘记密码?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn">
|
||||
<span>登录系统</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<footer class="login-footer">
|
||||
<p>© 2026 X-Financial Technology. All Rights Reserved.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, markRaw } from 'vue'
|
||||
|
||||
const emit = defineEmits(['login'])
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const remember = ref(false)
|
||||
|
||||
const ShieldCheck = {
|
||||
template: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>`
|
||||
}
|
||||
const Activity = {
|
||||
template: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>`
|
||||
}
|
||||
const Zap = {
|
||||
template: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`
|
||||
}
|
||||
|
||||
const features = [
|
||||
{ title: '多维合规引擎', desc: '内置 500+ 企业财务制度,实现毫秒级自动预审。', color: '#3b82f6', icon: markRaw(ShieldCheck) },
|
||||
{ title: '全链路风控监控', desc: '覆盖票据验真、重复报销及异常交易的深度挖掘。', color: '#10b981', icon: markRaw(Activity) },
|
||||
{ title: '流程自动化编排', desc: 'AI Agent 驱动的自动分类与审批建议,节省 80% 人力。', color: '#f59e0b', icon: markRaw(Zap) }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Main Layout ─────────────────────────────────── */
|
||||
.login-page {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(600px, 1.2fr) minmax(400px, 0.8fr);
|
||||
background: #fff;
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
/* ── Hero Side ───────────────────────────────────── */
|
||||
.hero {
|
||||
position: relative;
|
||||
background: #0f172a; /* Slate 900 */
|
||||
color: #f8fafc;
|
||||
padding: 60px 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
mask-image: radial-gradient(circle at 20% 30%, black, transparent 70%);
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.logo-box {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #3b82f6;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
.logo-box svg { width: 24px; height: 24px; }
|
||||
.brand-name {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hero-header h1 {
|
||||
font-size: 42px;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
margin-bottom: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #60a5fa, #3b82f6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.hero-header p {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.value-prop {
|
||||
display: grid;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.prop-item {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.prop-icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.prop-icon svg { width: 100%; height: 100%; }
|
||||
|
||||
.prop-text strong {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.prop-text p {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hero-footer {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.trust-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 99px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.avatars {
|
||||
display: flex;
|
||||
}
|
||||
.avatar-circle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #334155;
|
||||
border: 2px solid #0f172a;
|
||||
margin-left: -8px;
|
||||
}
|
||||
.avatar-circle:first-child { margin-left: 0; }
|
||||
|
||||
.trust-badge span {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Hero Preview Element ────────────────────────── */
|
||||
.hero-preview {
|
||||
position: absolute;
|
||||
right: -40px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 440px;
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 40px 80px rgba(0,0,0,0.4);
|
||||
overflow: hidden;
|
||||
animation: floatPreview 6s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes floatPreview {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(-20px); }
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
padding: 12px 16px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.dots { display: flex; gap: 6px; }
|
||||
.dots span { width: 8px; height: 8px; border-radius: 50%; background: #334155; }
|
||||
.preview-title { font-size: 11px; color: #94a3b8; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
.preview-body { padding: 24px; }
|
||||
|
||||
.preview-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-circle {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
.stat-circle svg { transform: rotate(-90deg); }
|
||||
.circle-bg { fill: none; stroke: #334155; stroke-width: 3; }
|
||||
.circle { fill: none; stroke: #3b82f6; stroke-width: 3; stroke-linecap: round; }
|
||||
.stat-val {
|
||||
position: absolute; inset: 0; display: grid; place-items: center;
|
||||
font-size: 14px; font-weight: 800; color: #fff;
|
||||
}
|
||||
|
||||
.stat-info strong { display: block; font-size: 16px; color: #fff; }
|
||||
.stat-info p { font-size: 12px; color: #10b981; margin-top: 2px; }
|
||||
|
||||
.preview-list { display: grid; gap: 12px; }
|
||||
.list-item { height: 12px; background: #334155; border-radius: 6px; overflow: hidden; }
|
||||
.item-bar { height: 100%; background: linear-gradient(90deg, #3b82f6, #60a5fa); border-radius: inherit; }
|
||||
|
||||
/* ── Login Main ──────────────────────────────────── */
|
||||
.login-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.login-form-wrap {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.login-header h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.login-header p {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-control .icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.input-control input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding-left: 40px;
|
||||
padding-right: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.input-control input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.forgot-link {
|
||||
font-size: 13px;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background: #1e293b;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.submit-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
.login-footer p {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.hero { display: none; }
|
||||
.login-page { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user