chore: restructure project and add i18n support
- Reorganize project structure with new electron and shared directories - Add comprehensive i18n support with Chinese, English, and Japanese locales - Update build configurations and TypeScript paths for new structure - Add various UI components including chat interface and task management - Include Windows release binaries and localization files - Update dependencies and fix import paths throughout the codebase
This commit is contained in:
5
src/pages/home/components/ChatAIMark.vue
Normal file
5
src/pages/home/components/ChatAIMark.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="mt-2 text-xs text-gray-400 ">
|
||||
本回答由 AI 生成
|
||||
</div>
|
||||
</template>
|
||||
34
src/pages/home/components/ChatAttach.vue
Normal file
34
src/pages/home/components/ChatAttach.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="tag-flex flex-wrap pt-3">
|
||||
<div class="inline-flex items-center justify-center box-border border border-[#E5E8EE] rounded-lg py-0.5 px-2.5 mr-2 mb-2"
|
||||
v-for="(item, index) in questionList" :key="index" @click="handleClick(item)">
|
||||
<span class="tag-text-[#2d91ff] text-[10px]">{{ item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { onMounted } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
question: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const questionList = ref<string[]>([]);
|
||||
|
||||
// 定义 emit 事件,向父组件发送选中的 tag
|
||||
const emit = defineEmits<{ (e: 'select', tag: string): void }>();
|
||||
|
||||
const handleClick = (item: string) => {
|
||||
emit('select', item);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
questionList.value = props.question.split(/[&|;]/).filter((tag) => tag.trim() !== "");
|
||||
});
|
||||
|
||||
</script>
|
||||
13
src/pages/home/components/ChatAvatar.vue
Normal file
13
src/pages/home/components/ChatAvatar.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<img class="w-9 h-9 rounded-full shrink-0" :src="src" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
src?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
src: '@assets/images/login/blue_logo.png'
|
||||
})
|
||||
</script>
|
||||
48
src/pages/home/components/ChatInputArea.vue
Normal file
48
src/pages/home/components/ChatInputArea.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="h-[174px] bg-white rounded-lg border border-[#eef2f6] shadow-[0_1px_0_rgba(0,0,0,0.03)] p-4 mt-2 flex flex-col justify-between">
|
||||
<textarea
|
||||
rows="2"
|
||||
placeholder="给我发布或者布置任务"
|
||||
class="flex-1 resize-none outline-none text-sm"
|
||||
:value="modelValue"
|
||||
@input="onInput"
|
||||
@keydown.enter="onKeydownEnter"
|
||||
/>
|
||||
|
||||
<div class="flex justify-between items-end">
|
||||
<button @click="onAttach">
|
||||
<RiLink />
|
||||
</button>
|
||||
<button class="w-12 h-12 bg-[#F5F7FA] px-2.5 py-1.5 rounded-md flex items-center justify-center" @click="onSend">
|
||||
<RiStopFill v-if="isSendingMessage" />
|
||||
<RiSendPlaneFill v-else />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
import { RiLink, RiSendPlaneFill, RiStopFill } from '@remixicon/vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
isSendingMessage: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'send', 'attach'])
|
||||
|
||||
const onInput = (e: Event) => {
|
||||
const v = (e.target as HTMLTextAreaElement).value
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
|
||||
const onKeydownEnter = (e: KeyboardEvent) => {
|
||||
if ((e as KeyboardEvent).shiftKey) return
|
||||
e.preventDefault()
|
||||
emit('send')
|
||||
}
|
||||
|
||||
const onAttach = () => emit('attach')
|
||||
const onSend = () => emit('send')
|
||||
</script>
|
||||
50
src/pages/home/components/ChatLoading.vue
Normal file
50
src/pages/home/components/ChatLoading.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="wave">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script></script>
|
||||
|
||||
<style scoped>
|
||||
.wave {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
width: 30px;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
margin-right: 3px;
|
||||
background: #333333;
|
||||
animation: wave 1.3s linear infinite;
|
||||
}
|
||||
|
||||
.dot:nth-child(2) {
|
||||
animation-delay: -1.1s;
|
||||
}
|
||||
|
||||
.dot:nth-child(3) {
|
||||
animation-delay: -0.9s;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: initial;
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
65
src/pages/home/components/ChatNameTime.vue
Normal file
65
src/pages/home/components/ChatNameTime.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="flex items-start gap-2 pt-0.5 mb-2" :class="props.showReverse ? 'flex-row-reverse' : 'flex-row'">
|
||||
<span class="text-xs text-[#4E5969]">{{ props.msg?.messageRole === MessageRole.AI ? 'NIANXX' : '我' }}</span>
|
||||
<span class="text-xs text-[#86909C]">{{ formattedTime }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { ChatMessage, MessageRole } from '../model/ChatModel'
|
||||
|
||||
interface Props {
|
||||
msg?: ChatMessage
|
||||
showReverse?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showReverse: false
|
||||
})
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
const tsRaw = props.msg?.timestamp
|
||||
if (tsRaw == null) return ''
|
||||
let ts = Number(tsRaw)
|
||||
if (isNaN(ts)) return ''
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
// Heuristic:
|
||||
// - If ts < 1e9, treat as a duration in seconds and convert to dd-hh-mm (legacy)
|
||||
// - If ts looks like an epoch (seconds or ms) format to YYYY年MM月DD日 HH:mm:ss
|
||||
if (ts < 1e9) {
|
||||
const totalSeconds = Math.floor(ts)
|
||||
const days = Math.floor(totalSeconds / 86400)
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
return `${String(days).padStart(2, '0')}-${pad(hours)}-${pad(minutes)}`
|
||||
}
|
||||
|
||||
// epoch handling: convert seconds -> ms when appropriate
|
||||
if (ts < 1e12) ts = ts * 1000
|
||||
const d = new Date(ts)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
const Y = d.getFullYear()
|
||||
const M = pad(d.getMonth() + 1)
|
||||
const D = pad(d.getDate())
|
||||
const h = pad(d.getHours())
|
||||
const m = pad(d.getMinutes())
|
||||
const s = pad(d.getSeconds())
|
||||
|
||||
// If the timestamp is the same calendar day as today, show only time HH:mm:ss
|
||||
const now = new Date()
|
||||
const sameDay = now.getFullYear() === d.getFullYear()
|
||||
&& now.getMonth() === d.getMonth()
|
||||
&& now.getDate() === d.getDate()
|
||||
|
||||
if (sameDay) {
|
||||
return `${h}:${m}:${s}`
|
||||
}
|
||||
|
||||
// otherwise show YYYY-MM-DD HH:mm:ss
|
||||
return `${Y}-${M}-${D} ${h}:${m}:${s}`
|
||||
})
|
||||
|
||||
</script>
|
||||
43
src/pages/home/components/ChatOperation.vue
Normal file
43
src/pages/home/components/ChatOperation.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="mt-4 text-gray-500 flex items-center justify-between gap-4 ">
|
||||
<RiFileCopyLine size="16px" @click="copyFileClick()" />
|
||||
<div class="flex items-center gap-4">
|
||||
<RiShareForwardLine size="16px" @click="shareForwardClick()" />
|
||||
<RiDownload2Line size="16px" @click="downloadClick()" />
|
||||
<RiThumbUpLine size="16px" @click="thumbUpClick()" />
|
||||
<RiThumbDownLine size="16px" @click="thumbDownClick()" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { RiFileCopyLine, RiShareForwardLine, RiDownload2Line, RiThumbUpLine, RiThumbDownLine } from '@remixicon/vue'
|
||||
import { ChatMessage } from '../model/ChatModel';
|
||||
|
||||
interface Props {
|
||||
msg: ChatMessage
|
||||
}
|
||||
|
||||
const { msg } = defineProps<Props>()
|
||||
|
||||
/// actions 实现复制、分享、下载、点赞等功能
|
||||
const copyFileClick = () => {
|
||||
console.log('copy file', msg)
|
||||
}
|
||||
|
||||
const shareForwardClick = () => {
|
||||
console.log('share forward', msg)
|
||||
}
|
||||
|
||||
const downloadClick = () => {
|
||||
console.log('download', msg)
|
||||
}
|
||||
|
||||
const thumbUpClick = () => {
|
||||
console.log('thumb up', msg)
|
||||
}
|
||||
|
||||
const thumbDownClick = () => {
|
||||
console.log('thumb down', msg)
|
||||
}
|
||||
|
||||
</script>
|
||||
60
src/pages/home/components/ChatRoleAI.vue
Normal file
60
src/pages/home/components/ChatRoleAI.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="max-w-[75%] flex flex-col">
|
||||
<slot name="header"></slot>
|
||||
<div v-if="!msg.messageContentList || msg.messageContentList.length === 0"
|
||||
class="flex flex-row text-sm text-gray-700">
|
||||
<div v-html="compiledMarkdown"></div>
|
||||
<ChatLoading v-if="msg.isLoading" />
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col p-2 mb-2 text-sm text-gray-700 bg-[#f7f9fc] rounded-md"
|
||||
v-for="(_, index) in msg.messageContentList" :key="index">
|
||||
<div v-html="compiledAt(index)"></div>
|
||||
</div>
|
||||
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ChatMessage } from '../model/ChatModel';
|
||||
import { computed } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import hljs from 'highlight.js'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import ChatLoading from './ChatLoading.vue';
|
||||
|
||||
interface Props {
|
||||
msg: ChatMessage
|
||||
}
|
||||
|
||||
const { msg } = defineProps<Props>()
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
highlight: function (str: string, lang: string) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(str, { language: lang, ignoreIllegals: true }).value;
|
||||
} catch (__) { }
|
||||
}
|
||||
// 自动检测
|
||||
return hljs.highlightAuto(str).value;
|
||||
}
|
||||
});
|
||||
|
||||
const compiledMarkdown = computed(() => md.render(msg.messageContent))
|
||||
|
||||
const compiledList = computed(() => {
|
||||
return (msg.messageContentList || []).map((m: string) => md.render(m || ''))
|
||||
})
|
||||
|
||||
const compiledAt = (index: number): string => {
|
||||
const list: string[] = (compiledList as any).value || []
|
||||
if (list[index]) return list[index]
|
||||
const raw = msg?.messageContentList?.[index] || ''
|
||||
return md.render(raw || '')
|
||||
}
|
||||
|
||||
</script>
|
||||
20
src/pages/home/components/ChatRoleMe.vue
Normal file
20
src/pages/home/components/ChatRoleMe.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="max-w-[75%]">
|
||||
<slot name="header"></slot>
|
||||
<div class="text-sm text-gray-700 bg-[#f7f9fc] rounded-md px-2 py-2">
|
||||
{{ msg.messageContent }}
|
||||
</div>
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ChatMessage } from '../model/ChatModel';
|
||||
|
||||
interface Props {
|
||||
msg: ChatMessage
|
||||
}
|
||||
|
||||
const { msg } = defineProps<Props>()
|
||||
|
||||
</script>
|
||||
119
src/pages/home/components/TaskOperationDialog.vue
Normal file
119
src/pages/home/components/TaskOperationDialog.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<el-dialog v-model="isVisible" :title="title" width="480" align-center>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-position="top" class="pl-4 pr-4 pt-4">
|
||||
<el-form-item label="选择房型" prop="roomType">
|
||||
<el-select v-model="form.roomType" placeholder="请选择房型">
|
||||
<el-option v-for="item in roomList" :label="item.pmsName" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择日期" prop="range">
|
||||
<el-date-picker v-model="form.range" type="daterange" value-format="YYYY-MM-DD" placeholder="请选择日期"
|
||||
style="width: 100%">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="cancel">取消</el-button>
|
||||
<el-button type="primary" @click="confirm">确认</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { taskCenterItem } from '@constant/taskCenterList'
|
||||
import { hotelStaffTypeMappingListUsingPost } from '@api/index'
|
||||
|
||||
const isVisible = ref(false)
|
||||
const roomList: any = ref([])
|
||||
const title = ref('')
|
||||
const formRef = ref()
|
||||
const form = ref({
|
||||
roomType: '',
|
||||
operation: '',
|
||||
range: [],
|
||||
})
|
||||
const rules = ref({
|
||||
roomType: [
|
||||
{ required: true, message: '请选择房型', trigger: 'blur' },
|
||||
],
|
||||
range: [
|
||||
{
|
||||
required: true,
|
||||
message: '请选择日期范围',
|
||||
trigger: 'change',
|
||||
type: 'array'
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
// 打开弹窗
|
||||
const open = ({ type }: taskCenterItem) => {
|
||||
title.value = type === 'open' ? '开启渠道房型' : '关闭渠道房型'
|
||||
isVisible.value = true
|
||||
form.value.operation = type
|
||||
getRoomTypeList()
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const close = () => {
|
||||
isVisible.value = false
|
||||
}
|
||||
|
||||
// 重置form
|
||||
const reset = () => {
|
||||
form.value.roomType = ''
|
||||
form.value.range = []
|
||||
|
||||
formRef.value.resetFields()
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const cancel = () => {
|
||||
close()
|
||||
reset()
|
||||
}
|
||||
|
||||
// 确认操作
|
||||
const confirm = () => {
|
||||
formRef.value.validate((valid: boolean) => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
close()
|
||||
|
||||
// 将roomList.value数组处理成标准数组
|
||||
const newList = roomList.value.map((item: any) => ({ ...item }))
|
||||
|
||||
const options = {
|
||||
roomType: form.value.roomType,
|
||||
startTime: form.value.range[0],
|
||||
endTime: form.value.range[1],
|
||||
operation: form.value.operation,
|
||||
roomList: newList
|
||||
}
|
||||
|
||||
console.log(options)
|
||||
/**
|
||||
* 坑:传给进程的参数不能是ref包裹的reactive对象
|
||||
*/
|
||||
window.api.executeScript(options)
|
||||
|
||||
reset()
|
||||
})
|
||||
}
|
||||
|
||||
// 获取房型列表
|
||||
const getRoomTypeList = async () => {
|
||||
const res = await hotelStaffTypeMappingListUsingPost({ body: {} })
|
||||
roomList.value = res.data
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user