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:
2026-04-29 23:36:40 +08:00
parent f98ad7953f
commit ecc0693cd7
3 changed files with 1609 additions and 130 deletions

View File

@@ -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
View 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>